@mcptoolshop/claude-sfx 1.2.0 → 1.3.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 CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-03-30
11
+
12
+ ### Added
13
+ - **Density-aware ducking** (`mix.ts`) — rolling 3s window classifies event density as sparse/medium/bursty. Gain and release are scaled per density level. High-priority verbs (commit, sync) duck less; low-priority (navigate) ducks more during bursts.
14
+ - **Repetition guard** — same verb firing 2+ times in 3s triggers variant rotation, gain reduction (×0.95→×0.85), and release shortening. Prevents search/grep spam from becoming a chirp wall.
15
+ - **Remote low-pass softening** — one-pole RC filter (6dB/octave rolloff at 2500 Hz) applied to all remote-scope sounds. Mimics distance by absorbing high frequencies, complementing the existing envelope/gain changes.
16
+ - **Per-verb ducking priorities** — commit/sync are "high" (protected from heavy ducking), navigate is "low" (maximally ducked during bursts), others are "normal"
17
+ - **Gain floor** at 0.40 prevents sounds from disappearing under worst-case stacking (bursty + many repeats + low priority)
18
+ - `applyLowPass(buffer, cutoffHz)` in synth.ts — reusable one-pole LP filter
19
+ - `MixAdvice` type and `getMixAdvice()` in mix.ts — pure function returning ducking adjustments
20
+ - `mixGain` and `mixRelease` fields in `PlayOptions` for density/repetition adjustments
21
+ - 59 new tests (262 total)
22
+
23
+ ## [1.2.1] - 2026-03-30
24
+
25
+ ### Fixed
26
+ - **Windows audio playback** — use ffplay (ffmpeg) instead of PowerShell SoundPlayer, which silently failed on short audio clips. Falls back to PowerShell if ffplay is not installed.
27
+ - **Gain levels too low** — boosted all verb master gains from 0.16–0.20 to 0.50–0.60 so sounds are actually audible through system speakers
28
+
10
29
  ## [1.2.0] - 2026-03-30
11
30
 
12
31
  ### Changed
package/dist/guard.d.ts CHANGED
@@ -6,6 +6,14 @@
6
6
  * Each play call reads/writes a small JSON ledger.
7
7
  */
8
8
  import { type SfxConfig } from "./config.js";
9
+ export interface LedgerEntry {
10
+ verb: string;
11
+ timestamp: number;
12
+ }
13
+ export interface Ledger {
14
+ entries: LedgerEntry[];
15
+ }
16
+ export declare function readLedger(): Ledger;
9
17
  export interface GuardResult {
10
18
  allowed: boolean;
11
19
  reason?: string;
package/dist/guard.js CHANGED
@@ -18,7 +18,7 @@ const RATE_LIMIT_MAX = 8;
18
18
  const RATE_LIMIT_WINDOW_MS = 10_000;
19
19
  /** Ledger file — tiny JSON tracking recent plays. */
20
20
  const LEDGER_FILE = join(tmpdir(), "claude-sfx-ledger.json");
21
- function readLedger() {
21
+ export function readLedger() {
22
22
  if (!existsSync(LEDGER_FILE)) {
23
23
  return { entries: [] };
24
24
  }
@@ -14,7 +14,8 @@ import { applyVolume } from "./synth.js";
14
14
  import { playSync } from "./player.js";
15
15
  import { resolveProfile } from "./profiles.js";
16
16
  import { loadConfig, resolveProfileName, volumeToGain } from "./config.js";
17
- import { guardPlay } from "./guard.js";
17
+ import { guardPlay, readLedger } from "./guard.js";
18
+ import { getMixAdvice } from "./mix.js";
18
19
  import { resolveAmbient, isAmbientRunning } from "./ambient.js";
19
20
  import { recordSuccess, recordError, getSessionOutcome, resetStreak } from "./streak.js";
20
21
  /** Map a PostToolUse tool_name to a verb + options. */
@@ -122,6 +123,18 @@ function playVerb(verb, options = {}) {
122
123
  const { intensity } = recordSuccess(verb);
123
124
  options = { ...options, intensity };
124
125
  }
126
+ // Mix intelligence — density ducking + repetition guard
127
+ const ledger = readLedger();
128
+ const advice = getMixAdvice(verb, ledger.entries);
129
+ if (advice.gainMultiplier < 1) {
130
+ options = { ...options, mixGain: advice.gainMultiplier };
131
+ }
132
+ if (advice.releaseMultiplier < 1) {
133
+ options = { ...options, mixRelease: advice.releaseMultiplier };
134
+ }
135
+ if (advice.forcedVariantIndex !== undefined && options.variantIndex === undefined) {
136
+ options = { ...options, variantIndex: advice.forcedVariantIndex };
137
+ }
125
138
  const profileName = resolveProfileName(config);
126
139
  const profile = resolveProfile(profileName);
127
140
  let buffer = generateVerb(profile, verb, options);
package/dist/mix.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Mix intelligence — density-aware ducking and repetition guard.
3
+ * Pure functions that read the guard ledger and return adjustment advice.
4
+ * No side effects, no writes — the guard module owns the ledger.
5
+ */
6
+ import type { LedgerEntry } from "./guard.js";
7
+ export type DensityLevel = "sparse" | "medium" | "bursty";
8
+ export type DuckingPriority = "high" | "normal" | "low";
9
+ export interface MixAdvice {
10
+ /** Gain multiplier (0.4–1.0), applied on top of existing gain. */
11
+ gainMultiplier: number;
12
+ /** Release multiplier (< 1.0 shortens tails under density). */
13
+ releaseMultiplier: number;
14
+ /** If set, force this variant index (for repetition rotation). */
15
+ forcedVariantIndex?: number;
16
+ /** Current density level (for diagnostics/testing). */
17
+ density: DensityLevel;
18
+ /** How many times the same verb repeated recently. */
19
+ recentRepeatCount: number;
20
+ }
21
+ /**
22
+ * Classify event density based on recent entries in the window.
23
+ * 0–2 events = sparse, 3–5 = medium, 6+ = bursty.
24
+ */
25
+ export declare function classifyDensity(entries: LedgerEntry[], now?: number): DensityLevel;
26
+ /**
27
+ * Count how many times the same verb fired in the recent window.
28
+ * Returns the count of prior occurrences (0 = first time).
29
+ */
30
+ export declare function countRecentRepeats(entries: LedgerEntry[], verb: string, now?: number, windowMs?: number): number;
31
+ /**
32
+ * Get the ducking priority for a verb.
33
+ * High = important culmination events (less ducking).
34
+ * Low = frequent scan events (more ducking during bursts).
35
+ * Normal = everything else.
36
+ */
37
+ export declare function getDuckingPriority(verb: string): DuckingPriority;
38
+ /**
39
+ * Compute mix advice for a verb given the current ledger state.
40
+ * Returns gain/release multipliers, optional forced variant, and diagnostics.
41
+ */
42
+ export declare function getMixAdvice(verb: string, entries: LedgerEntry[], now?: number): MixAdvice;
package/dist/mix.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Mix intelligence — density-aware ducking and repetition guard.
3
+ * Pure functions that read the guard ledger and return adjustment advice.
4
+ * No side effects, no writes — the guard module owns the ledger.
5
+ */
6
+ // --- Constants ---
7
+ /** Window for density classification and repeat counting. */
8
+ const DENSITY_WINDOW_MS = 3000;
9
+ /** Minimum gain multiplier — prevents sounds from disappearing entirely. */
10
+ const GAIN_FLOOR = 0.40;
11
+ // --- Density classification ---
12
+ /**
13
+ * Classify event density based on recent entries in the window.
14
+ * 0–2 events = sparse, 3–5 = medium, 6+ = bursty.
15
+ */
16
+ export function classifyDensity(entries, now = Date.now()) {
17
+ const cutoff = now - DENSITY_WINDOW_MS;
18
+ const recent = entries.filter((e) => e.timestamp > cutoff);
19
+ if (recent.length <= 2)
20
+ return "sparse";
21
+ if (recent.length <= 5)
22
+ return "medium";
23
+ return "bursty";
24
+ }
25
+ // --- Repetition counting ---
26
+ /**
27
+ * Count how many times the same verb fired in the recent window.
28
+ * Returns the count of prior occurrences (0 = first time).
29
+ */
30
+ export function countRecentRepeats(entries, verb, now = Date.now(), windowMs = DENSITY_WINDOW_MS) {
31
+ const cutoff = now - windowMs;
32
+ return entries.filter((e) => e.timestamp > cutoff && e.verb === verb).length;
33
+ }
34
+ // --- Priority tiers ---
35
+ /**
36
+ * Get the ducking priority for a verb.
37
+ * High = important culmination events (less ducking).
38
+ * Low = frequent scan events (more ducking during bursts).
39
+ * Normal = everything else.
40
+ */
41
+ export function getDuckingPriority(verb) {
42
+ switch (verb) {
43
+ case "commit":
44
+ case "sync":
45
+ return "high";
46
+ case "navigate":
47
+ return "low";
48
+ default:
49
+ return "normal";
50
+ }
51
+ }
52
+ // --- Density ducking tables ---
53
+ const DENSITY_GAIN = {
54
+ sparse: { high: 1.0, normal: 1.0, low: 1.0 },
55
+ medium: { high: 0.95, normal: 0.88, low: 0.82 },
56
+ bursty: { high: 0.90, normal: 0.75, low: 0.65 },
57
+ };
58
+ const DENSITY_RELEASE = {
59
+ sparse: 1.0,
60
+ medium: 0.85,
61
+ bursty: 0.70,
62
+ };
63
+ // --- Repetition ducking ---
64
+ function getRepetitionGain(repeatCount) {
65
+ if (repeatCount <= 1)
66
+ return 1.0;
67
+ if (repeatCount === 2)
68
+ return 0.95;
69
+ if (repeatCount === 3)
70
+ return 0.90;
71
+ return 0.85; // 4+
72
+ }
73
+ function getRepetitionRelease(repeatCount) {
74
+ if (repeatCount <= 1)
75
+ return 1.0;
76
+ if (repeatCount === 2)
77
+ return 1.0;
78
+ if (repeatCount === 3)
79
+ return 0.85;
80
+ return 0.80; // 4+
81
+ }
82
+ /**
83
+ * Determine variant rotation for repeated verbs.
84
+ * After 2+ repeats, force rotation to the next variant.
85
+ */
86
+ function getRepetitionVariant(entries, verb, now, repeatCount) {
87
+ if (repeatCount < 2)
88
+ return undefined;
89
+ // Rotate based on repeat count (simple modular rotation across 3 variants)
90
+ return repeatCount % 3;
91
+ }
92
+ // --- Main entry point ---
93
+ /**
94
+ * Compute mix advice for a verb given the current ledger state.
95
+ * Returns gain/release multipliers, optional forced variant, and diagnostics.
96
+ */
97
+ export function getMixAdvice(verb, entries, now = Date.now()) {
98
+ const density = classifyDensity(entries, now);
99
+ const priority = getDuckingPriority(verb);
100
+ const repeatCount = countRecentRepeats(entries, verb, now);
101
+ // Density ducking
102
+ const densityGain = DENSITY_GAIN[density][priority];
103
+ const densityRelease = DENSITY_RELEASE[density];
104
+ // Repetition ducking (composes multiplicatively)
105
+ const repGain = getRepetitionGain(repeatCount);
106
+ const repRelease = getRepetitionRelease(repeatCount);
107
+ // Compose
108
+ const gainMultiplier = Math.max(densityGain * repGain, GAIN_FLOOR);
109
+ const releaseMultiplier = Math.max(densityRelease * repRelease, 0.50);
110
+ // Variant rotation
111
+ const forcedVariantIndex = getRepetitionVariant(entries, verb, now, repeatCount);
112
+ return {
113
+ gainMultiplier,
114
+ releaseMultiplier,
115
+ forcedVariantIndex,
116
+ density,
117
+ recentRepeatCount: repeatCount,
118
+ };
119
+ }
package/dist/player.js CHANGED
@@ -22,15 +22,25 @@ function getTempPath() {
22
22
  function getPlayCommand(filePath) {
23
23
  const platform = process.platform;
24
24
  if (platform === "win32") {
25
- // PowerShell SoundPlayersynchronous, reliable, built-in
26
- return {
27
- command: "powershell",
28
- args: [
29
- "-NoProfile",
30
- "-Command",
31
- `(New-Object Media.SoundPlayer '${filePath}').PlaySync()`,
32
- ],
33
- };
25
+ // Prefer ffplay (from ffmpeg) fast, reliable, no UI
26
+ try {
27
+ execSync("where ffplay", { stdio: "ignore" });
28
+ return {
29
+ command: "ffplay",
30
+ args: ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath],
31
+ };
32
+ }
33
+ catch {
34
+ // Fallback: PowerShell SoundPlayer
35
+ return {
36
+ command: "powershell",
37
+ args: [
38
+ "-NoProfile",
39
+ "-Command",
40
+ `(New-Object Media.SoundPlayer '${filePath}').PlaySync()`,
41
+ ],
42
+ };
43
+ }
34
44
  }
35
45
  if (platform === "darwin") {
36
46
  return { command: "afplay", args: [filePath] };
package/dist/profiles.js CHANGED
@@ -66,7 +66,7 @@ const MINIMAL_PROFILE = {
66
66
  // ── intake: light, inviting, curious ───────────────────────────
67
67
  intake: {
68
68
  type: "motif",
69
- gain: 0.18,
69
+ gain: 0.55,
70
70
  variants: [
71
71
  // Variant A: A4 → C#5
72
72
  { notes: [
@@ -88,7 +88,7 @@ const MINIMAL_PROFILE = {
88
88
  // ── transform: active, clever, articulate ─────────────────────
89
89
  transform: {
90
90
  type: "motif",
91
- gain: 0.17,
91
+ gain: 0.52,
92
92
  variants: [
93
93
  // Variant A: C#5 → B4 → E5 (3-note)
94
94
  { notes: [
@@ -112,7 +112,7 @@ const MINIMAL_PROFILE = {
112
112
  // ── commit: satisfying, settled, rewarding ────────────────────
113
113
  commit: {
114
114
  type: "motif",
115
- gain: 0.20,
115
+ gain: 0.60,
116
116
  variants: [
117
117
  // Variant A: E4+A4 dyad → C#5 sparkle
118
118
  { notes: [
@@ -137,7 +137,7 @@ const MINIMAL_PROFILE = {
137
137
  // ── navigate: precise, clean, directional ─────────────────────
138
138
  navigate: {
139
139
  type: "motif",
140
- gain: 0.16,
140
+ gain: 0.50,
141
141
  variants: [
142
142
  // Variant A: B4 → E5
143
143
  { notes: [
@@ -159,7 +159,7 @@ const MINIMAL_PROFILE = {
159
159
  // ── execute: grounded, tactile, percussive ────────────────────
160
160
  execute: {
161
161
  type: "motif",
162
- gain: 0.19,
162
+ gain: 0.58,
163
163
  variants: [
164
164
  // Variant A: A3 body + noise click
165
165
  {
@@ -193,7 +193,7 @@ const MINIMAL_PROFILE = {
193
193
  freqDown: [2400, 700],
194
194
  bandwidth: 0.7,
195
195
  envelope: { attack: 0.01, decay: 0.11, sustain: 0.10, release: 0.06 },
196
- gain: 0.17,
196
+ gain: 0.52,
197
197
  anchorVariants: [
198
198
  // Variant A: E4 → A4
199
199
  { notes: [
@@ -219,7 +219,7 @@ const MINIMAL_PROFILE = {
219
219
  freqDown: [2600, 500],
220
220
  bandwidth: 0.65,
221
221
  envelope: { attack: 0.008, decay: 0.12, sustain: 0.16, release: 0.09 },
222
- gain: 0.19,
222
+ gain: 0.58,
223
223
  anchorVariants: [
224
224
  // Variant A: A3+E4 anchor → C#5 confirmation
225
225
  { notes: [
@@ -250,7 +250,7 @@ const MINIMAL_PROFILE = {
250
250
  frequency: S.A4,
251
251
  duration: 0.18,
252
252
  envelope: { attack: 0.01, decay: 0.08, sustain: 0.25, release: 0.06 },
253
- gain: 0.20,
253
+ gain: 0.60,
254
254
  harmonicGain: 0.08,
255
255
  },
256
256
  tone2: {
@@ -260,7 +260,7 @@ const MINIMAL_PROFILE = {
260
260
  frequency: S.Cs5,
261
261
  duration: 0.22,
262
262
  envelope: { attack: 0.01, decay: 0.10, sustain: 0.25, release: 0.08 },
263
- gain: 0.18,
263
+ gain: 0.55,
264
264
  harmonicGain: 0.08,
265
265
  },
266
266
  staggerSeconds: 0.065,
@@ -273,7 +273,7 @@ const MINIMAL_PROFILE = {
273
273
  frequency: S.Cs5,
274
274
  duration: 0.15,
275
275
  envelope: { attack: 0.01, decay: 0.08, sustain: 0.18, release: 0.04 },
276
- gain: 0.18,
276
+ gain: 0.55,
277
277
  },
278
278
  tone2: {
279
279
  waveform: "sine",
@@ -282,20 +282,20 @@ const MINIMAL_PROFILE = {
282
282
  frequency: S.A4,
283
283
  duration: 0.25,
284
284
  envelope: { attack: 0.01, decay: 0.12, sustain: 0.18, release: 0.10 },
285
- gain: 0.16,
285
+ gain: 0.50,
286
286
  },
287
287
  staggerSeconds: 0.075,
288
288
  },
289
289
  ambient: {
290
290
  droneFreq: S.A3,
291
291
  droneWaveform: "sine",
292
- droneGain: 0.10,
292
+ droneGain: 0.25,
293
293
  chunkDuration: 2.0,
294
294
  resolveNote1: S.A4,
295
295
  resolveNote2: S.E5,
296
296
  resolveWaveform: "sine",
297
297
  resolveDuration: 0.16,
298
- resolveGain: 0.18,
298
+ resolveGain: 0.55,
299
299
  },
300
300
  };
301
301
  // --- Retro profile: same motifs, square waveform, snappier envelopes ---
package/dist/synth.d.ts CHANGED
@@ -46,6 +46,11 @@ export declare function mixBuffers(buffers: Float64Array[]): Float64Array;
46
46
  export declare function concatBuffers(buffers: Float64Array[], gapSeconds?: number): Float64Array;
47
47
  /** Encode a Float64 audio buffer as a WAV file (PCM 16-bit mono). */
48
48
  export declare function encodeWav(buffer: Float64Array): Buffer;
49
+ /**
50
+ * One-pole RC low-pass filter.
51
+ * Gentle 6dB/octave rolloff — mimics distance (air absorbs highs).
52
+ */
53
+ export declare function applyLowPass(buffer: Float64Array, cutoffHz: number): Float64Array;
49
54
  /** Apply a hard loudness cap with gentle compression to a buffer. */
50
55
  export declare function limitLoudness(buffer: Float64Array, ceiling?: number): Float64Array;
51
56
  /** Apply a volume gain (0.0–1.0) to a buffer. */
package/dist/synth.js CHANGED
@@ -187,6 +187,21 @@ export function encodeWav(buffer) {
187
187
  }
188
188
  return wav;
189
189
  }
190
+ // --- Low-Pass Filter ---
191
+ /**
192
+ * One-pole RC low-pass filter.
193
+ * Gentle 6dB/octave rolloff — mimics distance (air absorbs highs).
194
+ */
195
+ export function applyLowPass(buffer, cutoffHz) {
196
+ const a = Math.exp(-2 * Math.PI * cutoffHz / SAMPLE_RATE);
197
+ const result = new Float64Array(buffer.length);
198
+ let prev = 0;
199
+ for (let i = 0; i < buffer.length; i++) {
200
+ prev = (1 - a) * buffer[i] + a * prev;
201
+ result[i] = prev;
202
+ }
203
+ return result;
204
+ }
190
205
  // --- Loudness Limiter ---
191
206
  /** Apply a hard loudness cap with gentle compression to a buffer. */
192
207
  export function limitLoudness(buffer, ceiling = 0.85) {
package/dist/verbs.d.ts CHANGED
@@ -19,6 +19,10 @@ export interface PlayOptions {
19
19
  escalation?: number;
20
20
  /** Override variant selection (0-indexed). For testing determinism. */
21
21
  variantIndex?: number;
22
+ /** Mix-layer gain multiplier from density/repetition analysis (0.4–1.0). */
23
+ mixGain?: number;
24
+ /** Mix-layer release multiplier from density/repetition analysis. */
25
+ mixRelease?: number;
22
26
  }
23
27
  export type Verb = "intake" | "transform" | "commit" | "navigate" | "execute" | "move" | "sync";
24
28
  export declare const VERB_LABELS: Record<Verb, string>;
package/dist/verbs.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Architecture: multi-note motifs in A major pentatonic with constrained variation.
6
6
  * Each verb has 3 variants. Micro-jitter keeps sounds alive without breaking identity.
7
7
  */
8
- import { SAMPLE_RATE, generateTone, generateWhoosh, mixBuffers, limitLoudness, } from "./synth.js";
8
+ import { SAMPLE_RATE, generateTone, generateWhoosh, mixBuffers, limitLoudness, applyLowPass, applyVolume, } from "./synth.js";
9
9
  import { SCALE, scaleStepDown } from "./profiles.js";
10
10
  export const VERB_LABELS = {
11
11
  intake: "Intake",
@@ -335,6 +335,30 @@ function applyWhooshEscalation(params, escalation) {
335
335
  }
336
336
  return p;
337
337
  }
338
+ // --- Mix modifiers (density/repetition from Pass B) ---
339
+ /** Scale all note release times in a motif (density/repetition ducking). */
340
+ function applyMotifMixRelease(variant, multiplier) {
341
+ if (multiplier >= 1)
342
+ return variant;
343
+ return {
344
+ ...variant,
345
+ notes: variant.notes.map(n => ({
346
+ ...n,
347
+ envelope: { ...n.envelope, release: n.envelope.release * multiplier },
348
+ })),
349
+ };
350
+ }
351
+ /** Scale whoosh envelope release (density/repetition ducking). */
352
+ function applyWhooshMixRelease(params, multiplier) {
353
+ if (multiplier >= 1)
354
+ return params;
355
+ return {
356
+ ...params,
357
+ envelope: { ...params.envelope, release: params.envelope.release * multiplier },
358
+ };
359
+ }
360
+ /** Low-pass cutoff for remote scope (Hz). Gentle rolloff mimicking distance. */
361
+ const REMOTE_LP_CUTOFF = 2500;
338
362
  // --- Main generation ---
339
363
  /** Generate a verb sound using the given profile. */
340
364
  export function generateVerb(profile, verb, options = {}) {
@@ -342,6 +366,7 @@ export function generateVerb(profile, verb, options = {}) {
342
366
  if (!cfg) {
343
367
  throw new Error(`Profile "${profile.name}" has no config for verb "${verb}"`);
344
368
  }
369
+ const isRemote = options.scope === "remote";
345
370
  // ── Whoosh-based verbs (move, sync) ─────────────────────────
346
371
  if (cfg.type === "whoosh") {
347
372
  const dir = options.direction ?? "up";
@@ -362,11 +387,13 @@ export function generateVerb(profile, verb, options = {}) {
362
387
  wp = applyWhooshIntensity(wp, options.intensity);
363
388
  if (options.escalation)
364
389
  wp = applyWhooshEscalation(wp, options.escalation);
390
+ if (options.mixRelease && options.mixRelease < 1)
391
+ wp = applyWhooshMixRelease(wp, options.mixRelease);
365
392
  const whooshBuf = generateWhoosh(wp);
366
393
  // Tonal anchor variant
394
+ let result;
367
395
  if (cfg.anchorVariants && cfg.anchorVariants.length > 0) {
368
396
  let anchor = pickVariant(cfg.anchorVariants, options.variantIndex);
369
- // Apply motif modifiers to anchor notes too
370
397
  let anchorMotif = { notes: anchor.notes };
371
398
  if (options.status)
372
399
  anchorMotif = applyMotifStatus(anchorMotif, options.status);
@@ -376,16 +403,26 @@ export function generateVerb(profile, verb, options = {}) {
376
403
  anchorMotif = applyMotifIntensity(anchorMotif, options.intensity);
377
404
  if (options.escalation)
378
405
  anchorMotif = applyMotifEscalation(anchorMotif, options.escalation);
406
+ if (options.mixRelease && options.mixRelease < 1)
407
+ anchorMotif = applyMotifMixRelease(anchorMotif, options.mixRelease);
379
408
  const anchorBuf = renderMotif(anchorMotif, cfg.gain);
380
- // Mix: align to same start, extend to longest
381
409
  const maxLen = Math.max(whooshBuf.length, anchorBuf.length);
382
410
  const whooshPadded = new Float64Array(maxLen);
383
411
  whooshPadded.set(whooshBuf, 0);
384
412
  const anchorPadded = new Float64Array(maxLen);
385
413
  anchorPadded.set(anchorBuf, 0);
386
- return limitLoudness(mixBuffers([whooshPadded, anchorPadded]));
414
+ result = mixBuffers([whooshPadded, anchorPadded]);
415
+ }
416
+ else {
417
+ result = whooshBuf;
387
418
  }
388
- return limitLoudness(whooshBuf);
419
+ // Remote low-pass softening
420
+ if (isRemote)
421
+ result = applyLowPass(result, REMOTE_LP_CUTOFF);
422
+ // Mix gain ducking
423
+ if (options.mixGain && options.mixGain < 1)
424
+ result = applyVolume(result, options.mixGain);
425
+ return limitLoudness(result);
389
426
  }
390
427
  // ── Motif-based verbs (intake, transform, commit, navigate, execute) ──
391
428
  let variant = pickVariant(cfg.variants, options.variantIndex);
@@ -397,7 +434,15 @@ export function generateVerb(profile, verb, options = {}) {
397
434
  variant = applyMotifIntensity(variant, options.intensity);
398
435
  if (options.escalation)
399
436
  variant = applyMotifEscalation(variant, options.escalation);
400
- const buffer = renderMotif(variant, cfg.gain);
437
+ if (options.mixRelease && options.mixRelease < 1)
438
+ variant = applyMotifMixRelease(variant, options.mixRelease);
439
+ let buffer = renderMotif(variant, cfg.gain);
440
+ // Remote low-pass softening
441
+ if (isRemote)
442
+ buffer = applyLowPass(buffer, REMOTE_LP_CUTOFF);
443
+ // Mix gain ducking
444
+ if (options.mixGain && options.mixGain < 1)
445
+ buffer = applyVolume(buffer, options.mixGain);
401
446
  return limitLoudness(buffer);
402
447
  }
403
448
  // --- Session sounds ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/claude-sfx",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Procedural audio feedback for Claude Code — UX for agentic coding",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,7 @@
4
4
  "verbs": {
5
5
  "intake": {
6
6
  "type": "motif",
7
- "gain": 0.18,
7
+ "gain": 0.55,
8
8
  "variants": [
9
9
  {
10
10
  "notes": [
@@ -112,7 +112,7 @@
112
112
  },
113
113
  "transform": {
114
114
  "type": "motif",
115
- "gain": 0.17,
115
+ "gain": 0.52,
116
116
  "variants": [
117
117
  {
118
118
  "notes": [
@@ -266,7 +266,7 @@
266
266
  },
267
267
  "commit": {
268
268
  "type": "motif",
269
- "gain": 0.2,
269
+ "gain": 0.6,
270
270
  "variants": [
271
271
  {
272
272
  "notes": [
@@ -428,7 +428,7 @@
428
428
  },
429
429
  "navigate": {
430
430
  "type": "motif",
431
- "gain": 0.16,
431
+ "gain": 0.5,
432
432
  "variants": [
433
433
  {
434
434
  "notes": [
@@ -542,7 +542,7 @@
542
542
  },
543
543
  "execute": {
544
544
  "type": "motif",
545
- "gain": 0.19,
545
+ "gain": 0.58,
546
546
  "variants": [
547
547
  {
548
548
  "notes": [
@@ -657,7 +657,7 @@
657
657
  "sustain": 0.1,
658
658
  "release": 0.06
659
659
  },
660
- "gain": 0.17,
660
+ "gain": 0.52,
661
661
  "anchorVariants": [
662
662
  {
663
663
  "notes": [
@@ -766,7 +766,7 @@
766
766
  "sustain": 0.16,
767
767
  "release": 0.09
768
768
  },
769
- "gain": 0.19,
769
+ "gain": 0.58,
770
770
  "anchorVariants": [
771
771
  {
772
772
  "notes": [
@@ -916,7 +916,7 @@
916
916
  "sustain": 0.25,
917
917
  "release": 0.06
918
918
  },
919
- "gain": 0.2,
919
+ "gain": 0.6,
920
920
  "harmonicGain": 0.08
921
921
  },
922
922
  "tone2": {
@@ -931,7 +931,7 @@
931
931
  "sustain": 0.25,
932
932
  "release": 0.08
933
933
  },
934
- "gain": 0.18,
934
+ "gain": 0.55,
935
935
  "harmonicGain": 0.08
936
936
  },
937
937
  "staggerSeconds": 0.065
@@ -949,7 +949,7 @@
949
949
  "sustain": 0.18,
950
950
  "release": 0.04
951
951
  },
952
- "gain": 0.18
952
+ "gain": 0.55
953
953
  },
954
954
  "tone2": {
955
955
  "waveform": "sine",
@@ -963,19 +963,19 @@
963
963
  "sustain": 0.18,
964
964
  "release": 0.1
965
965
  },
966
- "gain": 0.16
966
+ "gain": 0.5
967
967
  },
968
968
  "staggerSeconds": 0.075
969
969
  },
970
970
  "ambient": {
971
971
  "droneFreq": 220,
972
972
  "droneWaveform": "sine",
973
- "droneGain": 0.1,
973
+ "droneGain": 0.25,
974
974
  "chunkDuration": 2,
975
975
  "resolveNote1": 440,
976
976
  "resolveNote2": 659.25,
977
977
  "resolveWaveform": "sine",
978
978
  "resolveDuration": 0.16,
979
- "resolveGain": 0.18
979
+ "resolveGain": 0.55
980
980
  }
981
981
  }