@mcptoolshop/claude-sfx 1.2.1 → 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,19 @@ 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
+
10
23
  ## [1.2.1] - 2026-03-30
11
24
 
12
25
  ### Fixed
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/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.1",
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": {