@mcptoolshop/claude-sfx 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +22 -0
- package/dist/hooks.js +1 -1
- package/dist/profiles.d.ts +93 -25
- package/dist/profiles.js +357 -168
- package/dist/verbs.d.ts +14 -10
- package/dist/verbs.js +367 -262
- package/package.json +1 -1
- package/profiles/minimal.json +902 -123
- package/profiles/retro.json +804 -125
package/dist/verbs.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Verb generation engine — profile-driven.
|
|
3
3
|
* Reads synthesis parameters from a Profile, applies modifiers, generates audio.
|
|
4
|
+
*
|
|
5
|
+
* Architecture: multi-note motifs in A major pentatonic with constrained variation.
|
|
6
|
+
* Each verb has 3 variants. Micro-jitter keeps sounds alive without breaking identity.
|
|
4
7
|
*/
|
|
5
8
|
import { SAMPLE_RATE, generateTone, generateWhoosh, mixBuffers, limitLoudness, } from "./synth.js";
|
|
9
|
+
import { SCALE, scaleStepDown } from "./profiles.js";
|
|
6
10
|
export const VERB_LABELS = {
|
|
7
11
|
intake: "Intake",
|
|
8
12
|
transform: "Transform",
|
|
@@ -22,211 +26,315 @@ export const VERB_DESCRIPTIONS = {
|
|
|
22
26
|
sync: "git push / pull / deploy",
|
|
23
27
|
};
|
|
24
28
|
export const ALL_VERBS = [
|
|
25
|
-
"intake",
|
|
26
|
-
"transform",
|
|
27
|
-
"commit",
|
|
28
|
-
"navigate",
|
|
29
|
-
"execute",
|
|
30
|
-
"move",
|
|
31
|
-
"sync",
|
|
29
|
+
"intake", "transform", "commit", "navigate", "execute", "move", "sync",
|
|
32
30
|
];
|
|
33
|
-
// ---
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
case "ok":
|
|
38
|
-
p.harmonicGain = 0.15;
|
|
39
|
-
break;
|
|
40
|
-
case "err":
|
|
41
|
-
p.frequency *= 0.7;
|
|
42
|
-
if (p.frequencyEnd)
|
|
43
|
-
p.frequencyEnd *= 0.7;
|
|
44
|
-
p.detune = 3;
|
|
45
|
-
p.duration *= 1.3;
|
|
46
|
-
break;
|
|
47
|
-
case "warn":
|
|
48
|
-
p.tremoloRate = 8;
|
|
49
|
-
p.tremoloDepth = 0.5;
|
|
50
|
-
break;
|
|
31
|
+
// --- Variant selection ---
|
|
32
|
+
function pickVariant(variants, override) {
|
|
33
|
+
if (override !== undefined && override >= 0 && override < variants.length) {
|
|
34
|
+
return variants[override];
|
|
51
35
|
}
|
|
52
|
-
return
|
|
36
|
+
return variants[Math.floor(Math.random() * variants.length)];
|
|
37
|
+
}
|
|
38
|
+
// --- Micro-jitter (constrained variation) ---
|
|
39
|
+
/**
|
|
40
|
+
* Apply micro-jitter to a note to reduce listener fatigue.
|
|
41
|
+
* Constraints: ±5 cents pitch, ±6% duration, ±4ms onset, ±0.8 dB gain.
|
|
42
|
+
* Identity (register, contour, timbre) is preserved.
|
|
43
|
+
*/
|
|
44
|
+
function jitterNote(note) {
|
|
45
|
+
const n = { ...note, envelope: { ...note.envelope } };
|
|
46
|
+
// Pitch drift: ±5 cents (1 cent ≈ 0.058% frequency change)
|
|
47
|
+
const centsDrift = (Math.random() - 0.5) * 10;
|
|
48
|
+
n.frequency *= Math.pow(2, centsDrift / 1200);
|
|
49
|
+
// Duration jitter: ±6%
|
|
50
|
+
n.duration *= 1 + (Math.random() - 0.5) * 0.12;
|
|
51
|
+
// Onset jitter: ±4ms
|
|
52
|
+
n.offsetMs = Math.max(0, n.offsetMs + (Math.random() - 0.5) * 8);
|
|
53
|
+
// Gain drift: ±0.8 dB (≈ ±9.6%)
|
|
54
|
+
n.gain *= 1 + (Math.random() - 0.5) * 0.19;
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
// --- Note rendering ---
|
|
58
|
+
/** Render a single MotifNote into a PCM buffer with optional waveform blending. */
|
|
59
|
+
function renderNote(note, masterGain) {
|
|
60
|
+
const gain = note.gain * masterGain;
|
|
61
|
+
const offsetSamples = Math.max(0, Math.floor((note.offsetMs / 1000) * SAMPLE_RATE));
|
|
62
|
+
const baseParams = {
|
|
63
|
+
waveform: note.waveform,
|
|
64
|
+
frequency: note.frequency,
|
|
65
|
+
duration: note.duration,
|
|
66
|
+
envelope: { ...note.envelope },
|
|
67
|
+
gain,
|
|
68
|
+
fmRatio: note.fmRatio,
|
|
69
|
+
fmDepth: note.fmDepth,
|
|
70
|
+
harmonicGain: note.harmonicGain,
|
|
71
|
+
tremoloRate: note.tremoloRate,
|
|
72
|
+
tremoloDepth: note.tremoloDepth,
|
|
73
|
+
detune: note.detune,
|
|
74
|
+
};
|
|
75
|
+
let buffer;
|
|
76
|
+
if (note.blendWaveform && note.blendAmount && note.blendAmount > 0) {
|
|
77
|
+
const primary = generateTone({ ...baseParams, gain: gain * (1 - note.blendAmount) });
|
|
78
|
+
const blend = generateTone({ ...baseParams, waveform: note.blendWaveform, gain: gain * note.blendAmount });
|
|
79
|
+
buffer = mixBuffers([primary, blend]);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
buffer = generateTone(baseParams);
|
|
83
|
+
}
|
|
84
|
+
return { buffer, offsetSamples };
|
|
85
|
+
}
|
|
86
|
+
/** Render a complete motif variant into a single buffer. */
|
|
87
|
+
function renderMotif(variant, masterGain, applyJitter = true) {
|
|
88
|
+
const notes = applyJitter ? variant.notes.map(jitterNote) : variant.notes;
|
|
89
|
+
const rendered = notes.map(n => renderNote(n, masterGain));
|
|
90
|
+
// Calculate total buffer length
|
|
91
|
+
let maxLen = 0;
|
|
92
|
+
for (const { buffer, offsetSamples } of rendered) {
|
|
93
|
+
maxLen = Math.max(maxLen, offsetSamples + buffer.length);
|
|
94
|
+
}
|
|
95
|
+
const output = new Float64Array(maxLen);
|
|
96
|
+
// Mix all notes at their offsets
|
|
97
|
+
for (const { buffer, offsetSamples } of rendered) {
|
|
98
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
99
|
+
if (offsetSamples + i < output.length) {
|
|
100
|
+
output[offsetSamples + i] += buffer[i];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Add noise transient if present (e.g., execute verb percussive onset)
|
|
105
|
+
if (variant.noiseTransient) {
|
|
106
|
+
const nt = variant.noiseTransient;
|
|
107
|
+
const noiseBuffer = generateWhoosh({
|
|
108
|
+
duration: nt.duration,
|
|
109
|
+
freqStart: nt.freqStart,
|
|
110
|
+
freqEnd: nt.freqEnd,
|
|
111
|
+
bandwidth: nt.bandwidth,
|
|
112
|
+
envelope: { attack: 0.001, decay: nt.duration * 0.4, sustain: 0.0, release: nt.duration * 0.3 },
|
|
113
|
+
gain: nt.gain * masterGain,
|
|
114
|
+
});
|
|
115
|
+
// Noise at the very start of the buffer
|
|
116
|
+
for (let i = 0; i < noiseBuffer.length && i < output.length; i++) {
|
|
117
|
+
output[i] += noiseBuffer[i];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return output;
|
|
121
|
+
}
|
|
122
|
+
/** Render a whoosh anchor variant into a buffer. */
|
|
123
|
+
function renderAnchor(anchor, masterGain, applyJitter = true) {
|
|
124
|
+
// Reuse motif rendering for the tonal anchor notes
|
|
125
|
+
return renderMotif({ notes: anchor.notes }, masterGain, applyJitter);
|
|
126
|
+
}
|
|
127
|
+
// --- Musical modifiers (motif-based) ---
|
|
128
|
+
/**
|
|
129
|
+
* Apply status modifier to a motif variant.
|
|
130
|
+
* ok: add quiet fifth above strongest tone.
|
|
131
|
+
* warn: gentle tremolo (5.5 Hz / 0.22 depth).
|
|
132
|
+
* err: final note drops one scale degree, detune ±2 Hz, longer release.
|
|
133
|
+
*/
|
|
134
|
+
function applyMotifStatus(variant, status) {
|
|
135
|
+
if (status === "ok") {
|
|
136
|
+
const notes = [...variant.notes];
|
|
137
|
+
const lastNote = notes[notes.length - 1];
|
|
138
|
+
// Add a quiet perfect fifth above the last note
|
|
139
|
+
notes.push({
|
|
140
|
+
...lastNote,
|
|
141
|
+
frequency: lastNote.frequency * 1.5,
|
|
142
|
+
gain: lastNote.gain * 0.35,
|
|
143
|
+
harmonicGain: undefined,
|
|
144
|
+
fmRatio: undefined,
|
|
145
|
+
fmDepth: undefined,
|
|
146
|
+
});
|
|
147
|
+
return { ...variant, notes };
|
|
148
|
+
}
|
|
149
|
+
if (status === "warn") {
|
|
150
|
+
return {
|
|
151
|
+
...variant,
|
|
152
|
+
notes: variant.notes.map(n => ({
|
|
153
|
+
...n,
|
|
154
|
+
tremoloRate: 5.5,
|
|
155
|
+
tremoloDepth: 0.22,
|
|
156
|
+
})),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (status === "err") {
|
|
160
|
+
const notes = variant.notes.map((n, i) => {
|
|
161
|
+
const isLast = i === variant.notes.length - 1;
|
|
162
|
+
return {
|
|
163
|
+
...n,
|
|
164
|
+
// Final note drops one scale degree
|
|
165
|
+
frequency: isLast ? scaleStepDown(n.frequency) : n.frequency,
|
|
166
|
+
detune: 2,
|
|
167
|
+
duration: n.duration * 1.15,
|
|
168
|
+
envelope: { ...n.envelope, release: n.envelope.release * 1.25 },
|
|
169
|
+
// Reduce top harmonic by 20%
|
|
170
|
+
harmonicGain: n.harmonicGain ? n.harmonicGain * 0.8 : undefined,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
return { ...variant, notes };
|
|
174
|
+
}
|
|
175
|
+
return variant;
|
|
53
176
|
}
|
|
54
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Apply scope modifier to a motif variant.
|
|
179
|
+
* remote: slightly farther away — longer attack/release, quieter.
|
|
180
|
+
*/
|
|
181
|
+
function applyMotifScope(variant, scope) {
|
|
55
182
|
if (scope === "remote") {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
183
|
+
return {
|
|
184
|
+
...variant,
|
|
185
|
+
notes: variant.notes.map(n => ({
|
|
186
|
+
...n,
|
|
187
|
+
envelope: {
|
|
188
|
+
...n.envelope,
|
|
189
|
+
attack: n.envelope.attack * 1.25,
|
|
190
|
+
release: n.envelope.release * 1.5,
|
|
191
|
+
},
|
|
192
|
+
gain: n.gain * 0.92,
|
|
193
|
+
})),
|
|
194
|
+
};
|
|
60
195
|
}
|
|
61
|
-
return
|
|
196
|
+
return variant;
|
|
62
197
|
}
|
|
63
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Apply streak intensity (1–5) to a motif variant.
|
|
200
|
+
* Progression: base → harmonic → brighter interval → FM shimmer → richer tail.
|
|
201
|
+
* Gain change kept within ~+2.5 dB total.
|
|
202
|
+
*/
|
|
203
|
+
function applyMotifIntensity(variant, intensity) {
|
|
204
|
+
if (intensity <= 1)
|
|
205
|
+
return variant;
|
|
206
|
+
return {
|
|
207
|
+
...variant,
|
|
208
|
+
notes: variant.notes.map(n => {
|
|
209
|
+
const note = { ...n, envelope: { ...n.envelope } };
|
|
210
|
+
if (intensity >= 2) {
|
|
211
|
+
// Add quiet harmonic
|
|
212
|
+
note.harmonicGain = Math.max(note.harmonicGain ?? 0, 0.06 + (intensity - 2) * 0.03);
|
|
213
|
+
}
|
|
214
|
+
if (intensity >= 3) {
|
|
215
|
+
// Slightly brighter (lift pitch within scale tolerance)
|
|
216
|
+
note.frequency *= 1 + (intensity - 2) * 0.015;
|
|
217
|
+
}
|
|
218
|
+
if (intensity >= 4) {
|
|
219
|
+
// Add subtle FM shimmer
|
|
220
|
+
note.fmRatio = note.fmRatio ?? 2;
|
|
221
|
+
note.fmDepth = Math.max(note.fmDepth ?? 0, 6 + (intensity - 4) * 5);
|
|
222
|
+
}
|
|
223
|
+
if (intensity >= 5) {
|
|
224
|
+
// Slightly longer tail + tiny gain lift
|
|
225
|
+
note.envelope.release *= 1.2;
|
|
226
|
+
note.gain = Math.min(note.gain * 1.06, 1.0);
|
|
227
|
+
}
|
|
228
|
+
return note;
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Apply error escalation (1–5) to a motif variant.
|
|
234
|
+
* Progression: base → lower register → detune → tremolo → heavy weight.
|
|
235
|
+
*/
|
|
236
|
+
function applyMotifEscalation(variant, escalation) {
|
|
237
|
+
if (escalation <= 1)
|
|
238
|
+
return variant;
|
|
239
|
+
return {
|
|
240
|
+
...variant,
|
|
241
|
+
notes: variant.notes.map(n => {
|
|
242
|
+
const note = { ...n, envelope: { ...n.envelope } };
|
|
243
|
+
if (escalation >= 2) {
|
|
244
|
+
// Lower register
|
|
245
|
+
note.frequency = scaleStepDown(note.frequency);
|
|
246
|
+
}
|
|
247
|
+
if (escalation >= 3) {
|
|
248
|
+
// Slight detune
|
|
249
|
+
note.detune = (note.detune ?? 0) + 1.5;
|
|
250
|
+
}
|
|
251
|
+
if (escalation >= 4) {
|
|
252
|
+
// Soft tremolo/pulse
|
|
253
|
+
note.tremoloRate = 4.5;
|
|
254
|
+
note.tremoloDepth = 0.30;
|
|
255
|
+
note.envelope.decay *= 1.15;
|
|
256
|
+
}
|
|
257
|
+
if (escalation >= 5) {
|
|
258
|
+
// Longer decay, heavier instability
|
|
259
|
+
note.detune = (note.detune ?? 0) + 1.5;
|
|
260
|
+
note.tremoloDepth = 0.45;
|
|
261
|
+
note.envelope.release *= 1.5;
|
|
262
|
+
note.gain *= 0.95;
|
|
263
|
+
}
|
|
264
|
+
return note;
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// --- Musical modifiers (whoosh-based) ---
|
|
64
269
|
function applyWhooshStatus(params, status) {
|
|
65
|
-
const p = { ...params };
|
|
270
|
+
const p = { ...params, envelope: { ...params.envelope } };
|
|
66
271
|
switch (status) {
|
|
67
272
|
case "ok":
|
|
68
|
-
p.freqEnd = Math.min(p.freqEnd * 1.
|
|
273
|
+
p.freqEnd = Math.min(p.freqEnd * 1.15, 5000);
|
|
69
274
|
break;
|
|
70
275
|
case "err":
|
|
71
|
-
p.freqStart
|
|
72
|
-
p.freqEnd *= 0.
|
|
73
|
-
p.duration *= 1.
|
|
74
|
-
p.bandwidth = 1.
|
|
276
|
+
p.freqStart = scaleStepDown(p.freqStart);
|
|
277
|
+
p.freqEnd *= 0.7;
|
|
278
|
+
p.duration *= 1.15;
|
|
279
|
+
p.bandwidth = Math.min(p.bandwidth * 1.3, 1.5);
|
|
75
280
|
break;
|
|
76
281
|
case "warn":
|
|
77
|
-
p.duration *= 0.
|
|
78
|
-
p.envelope
|
|
282
|
+
p.duration *= 0.85;
|
|
283
|
+
p.envelope.attack *= 0.5;
|
|
284
|
+
p.envelope.release *= 0.7;
|
|
79
285
|
break;
|
|
80
286
|
}
|
|
81
287
|
return p;
|
|
82
288
|
}
|
|
83
289
|
function applyWhooshScope(params, scope) {
|
|
84
290
|
if (scope === "remote") {
|
|
85
|
-
const p = { ...params };
|
|
86
|
-
p.
|
|
87
|
-
p.envelope
|
|
291
|
+
const p = { ...params, envelope: { ...params.envelope } };
|
|
292
|
+
p.envelope.attack *= 1.25;
|
|
293
|
+
p.envelope.release *= 1.5;
|
|
294
|
+
p.duration *= 1.15;
|
|
295
|
+
p.gain *= 0.92;
|
|
88
296
|
return p;
|
|
89
297
|
}
|
|
90
298
|
return params;
|
|
91
299
|
}
|
|
92
|
-
// --- Intensity modifiers (streak-driven layering) ---
|
|
93
|
-
/**
|
|
94
|
-
* Apply streak intensity to tone params.
|
|
95
|
-
* Level 1: clean (no change)
|
|
96
|
-
* Level 2: subtle octave harmonic
|
|
97
|
-
* Level 3: harmonic + slight frequency lift
|
|
98
|
-
* Level 4: richer harmonics + FM shimmer
|
|
99
|
-
* Level 5: full chord feel — harmonics + FM + slight gain boost
|
|
100
|
-
*/
|
|
101
|
-
function applyToneIntensity(params, intensity) {
|
|
102
|
-
if (intensity <= 1)
|
|
103
|
-
return params;
|
|
104
|
-
const p = { ...params };
|
|
105
|
-
if (intensity >= 2) {
|
|
106
|
-
p.harmonicGain = Math.max(p.harmonicGain ?? 0, 0.08 + (intensity - 2) * 0.04);
|
|
107
|
-
}
|
|
108
|
-
if (intensity >= 3) {
|
|
109
|
-
// Slight frequency lift — sounds "brighter" with momentum
|
|
110
|
-
p.frequency *= 1 + (intensity - 2) * 0.02;
|
|
111
|
-
if (p.frequencyEnd)
|
|
112
|
-
p.frequencyEnd *= 1 + (intensity - 2) * 0.02;
|
|
113
|
-
}
|
|
114
|
-
if (intensity >= 4) {
|
|
115
|
-
// Add FM shimmer for texture
|
|
116
|
-
p.fmRatio = p.fmRatio ?? 2;
|
|
117
|
-
p.fmDepth = Math.max(p.fmDepth ?? 0, 10 + (intensity - 4) * 8);
|
|
118
|
-
}
|
|
119
|
-
if (intensity >= 5) {
|
|
120
|
-
// Subtle gain boost — session is cooking
|
|
121
|
-
p.gain = Math.min(p.gain * 1.1, 0.9);
|
|
122
|
-
}
|
|
123
|
-
return p;
|
|
124
|
-
}
|
|
125
300
|
function applyWhooshIntensity(params, intensity) {
|
|
126
301
|
if (intensity <= 1)
|
|
127
302
|
return params;
|
|
128
|
-
const p = { ...params };
|
|
303
|
+
const p = { ...params, envelope: { ...params.envelope } };
|
|
129
304
|
if (intensity >= 2) {
|
|
130
|
-
|
|
131
|
-
p.freqEnd = Math.min(p.freqEnd * (1 + (intensity - 1) * 0.08), 8000);
|
|
305
|
+
p.freqEnd = Math.min(p.freqEnd * (1 + (intensity - 1) * 0.06), 6000);
|
|
132
306
|
}
|
|
133
307
|
if (intensity >= 3) {
|
|
134
|
-
|
|
135
|
-
p.bandwidth = Math.max(p.bandwidth * 0.85, 0.3);
|
|
308
|
+
p.bandwidth = Math.max(p.bandwidth * 0.88, 0.3);
|
|
136
309
|
}
|
|
137
310
|
if (intensity >= 4) {
|
|
138
|
-
|
|
139
|
-
p.duration *= 1.05;
|
|
140
|
-
}
|
|
141
|
-
return p;
|
|
142
|
-
}
|
|
143
|
-
// --- Error escalation modifiers ---
|
|
144
|
-
/**
|
|
145
|
-
* Progressive error escalation for tones.
|
|
146
|
-
* Level 1: standard error (same as status:err)
|
|
147
|
-
* Level 2: deeper frequency drop + more detuning
|
|
148
|
-
* Level 3: add tremolo (something is wrong)
|
|
149
|
-
* Level 4: wider detuning + slower tremolo (stuck)
|
|
150
|
-
* Level 5: max urgency — deep drop, heavy tremolo, long decay
|
|
151
|
-
*/
|
|
152
|
-
function applyToneEscalation(params, escalation) {
|
|
153
|
-
if (escalation <= 1)
|
|
154
|
-
return params;
|
|
155
|
-
const p = { ...params };
|
|
156
|
-
if (escalation >= 2) {
|
|
157
|
-
p.frequency *= 0.9;
|
|
158
|
-
if (p.frequencyEnd)
|
|
159
|
-
p.frequencyEnd *= 0.9;
|
|
160
|
-
p.detune = (p.detune ?? 0) + 2;
|
|
161
|
-
}
|
|
162
|
-
if (escalation >= 3) {
|
|
163
|
-
p.tremoloRate = 6;
|
|
164
|
-
p.tremoloDepth = 0.4;
|
|
165
|
-
}
|
|
166
|
-
if (escalation >= 4) {
|
|
167
|
-
p.detune = (p.detune ?? 0) + 3;
|
|
168
|
-
p.tremoloRate = 4;
|
|
169
|
-
p.tremoloDepth = 0.6;
|
|
170
|
-
p.duration *= 1.15;
|
|
171
|
-
}
|
|
172
|
-
if (escalation >= 5) {
|
|
173
|
-
p.frequency *= 0.85;
|
|
174
|
-
if (p.frequencyEnd)
|
|
175
|
-
p.frequencyEnd *= 0.85;
|
|
176
|
-
p.tremoloDepth = 0.75;
|
|
177
|
-
p.duration *= 1.2;
|
|
178
|
-
p.envelope = { ...p.envelope, release: p.envelope.release * 2 };
|
|
311
|
+
p.duration *= 1.04;
|
|
179
312
|
}
|
|
180
313
|
return p;
|
|
181
314
|
}
|
|
182
315
|
function applyWhooshEscalation(params, escalation) {
|
|
183
316
|
if (escalation <= 1)
|
|
184
317
|
return params;
|
|
185
|
-
const p = { ...params };
|
|
318
|
+
const p = { ...params, envelope: { ...params.envelope } };
|
|
186
319
|
if (escalation >= 2) {
|
|
187
|
-
p.freqStart *= 0.
|
|
188
|
-
p.freqEnd *= 0.
|
|
320
|
+
p.freqStart *= 0.75;
|
|
321
|
+
p.freqEnd *= 0.75;
|
|
189
322
|
}
|
|
190
323
|
if (escalation >= 3) {
|
|
191
|
-
p.bandwidth = Math.min(p.bandwidth * 1.
|
|
192
|
-
p.duration *= 1.
|
|
324
|
+
p.bandwidth = Math.min(p.bandwidth * 1.4, 2);
|
|
325
|
+
p.duration *= 1.15;
|
|
193
326
|
}
|
|
194
327
|
if (escalation >= 4) {
|
|
195
|
-
p.
|
|
196
|
-
p.
|
|
328
|
+
p.envelope.release *= 1.4;
|
|
329
|
+
p.duration *= 1.1;
|
|
197
330
|
}
|
|
198
331
|
if (escalation >= 5) {
|
|
199
|
-
p.freqStart *= 0.
|
|
200
|
-
p.freqEnd *= 0.
|
|
201
|
-
p.duration *= 1.
|
|
332
|
+
p.freqStart *= 0.85;
|
|
333
|
+
p.freqEnd *= 0.85;
|
|
334
|
+
p.duration *= 1.08;
|
|
202
335
|
}
|
|
203
336
|
return p;
|
|
204
337
|
}
|
|
205
|
-
// --- Config → Params conversion ---
|
|
206
|
-
function toneConfigToParams(cfg) {
|
|
207
|
-
return {
|
|
208
|
-
waveform: cfg.waveform,
|
|
209
|
-
frequency: cfg.frequency,
|
|
210
|
-
frequencyEnd: cfg.frequencyEnd,
|
|
211
|
-
duration: cfg.duration,
|
|
212
|
-
envelope: { ...cfg.envelope },
|
|
213
|
-
gain: cfg.gain,
|
|
214
|
-
fmRatio: cfg.fmRatio,
|
|
215
|
-
fmDepth: cfg.fmDepth,
|
|
216
|
-
harmonicGain: cfg.harmonicGain,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
function whooshConfigToParams(cfg, direction) {
|
|
220
|
-
const freqPair = direction === "down" ? cfg.freqDown : cfg.freqUp;
|
|
221
|
-
return {
|
|
222
|
-
duration: cfg.duration,
|
|
223
|
-
freqStart: freqPair[0],
|
|
224
|
-
freqEnd: freqPair[1],
|
|
225
|
-
bandwidth: cfg.bandwidth,
|
|
226
|
-
envelope: { ...cfg.envelope },
|
|
227
|
-
gain: cfg.gain,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
338
|
// --- Main generation ---
|
|
231
339
|
/** Generate a verb sound using the given profile. */
|
|
232
340
|
export function generateVerb(profile, verb, options = {}) {
|
|
@@ -234,10 +342,18 @@ export function generateVerb(profile, verb, options = {}) {
|
|
|
234
342
|
if (!cfg) {
|
|
235
343
|
throw new Error(`Profile "${profile.name}" has no config for verb "${verb}"`);
|
|
236
344
|
}
|
|
237
|
-
// Whoosh-based verbs
|
|
345
|
+
// ── Whoosh-based verbs (move, sync) ─────────────────────────
|
|
238
346
|
if (cfg.type === "whoosh") {
|
|
239
347
|
const dir = options.direction ?? "up";
|
|
240
|
-
|
|
348
|
+
const freqPair = dir === "down" ? cfg.freqDown : cfg.freqUp;
|
|
349
|
+
let wp = {
|
|
350
|
+
duration: cfg.duration,
|
|
351
|
+
freqStart: freqPair[0],
|
|
352
|
+
freqEnd: freqPair[1],
|
|
353
|
+
bandwidth: cfg.bandwidth,
|
|
354
|
+
envelope: { ...cfg.envelope },
|
|
355
|
+
gain: cfg.gain,
|
|
356
|
+
};
|
|
241
357
|
if (options.status)
|
|
242
358
|
wp = applyWhooshStatus(wp, options.status);
|
|
243
359
|
if (options.scope)
|
|
@@ -246,64 +362,66 @@ export function generateVerb(profile, verb, options = {}) {
|
|
|
246
362
|
wp = applyWhooshIntensity(wp, options.intensity);
|
|
247
363
|
if (options.escalation)
|
|
248
364
|
wp = applyWhooshEscalation(wp, options.escalation);
|
|
249
|
-
const
|
|
250
|
-
// Tonal anchor
|
|
251
|
-
if (cfg.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
365
|
+
const whooshBuf = generateWhoosh(wp);
|
|
366
|
+
// Tonal anchor variant
|
|
367
|
+
if (cfg.anchorVariants && cfg.anchorVariants.length > 0) {
|
|
368
|
+
let anchor = pickVariant(cfg.anchorVariants, options.variantIndex);
|
|
369
|
+
// Apply motif modifiers to anchor notes too
|
|
370
|
+
let anchorMotif = { notes: anchor.notes };
|
|
371
|
+
if (options.status)
|
|
372
|
+
anchorMotif = applyMotifStatus(anchorMotif, options.status);
|
|
373
|
+
if (options.scope)
|
|
374
|
+
anchorMotif = applyMotifScope(anchorMotif, options.scope);
|
|
375
|
+
if (options.intensity)
|
|
376
|
+
anchorMotif = applyMotifIntensity(anchorMotif, options.intensity);
|
|
377
|
+
if (options.escalation)
|
|
378
|
+
anchorMotif = applyMotifEscalation(anchorMotif, options.escalation);
|
|
379
|
+
const anchorBuf = renderMotif(anchorMotif, cfg.gain);
|
|
380
|
+
// Mix: align to same start, extend to longest
|
|
381
|
+
const maxLen = Math.max(whooshBuf.length, anchorBuf.length);
|
|
382
|
+
const whooshPadded = new Float64Array(maxLen);
|
|
383
|
+
whooshPadded.set(whooshBuf, 0);
|
|
384
|
+
const anchorPadded = new Float64Array(maxLen);
|
|
385
|
+
anchorPadded.set(anchorBuf, 0);
|
|
386
|
+
return limitLoudness(mixBuffers([whooshPadded, anchorPadded]));
|
|
262
387
|
}
|
|
263
|
-
return limitLoudness(
|
|
388
|
+
return limitLoudness(whooshBuf);
|
|
264
389
|
}
|
|
265
|
-
//
|
|
266
|
-
let
|
|
390
|
+
// ── Motif-based verbs (intake, transform, commit, navigate, execute) ──
|
|
391
|
+
let variant = pickVariant(cfg.variants, options.variantIndex);
|
|
267
392
|
if (options.status)
|
|
268
|
-
|
|
393
|
+
variant = applyMotifStatus(variant, options.status);
|
|
269
394
|
if (options.scope)
|
|
270
|
-
|
|
395
|
+
variant = applyMotifScope(variant, options.scope);
|
|
271
396
|
if (options.intensity)
|
|
272
|
-
|
|
397
|
+
variant = applyMotifIntensity(variant, options.intensity);
|
|
273
398
|
if (options.escalation)
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
if (cfg.noiseBurst) {
|
|
278
|
-
const noise = generateTone({
|
|
279
|
-
waveform: "noise",
|
|
280
|
-
frequency: 0,
|
|
281
|
-
duration: cfg.noiseBurst.duration,
|
|
282
|
-
envelope: { attack: 0.002, decay: 0.03, sustain: 0.0, release: 0.005 },
|
|
283
|
-
gain: cfg.noiseBurst.gain,
|
|
284
|
-
});
|
|
285
|
-
return limitLoudness(mixBuffers([primary, noise]));
|
|
286
|
-
}
|
|
287
|
-
return limitLoudness(primary);
|
|
399
|
+
variant = applyMotifEscalation(variant, options.escalation);
|
|
400
|
+
const buffer = renderMotif(variant, cfg.gain);
|
|
401
|
+
return limitLoudness(buffer);
|
|
288
402
|
}
|
|
289
403
|
// --- Session sounds ---
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
404
|
+
/** Render a chime tone with optional waveform blending. */
|
|
405
|
+
function renderChimeTone(tone, extraGain = 1.0) {
|
|
406
|
+
const gain = tone.gain * extraGain;
|
|
407
|
+
const params = {
|
|
408
|
+
waveform: tone.waveform,
|
|
409
|
+
frequency: tone.frequency,
|
|
410
|
+
duration: tone.duration,
|
|
411
|
+
envelope: { ...tone.envelope },
|
|
412
|
+
gain,
|
|
413
|
+
harmonicGain: tone.harmonicGain,
|
|
414
|
+
};
|
|
415
|
+
if (tone.blendWaveform && tone.blendAmount && tone.blendAmount > 0) {
|
|
416
|
+
const primary = generateTone({ ...params, gain: gain * (1 - tone.blendAmount) });
|
|
417
|
+
const blend = generateTone({ ...params, waveform: tone.blendWaveform, gain: gain * tone.blendAmount });
|
|
418
|
+
return mixBuffers([primary, blend]);
|
|
419
|
+
}
|
|
420
|
+
return generateTone(params);
|
|
421
|
+
}
|
|
422
|
+
function generateChime(chime, gainMul = 1.0) {
|
|
423
|
+
const tone1 = renderChimeTone(chime.tone1, gainMul);
|
|
424
|
+
const tone2 = renderChimeTone(chime.tone2, gainMul);
|
|
307
425
|
const offset = Math.floor(chime.staggerSeconds * SAMPLE_RATE);
|
|
308
426
|
const total = new Float64Array(Math.max(tone1.length, offset + tone2.length));
|
|
309
427
|
total.set(tone1, 0);
|
|
@@ -322,49 +440,54 @@ export function generateSessionEnd(profile) {
|
|
|
322
440
|
}
|
|
323
441
|
/**
|
|
324
442
|
* Generate an outcome-aware session end sound.
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* Empty session (< 5 plays): standard chime (not enough data)
|
|
443
|
+
* Great (ratio >= 0.8, 5+ plays): A4 → C#5 → E5 ascending fanfare.
|
|
444
|
+
* Normal: standard descending chime.
|
|
445
|
+
* Rough (ratio < 0.6): muted, lower, shorter resolution.
|
|
446
|
+
* Empty (< 5 plays): standard chime.
|
|
330
447
|
*/
|
|
331
448
|
export function generateSessionEndWithOutcome(profile, outcome) {
|
|
332
|
-
// Not enough data — use standard chime
|
|
333
449
|
if (outcome.totalPlays < 5) {
|
|
334
450
|
return generateChime(profile.sessionEnd);
|
|
335
451
|
}
|
|
336
452
|
if (outcome.successRatio >= 0.8) {
|
|
337
|
-
// Triumphant: 3-note ascending chord (C5 → E5 → G5) with harmonics
|
|
338
453
|
return generateFanfare(profile);
|
|
339
454
|
}
|
|
340
455
|
if (outcome.successRatio < 0.6) {
|
|
341
|
-
// Rough session: lower, shorter, muted resolution
|
|
342
456
|
return generateMutedEnd(profile);
|
|
343
457
|
}
|
|
344
|
-
// Normal session: standard chime
|
|
345
458
|
return generateChime(profile.sessionEnd);
|
|
346
459
|
}
|
|
347
|
-
/**
|
|
460
|
+
/** A4 → C#5 → E5 ascending fanfare — the big win. */
|
|
348
461
|
function generateFanfare(profile) {
|
|
349
462
|
const waveform = profile.sessionEnd.tone1.waveform;
|
|
350
|
-
|
|
351
|
-
const
|
|
463
|
+
const blendWave = profile.sessionEnd.tone1.blendWaveform;
|
|
464
|
+
const blendAmt = profile.sessionEnd.tone1.blendAmount ?? 0;
|
|
465
|
+
// A major triad ascending: A4 → C#5 → E5
|
|
466
|
+
const notes = [SCALE.A4, SCALE.Cs5, SCALE.E5];
|
|
352
467
|
const buffers = [];
|
|
353
468
|
for (let i = 0; i < notes.length; i++) {
|
|
354
469
|
const isLast = i === notes.length - 1;
|
|
355
|
-
|
|
470
|
+
const params = {
|
|
356
471
|
waveform,
|
|
357
472
|
frequency: notes[i],
|
|
358
|
-
duration: isLast ? 0.
|
|
473
|
+
duration: isLast ? 0.30 : 0.15,
|
|
359
474
|
envelope: {
|
|
360
|
-
attack: 0.
|
|
361
|
-
decay: isLast ? 0.
|
|
362
|
-
sustain: isLast ? 0.
|
|
363
|
-
release: isLast ? 0.
|
|
475
|
+
attack: 0.008,
|
|
476
|
+
decay: isLast ? 0.10 : 0.05,
|
|
477
|
+
sustain: isLast ? 0.30 : 0.20,
|
|
478
|
+
release: isLast ? 0.10 : 0.04,
|
|
364
479
|
},
|
|
365
|
-
gain: isLast ? 0.
|
|
366
|
-
harmonicGain: isLast ? 0.
|
|
367
|
-
}
|
|
480
|
+
gain: isLast ? 0.22 : 0.18,
|
|
481
|
+
harmonicGain: isLast ? 0.12 : 0.06,
|
|
482
|
+
};
|
|
483
|
+
if (blendWave && blendAmt > 0) {
|
|
484
|
+
const primary = generateTone({ ...params, gain: params.gain * (1 - blendAmt) });
|
|
485
|
+
const blend = generateTone({ ...params, waveform: blendWave, gain: params.gain * blendAmt });
|
|
486
|
+
buffers.push(mixBuffers([primary, blend]));
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
buffers.push(generateTone(params));
|
|
490
|
+
}
|
|
368
491
|
}
|
|
369
492
|
// Stagger: each note starts 70ms after the previous
|
|
370
493
|
const stagger = Math.floor(0.07 * SAMPLE_RATE);
|
|
@@ -381,38 +504,21 @@ function generateFanfare(profile) {
|
|
|
381
504
|
}
|
|
382
505
|
return limitLoudness(total);
|
|
383
506
|
}
|
|
384
|
-
/** Muted session end —
|
|
507
|
+
/** Muted session end — same contour, octave lower, quieter. */
|
|
385
508
|
function generateMutedEnd(profile) {
|
|
386
509
|
const cfg = profile.sessionEnd;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
frequency: cfg.tone1.frequency * 0.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const tone2 = generateTone({
|
|
395
|
-
waveform: cfg.tone2.waveform,
|
|
396
|
-
frequency: cfg.tone2.frequency * 0.75,
|
|
397
|
-
duration: cfg.tone2.duration * 0.7,
|
|
398
|
-
envelope: { ...cfg.tone2.envelope, release: cfg.tone2.envelope.release * 0.5 },
|
|
399
|
-
gain: cfg.tone2.gain * 0.6,
|
|
400
|
-
});
|
|
401
|
-
const offset = Math.floor(cfg.staggerSeconds * SAMPLE_RATE);
|
|
402
|
-
const total = new Float64Array(Math.max(tone1.length, offset + tone2.length));
|
|
403
|
-
total.set(tone1, 0);
|
|
404
|
-
for (let i = 0; i < tone2.length; i++) {
|
|
405
|
-
if (offset + i < total.length) {
|
|
406
|
-
total[offset + i] += tone2[i];
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
return limitLoudness(total);
|
|
510
|
+
// One octave lower, 60% gain
|
|
511
|
+
const mutedChime = {
|
|
512
|
+
tone1: { ...cfg.tone1, frequency: cfg.tone1.frequency * 0.5, gain: cfg.tone1.gain * 0.6 },
|
|
513
|
+
tone2: { ...cfg.tone2, frequency: cfg.tone2.frequency * 0.5, gain: cfg.tone2.gain * 0.6 },
|
|
514
|
+
staggerSeconds: cfg.staggerSeconds,
|
|
515
|
+
};
|
|
516
|
+
return generateChime(mutedChime);
|
|
410
517
|
}
|
|
411
518
|
// --- Ambient (long-running thinking) ---
|
|
412
519
|
/** Generate a single chunk of the ambient thinking drone. */
|
|
413
520
|
export function generateAmbientChunk(profile) {
|
|
414
521
|
const cfg = profile.ambient;
|
|
415
|
-
// Low drone with very gentle fade in/out to allow seamless looping
|
|
416
522
|
return generateTone({
|
|
417
523
|
waveform: cfg.droneWaveform,
|
|
418
524
|
frequency: cfg.droneFreq,
|
|
@@ -424,29 +530,28 @@ export function generateAmbientChunk(profile) {
|
|
|
424
530
|
release: 0.3,
|
|
425
531
|
},
|
|
426
532
|
gain: cfg.droneGain,
|
|
427
|
-
// Subtle tremolo gives the drone "life" instead of a dead flat tone
|
|
428
533
|
tremoloRate: 0.5,
|
|
429
534
|
tremoloDepth: 0.15,
|
|
430
535
|
});
|
|
431
536
|
}
|
|
432
|
-
/** Generate the resolution stinger (two ascending notes). */
|
|
537
|
+
/** Generate the resolution stinger (two ascending notes, A major). */
|
|
433
538
|
export function generateAmbientResolve(profile) {
|
|
434
539
|
const cfg = profile.ambient;
|
|
435
540
|
const note1 = generateTone({
|
|
436
541
|
waveform: cfg.resolveWaveform,
|
|
437
542
|
frequency: cfg.resolveNote1,
|
|
438
543
|
duration: cfg.resolveDuration,
|
|
439
|
-
envelope: { attack: 0.
|
|
544
|
+
envelope: { attack: 0.006, decay: 0.05, sustain: 0.20, release: 0.04 },
|
|
440
545
|
gain: cfg.resolveGain,
|
|
441
|
-
harmonicGain: 0.
|
|
546
|
+
harmonicGain: 0.08,
|
|
442
547
|
});
|
|
443
548
|
const note2 = generateTone({
|
|
444
549
|
waveform: cfg.resolveWaveform,
|
|
445
550
|
frequency: cfg.resolveNote2,
|
|
446
551
|
duration: cfg.resolveDuration * 1.3,
|
|
447
|
-
envelope: { attack: 0.
|
|
552
|
+
envelope: { attack: 0.006, decay: 0.07, sustain: 0.20, release: 0.05 },
|
|
448
553
|
gain: cfg.resolveGain * 0.9,
|
|
449
|
-
harmonicGain: 0.
|
|
554
|
+
harmonicGain: 0.08,
|
|
450
555
|
});
|
|
451
556
|
const offset = Math.floor(cfg.resolveDuration * 0.6 * SAMPLE_RATE);
|
|
452
557
|
const total = new Float64Array(offset + note2.length);
|