@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/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
- // --- Status modifier functions (tone-based) ---
34
- function applyToneStatus(params, status) {
35
- const p = { ...params };
36
- switch (status) {
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 p;
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
- function applyToneScope(params, scope) {
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
- const p = { ...params };
57
- p.envelope = { ...p.envelope, release: p.envelope.release * 2.5 };
58
- p.duration *= 1.2;
59
- return p;
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 params;
196
+ return variant;
62
197
  }
63
- // --- Status modifier functions (whoosh-based) ---
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.2, 6000);
273
+ p.freqEnd = Math.min(p.freqEnd * 1.15, 5000);
69
274
  break;
70
275
  case "err":
71
- p.freqStart *= 0.6;
72
- p.freqEnd *= 0.6;
73
- p.duration *= 1.3;
74
- p.bandwidth = 1.2;
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.8;
78
- p.envelope = { ...p.envelope, attack: 0.01, release: 0.03 };
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.duration *= 1.3;
87
- p.envelope = { ...p.envelope, release: p.envelope.release * 2 };
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
- // Wider sweep range
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
- // Tighter bandwidth = more defined whoosh
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
- // Slightly longer — more dramatic
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.7;
188
- p.freqEnd *= 0.7;
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.5, 2);
192
- p.duration *= 1.2;
324
+ p.bandwidth = Math.min(p.bandwidth * 1.4, 2);
325
+ p.duration *= 1.15;
193
326
  }
194
327
  if (escalation >= 4) {
195
- p.duration *= 1.2;
196
- p.envelope = { ...p.envelope, release: p.envelope.release * 1.5 };
328
+ p.envelope.release *= 1.4;
329
+ p.duration *= 1.1;
197
330
  }
198
331
  if (escalation >= 5) {
199
- p.freqStart *= 0.8;
200
- p.freqEnd *= 0.8;
201
- p.duration *= 1.15;
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
- let wp = whooshConfigToParams(cfg, dir);
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 whoosh = generateWhoosh(wp);
250
- // Tonal anchor (sync)
251
- if (cfg.tonalAnchor) {
252
- const anchorFreqs = dir === "down" ? cfg.tonalAnchor.freqDown : cfg.tonalAnchor.freqUp;
253
- const anchor = generateTone({
254
- waveform: "sine",
255
- frequency: anchorFreqs[0],
256
- frequencyEnd: anchorFreqs[1],
257
- duration: wp.duration,
258
- envelope: { ...cfg.tonalAnchor.envelope },
259
- gain: cfg.tonalAnchor.gain,
260
- });
261
- return limitLoudness(mixBuffers([whoosh, anchor]));
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(whoosh);
388
+ return limitLoudness(whooshBuf);
264
389
  }
265
- // Tone-based verbs
266
- let params = toneConfigToParams(cfg);
390
+ // ── Motif-based verbs (intake, transform, commit, navigate, execute) ──
391
+ let variant = pickVariant(cfg.variants, options.variantIndex);
267
392
  if (options.status)
268
- params = applyToneStatus(params, options.status);
393
+ variant = applyMotifStatus(variant, options.status);
269
394
  if (options.scope)
270
- params = applyToneScope(params, options.scope);
395
+ variant = applyMotifScope(variant, options.scope);
271
396
  if (options.intensity)
272
- params = applyToneIntensity(params, options.intensity);
397
+ variant = applyMotifIntensity(variant, options.intensity);
273
398
  if (options.escalation)
274
- params = applyToneEscalation(params, options.escalation);
275
- const primary = generateTone(params);
276
- // Noise burst layer (execute)
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
- function generateChime(chime) {
291
- const tone1 = generateTone({
292
- waveform: chime.tone1.waveform,
293
- frequency: chime.tone1.frequency,
294
- duration: chime.tone1.duration,
295
- envelope: { ...chime.tone1.envelope },
296
- gain: chime.tone1.gain,
297
- harmonicGain: chime.tone1.harmonicGain,
298
- });
299
- const tone2 = generateTone({
300
- waveform: chime.tone2.waveform,
301
- frequency: chime.tone2.frequency,
302
- duration: chime.tone2.duration,
303
- envelope: { ...chime.tone2.envelope },
304
- gain: chime.tone2.gain,
305
- harmonicGain: chime.tone2.harmonicGain,
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
- * Great session (ratio >= 0.8, 5+ plays): triumphant ascending chord
327
- * Good session (ratio >= 0.6): standard descending chime (existing behavior)
328
- * Rough session (ratio < 0.6): muted, lower, shorter resolution
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
- /** Triumphant 3-note ascending fanfare. */
460
+ /** A4 C#5 → E5 ascending fanfare — the big win. */
348
461
  function generateFanfare(profile) {
349
462
  const waveform = profile.sessionEnd.tone1.waveform;
350
- // C5 E5 → G5 (major triad ascending)
351
- const notes = [523, 659, 784];
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
- buffers.push(generateTone({
470
+ const params = {
356
471
  waveform,
357
472
  frequency: notes[i],
358
- duration: isLast ? 0.35 : 0.18,
473
+ duration: isLast ? 0.30 : 0.15,
359
474
  envelope: {
360
- attack: 0.01,
361
- decay: isLast ? 0.12 : 0.06,
362
- sustain: isLast ? 0.35 : 0.25,
363
- release: isLast ? 0.12 : 0.05,
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.55 : 0.45,
366
- harmonicGain: isLast ? 0.15 : 0.08,
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 — lower pitch, shorter, subdued. */
507
+ /** Muted session end — same contour, octave lower, quieter. */
385
508
  function generateMutedEnd(profile) {
386
509
  const cfg = profile.sessionEnd;
387
- const tone1 = generateTone({
388
- waveform: cfg.tone1.waveform,
389
- frequency: cfg.tone1.frequency * 0.8,
390
- duration: cfg.tone1.duration * 0.8,
391
- envelope: { ...cfg.tone1.envelope, release: cfg.tone1.envelope.release * 0.6 },
392
- gain: cfg.tone1.gain * 0.7,
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.008, decay: 0.06, sustain: 0.25, release: 0.04 },
544
+ envelope: { attack: 0.006, decay: 0.05, sustain: 0.20, release: 0.04 },
440
545
  gain: cfg.resolveGain,
441
- harmonicGain: 0.1,
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.008, decay: 0.08, sustain: 0.25, release: 0.06 },
552
+ envelope: { attack: 0.006, decay: 0.07, sustain: 0.20, release: 0.05 },
448
553
  gain: cfg.resolveGain * 0.9,
449
- harmonicGain: 0.1,
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);