@mcptoolshop/claude-sfx 1.0.0 → 1.1.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,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-03-19
11
+
12
+ ### Added
13
+ - **Streak awareness** — consecutive successful plays build intensity (1-5), adding harmonic layers, frequency lift, FM shimmer, and gain boost as momentum builds
14
+ - **Error escalation** — consecutive errors progressively increase urgency (1-5), with deeper frequency drops, wider detuning, tremolo, and longer decay
15
+ - **Completion fanfare** — session end sound is outcome-aware: triumphant ascending chord for great sessions (80%+ success), muted resolution for rough ones, standard chime for normal
16
+ - New `--intensity` and `--escalation` flags for `play` command
17
+ - `demo` command now showcases intensity levels 1-5 and error escalation 1-5
18
+ - `export` command now includes intensity variants, escalation variants, fanfare, and muted end WAVs
19
+ - New `streak.ts` module for tracking session momentum and error runs
20
+ - 40 new tests (192 total)
21
+
22
+ ### Fixed
23
+ - Ambient drone now self-terminates after 30 minutes (prevents orphaned loops)
24
+ - Stale ambient PID/WAV files cleaned up on detection
25
+ - Hook and player timeout reduced from 5s to 3s (sounds are 80-320ms)
26
+ - npm audit now blocks CI on known vulnerabilities (removed `|| true`)
27
+ - CI coverage thresholds enforced (80% lines/functions/statements, 70% branches)
28
+ - SCORECARD.md filled with actual audit gate results
29
+
10
30
  ## [1.0.0] - 2026-02-27
11
31
 
12
32
  ### Added
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
- <p align="center">
2
- <a href="README.ja.md">日本語</a> | <a href="README.zh.md">中文</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.hi.md">हिन्दी</a> | <a href="README.it.md">Italiano</a> | <a href="README.pt-BR.md">Português (BR)</a>
3
- </p>
4
-
1
+ <p align="center">
2
+ <a href="README.ja.md">日本語</a> | <a href="README.zh.md">中文</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.hi.md">हिन्दी</a> | <a href="README.it.md">Italiano</a> | <a href="README.pt-BR.md">Português (BR)</a>
3
+ </p>
4
+
5
5
  <p align="center">
6
6
  <img src="https://raw.githubusercontent.com/mcp-tool-shop-org/brand/main/logos/claude-sfx/readme.jpg" width="400" alt="Claude-SFX">
7
7
  </p>
@@ -58,8 +58,42 @@ claude-sfx play navigate --status warn # tremolo ping
58
58
  claude-sfx play sync --direction up # rising whoosh (push)
59
59
  claude-sfx play sync --direction down # falling whoosh (pull)
60
60
  claude-sfx play intake --scope remote # longer tail (distance feel)
61
+ claude-sfx play commit --intensity 5 # full streak momentum
62
+ claude-sfx play execute --status err --escalation 4 # urgent repeated error
61
63
  ```
62
64
 
65
+ ### Streak Awareness
66
+
67
+ Sounds evolve with momentum. Consecutive successful tool calls build a streak that adds harmonic richness — your productive session *sounds* productive.
68
+
69
+ | Streak | Intensity | Effect |
70
+ |---|---|---|
71
+ | 1 play | Level 1 | Clean base tone |
72
+ | 3+ plays | Level 2 | Subtle octave harmonic |
73
+ | 6+ plays | Level 3 | Harmonic + brighter frequency |
74
+ | 10+ plays | Level 4 | FM shimmer added |
75
+ | 15+ plays | Level 5 | Full richness — session is cooking |
76
+
77
+ Streaks reset after 30 seconds of idle or on any error.
78
+
79
+ ### Error Escalation
80
+
81
+ Repeated errors get progressively more urgent — so you know something is stuck without reading the output.
82
+
83
+ | Consecutive Errors | Escalation | Effect |
84
+ |---|---|---|
85
+ | 1st error | Level 1 | Standard error tone (detuned, dropped pitch) |
86
+ | 2nd error | Level 2 | Deeper frequency drop + more detuning |
87
+ | 3rd error | Level 3 | Tremolo added (audible "wobble") |
88
+ | 4th error | Level 4 | Wide detuning, slower tremolo, longer decay |
89
+ | 5th+ error | Level 5 | Max urgency — deep, heavy, lingering |
90
+
91
+ Resets on any successful play.
92
+
93
+ ### Completion Fanfare
94
+
95
+ Session end sounds are outcome-aware. A great session (80%+ success rate, 5+ plays) gets a triumphant ascending chord. A rough session gets a muted, subdued resolution. Short sessions get the standard chime.
96
+
63
97
  ### Smart Bash Detection
64
98
 
65
99
  The hook handler inspects Bash commands to pick the right sound:
@@ -177,7 +211,7 @@ Zero audio files. Every sound is synthesized at runtime from math:
177
211
  - **Frequency sweeps** — linear interpolation for movement
178
212
  - **Loudness limiter** — soft-knee compression, hard ceiling
179
213
 
180
- The entire package is ~2,800 lines of TypeScript with zero production dependencies. Sounds are generated as PCM buffers, encoded to WAV in memory, and played through the OS native audio player (PowerShell on Windows, afplay on macOS, aplay on Linux).
214
+ Sounds adapt over time through **streak awareness** (momentum builds harmonic layers), **error escalation** (repeated failures increase urgency), and **completion fanfare** (session outcome shapes the closing chord). The entire package is pure TypeScript with zero production dependencies. Sounds are generated as PCM buffers, encoded to WAV in memory, and played through the OS native audio player (PowerShell on Windows, afplay on macOS, aplay on Linux).
181
215
 
182
216
  ## Security & Privacy
183
217
 
package/dist/ambient.js CHANGED
@@ -16,12 +16,15 @@ import { encodeWav } from "./synth.js";
16
16
  import { playSync } from "./player.js";
17
17
  const PID_FILE = join(tmpdir(), "claude-sfx-ambient.pid");
18
18
  const DRONE_WAV = join(tmpdir(), "claude-sfx-drone.wav");
19
+ /** Max ambient lifetime in seconds — kills orphaned drones. */
20
+ const MAX_LIFETIME_S = 30 * 60; // 30 minutes
19
21
  /** Check if an ambient drone is currently running. */
20
22
  export function isAmbientRunning() {
21
23
  if (!existsSync(PID_FILE))
22
24
  return false;
23
25
  try {
24
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
26
+ const raw = readFileSync(PID_FILE, "utf-8").trim();
27
+ const pid = parseInt(raw.split("\n")[0], 10);
25
28
  // Check if process is alive (signal 0 = existence check)
26
29
  process.kill(pid, 0);
27
30
  return true;
@@ -32,6 +35,10 @@ export function isAmbientRunning() {
32
35
  unlinkSync(PID_FILE);
33
36
  }
34
37
  catch { /* ignore */ }
38
+ try {
39
+ unlinkSync(DRONE_WAV);
40
+ }
41
+ catch { /* ignore */ }
35
42
  return false;
36
43
  }
37
44
  }
@@ -51,28 +58,28 @@ export function startAmbient(profile) {
51
58
  const platform = process.platform;
52
59
  let child;
53
60
  if (platform === "win32") {
54
- // PowerShell loop: plays the WAV repeatedly until killed
61
+ // PowerShell loop: plays the WAV repeatedly until killed or max lifetime reached
55
62
  child = spawn("powershell", [
56
63
  "-NoProfile",
57
64
  "-Command",
58
- `while($true){(New-Object Media.SoundPlayer '${DRONE_WAV}').PlaySync()}`,
65
+ `$start=[DateTime]::UtcNow; while(([DateTime]::UtcNow-$start).TotalSeconds -lt ${MAX_LIFETIME_S}){(New-Object Media.SoundPlayer '${DRONE_WAV}').PlaySync()}`,
59
66
  ], {
60
67
  stdio: "ignore",
61
68
  detached: true,
62
69
  });
63
70
  }
64
71
  else if (platform === "darwin") {
65
- // macOS: loop with afplay
72
+ // macOS: loop with afplay, self-terminates after max lifetime
66
73
  child = spawn("bash", [
67
74
  "-c",
68
- `while true; do afplay "${DRONE_WAV}"; done`,
75
+ `END=$((SECONDS+${MAX_LIFETIME_S})); while [ $SECONDS -lt $END ]; do afplay "${DRONE_WAV}"; done`,
69
76
  ], {
70
77
  stdio: "ignore",
71
78
  detached: true,
72
79
  });
73
80
  }
74
81
  else {
75
- // Linux: loop with aplay/paplay
82
+ // Linux: loop with aplay/paplay, self-terminates after max lifetime
76
83
  const player = (() => {
77
84
  try {
78
85
  execSync("which paplay", { stdio: "ignore" });
@@ -84,7 +91,7 @@ export function startAmbient(profile) {
84
91
  })();
85
92
  child = spawn("bash", [
86
93
  "-c",
87
- `while true; do ${player} "${DRONE_WAV}"; done`,
94
+ `END=$((SECONDS+${MAX_LIFETIME_S})); while [ $SECONDS -lt $END ]; do ${player} "${DRONE_WAV}"; done`,
88
95
  ], {
89
96
  stdio: "ignore",
90
97
  detached: true,
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * claude-sfx CLI
4
4
  * Procedural audio feedback for Claude Code.
5
5
  */
6
- import { ALL_VERBS, VERB_LABELS, VERB_DESCRIPTIONS, generateVerb, generateSessionStart, generateSessionEnd, generateAmbientResolve, } from "./verbs.js";
6
+ import { ALL_VERBS, VERB_LABELS, VERB_DESCRIPTIONS, generateVerb, generateSessionStart, generateSessionEnd, generateSessionEndWithOutcome, generateAmbientResolve, } from "./verbs.js";
7
7
  import { concatBuffers, applyVolume } from "./synth.js";
8
8
  import { playSync, saveWav } from "./player.js";
9
9
  import { resolveProfile, listBuiltinProfiles, } from "./profiles.js";
@@ -90,6 +90,20 @@ function cmdDemo(flags) {
90
90
  console.log(" [no audio output available]");
91
91
  }
92
92
  }
93
+ console.log("\n Streak intensity (commit at levels 1-5):\n");
94
+ for (let i = 1; i <= 5; i++) {
95
+ process.stdout.write(` Level ${i} `);
96
+ const buf = generateVerb(profile, "commit", { intensity: i });
97
+ const r = playSync(buf);
98
+ console.log(r.played ? `(${r.durationMs}ms)` : "[no audio]");
99
+ }
100
+ console.log("\n Error escalation (execute at levels 1-5):\n");
101
+ for (let i = 1; i <= 5; i++) {
102
+ process.stdout.write(` Level ${i} `);
103
+ const buf = generateVerb(profile, "execute", { status: "err", escalation: i });
104
+ const r = playSync(buf);
105
+ console.log(r.played ? `(${r.durationMs}ms)` : "[no audio]");
106
+ }
93
107
  console.log("\n Session sounds:\n");
94
108
  process.stdout.write(" Start boot chime");
95
109
  const startResult = playSync(generateSessionStart(profile));
@@ -97,6 +111,12 @@ function cmdDemo(flags) {
97
111
  process.stdout.write(" End closure");
98
112
  const endResult = playSync(generateSessionEnd(profile));
99
113
  console.log(endResult.played ? ` (${endResult.durationMs}ms)` : " [no audio]");
114
+ process.stdout.write(" Fanfare great session");
115
+ const fanfareResult = playSync(generateSessionEndWithOutcome(profile, { successRatio: 1, totalPlays: 20 }));
116
+ console.log(fanfareResult.played ? ` (${fanfareResult.durationMs}ms)` : " [no audio]");
117
+ process.stdout.write(" Muted rough session");
118
+ const mutedResult = playSync(generateSessionEndWithOutcome(profile, { successRatio: 0.3, totalPlays: 20 }));
119
+ console.log(mutedResult.played ? ` (${mutedResult.durationMs}ms)` : " [no audio]");
100
120
  process.stdout.write(" Resolve stinger");
101
121
  const resolveResult = playSync(generateAmbientResolve(profile));
102
122
  console.log(resolveResult.played ? ` (${resolveResult.durationMs}ms)` : " [no audio]");
@@ -152,6 +172,22 @@ function cmdPlay(positional, flags) {
152
172
  }
153
173
  options.direction = flags.direction;
154
174
  }
175
+ if (flags.intensity) {
176
+ const n = parseInt(flags.intensity, 10);
177
+ if (isNaN(n) || n < 1 || n > 5) {
178
+ console.error(` Error: invalid intensity "${flags.intensity}". Use: 1-5`);
179
+ process.exit(1);
180
+ }
181
+ options.intensity = n;
182
+ }
183
+ if (flags.escalation) {
184
+ const n = parseInt(flags.escalation, 10);
185
+ if (isNaN(n) || n < 1 || n > 5) {
186
+ console.error(` Error: invalid escalation "${flags.escalation}". Use: 1-5`);
187
+ process.exit(1);
188
+ }
189
+ options.escalation = n;
190
+ }
155
191
  let buffer = generateVerb(profile, verb, options);
156
192
  // Apply volume
157
193
  const gain = volumeToGain(config.volume);
@@ -448,17 +484,33 @@ function cmdExport(positional, flags) {
448
484
  console.log(` ${filename}`);
449
485
  }
450
486
  }
487
+ // Intensity variants (commit at levels 1-5)
488
+ for (let i = 1; i <= 5; i++) {
489
+ const filename = `commit-intensity-${i}.wav`;
490
+ saveWav(generateVerb(profile, "commit", { intensity: i }), join(outputDir, filename));
491
+ console.log(` ${filename}`);
492
+ }
493
+ // Escalation variants (execute-err at levels 1-5)
494
+ for (let i = 1; i <= 5; i++) {
495
+ const filename = `execute-err-escalation-${i}.wav`;
496
+ saveWav(generateVerb(profile, "execute", { status: "err", escalation: i }), join(outputDir, filename));
497
+ console.log(` ${filename}`);
498
+ }
451
499
  saveWav(generateSessionStart(profile), join(outputDir, "session-start.wav"));
452
500
  console.log(" session-start.wav");
453
501
  saveWav(generateSessionEnd(profile), join(outputDir, "session-end.wav"));
454
502
  console.log(" session-end.wav");
503
+ saveWav(generateSessionEndWithOutcome(profile, { successRatio: 1, totalPlays: 20 }), join(outputDir, "session-fanfare.wav"));
504
+ console.log(" session-fanfare.wav");
505
+ saveWav(generateSessionEndWithOutcome(profile, { successRatio: 0.3, totalPlays: 20 }), join(outputDir, "session-muted.wav"));
506
+ console.log(" session-muted.wav");
455
507
  saveWav(generateAmbientResolve(profile), join(outputDir, "ambient-resolve.wav"));
456
508
  console.log(" ambient-resolve.wav");
457
509
  const demoBuffers = ALL_VERBS.map((v) => generateVerb(profile, v));
458
510
  const demoSequence = concatBuffers(demoBuffers, 0.3);
459
511
  saveWav(demoSequence, join(outputDir, "demo-sequence.wav"));
460
512
  console.log(" demo-sequence.wav");
461
- const fileCount = ALL_VERBS.length * statuses.length + 4 + 3 + 1;
513
+ const fileCount = ALL_VERBS.length * statuses.length + 4 + 5 + 5 + 4 + 1;
462
514
  console.log(`\n Exported ${fileCount} files.\n`);
463
515
  }
464
516
  // --- Init / Uninstall ---
@@ -534,6 +586,8 @@ function cmdHelp() {
534
586
  --status <ok|err|warn> Status modifier
535
587
  --scope <local|remote> Scope modifier
536
588
  --direction <up|down> Direction (move/sync verbs)
589
+ --intensity <1-5> Streak intensity (harmonic layering)
590
+ --escalation <1-5> Error escalation (urgency level)
537
591
  --profile <name|path> Sound profile (default: minimal)
538
592
  --force Bypass guard (debounce/rate/mute)
539
593
  --debug Show stack traces on errors
@@ -9,13 +9,14 @@
9
9
  * echo '{"tool_name":"Read",...}' | claude-sfx hook-handler PostToolUse
10
10
  * echo '{"session_id":"..."}' | claude-sfx hook-handler SessionStart
11
11
  */
12
- import { generateVerb, generateSessionStart, generateSessionEnd, } from "./verbs.js";
12
+ import { generateVerb, generateSessionStart, generateSessionEndWithOutcome, } from "./verbs.js";
13
13
  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
17
  import { guardPlay } from "./guard.js";
18
18
  import { resolveAmbient, isAmbientRunning } from "./ambient.js";
19
+ import { recordSuccess, recordError, getSessionOutcome, resetStreak } from "./streak.js";
19
20
  /** Map a PostToolUse tool_name to a verb + options. */
20
21
  export function mapToolToVerb(toolName, input) {
21
22
  // Exact matches first
@@ -111,6 +112,16 @@ function playVerb(verb, options = {}) {
111
112
  const guard = guardPlay(verb, config);
112
113
  if (!guard.allowed)
113
114
  return;
115
+ // Streak tracking — record success/error and get intensity/escalation
116
+ const isError = options.status === "err";
117
+ if (isError) {
118
+ const { escalation } = recordError();
119
+ options = { ...options, escalation };
120
+ }
121
+ else {
122
+ const { intensity } = recordSuccess(verb);
123
+ options = { ...options, intensity };
124
+ }
114
125
  const profileName = resolveProfileName(config);
115
126
  const profile = resolveProfile(profileName);
116
127
  let buffer = generateVerb(profile, verb, options);
@@ -126,8 +137,18 @@ function playSession(type) {
126
137
  return;
127
138
  const profileName = resolveProfileName(config);
128
139
  const profile = resolveProfile(profileName);
129
- const gen = type === "start" ? generateSessionStart : generateSessionEnd;
130
- let buffer = gen(profile);
140
+ let buffer;
141
+ if (type === "start") {
142
+ // Reset streak for new session
143
+ resetStreak();
144
+ buffer = generateSessionStart(profile);
145
+ }
146
+ else {
147
+ // Outcome-aware session end (fanfare or muted)
148
+ const outcome = getSessionOutcome();
149
+ buffer = generateSessionEndWithOutcome(profile, outcome);
150
+ resetStreak();
151
+ }
131
152
  const gain = volumeToGain(config.volume);
132
153
  if (gain < 1) {
133
154
  buffer = applyVolume(buffer, gain);
package/dist/hooks.js CHANGED
@@ -39,7 +39,7 @@ export function generateHooksConfig() {
39
39
  {
40
40
  type: "command",
41
41
  command: makeHookCommand(sfxBin, "SessionStart"),
42
- timeout: 5,
42
+ timeout: 3,
43
43
  },
44
44
  ],
45
45
  },
@@ -52,7 +52,7 @@ export function generateHooksConfig() {
52
52
  {
53
53
  type: "command",
54
54
  command: makeHookCommand(sfxBin, "PostToolUse"),
55
- timeout: 5,
55
+ timeout: 3,
56
56
  },
57
57
  ],
58
58
  },
@@ -64,7 +64,7 @@ export function generateHooksConfig() {
64
64
  {
65
65
  type: "command",
66
66
  command: makeHookCommand(sfxBin, "PostToolUseFailure"),
67
- timeout: 5,
67
+ timeout: 3,
68
68
  },
69
69
  ],
70
70
  },
@@ -75,7 +75,7 @@ export function generateHooksConfig() {
75
75
  {
76
76
  type: "command",
77
77
  command: makeHookCommand(sfxBin, "SubagentStart"),
78
- timeout: 5,
78
+ timeout: 3,
79
79
  },
80
80
  ],
81
81
  },
@@ -86,7 +86,7 @@ export function generateHooksConfig() {
86
86
  {
87
87
  type: "command",
88
88
  command: makeHookCommand(sfxBin, "SubagentStop"),
89
- timeout: 5,
89
+ timeout: 3,
90
90
  },
91
91
  ],
92
92
  },
@@ -97,7 +97,7 @@ export function generateHooksConfig() {
97
97
  {
98
98
  type: "command",
99
99
  command: makeHookCommand(sfxBin, "Stop"),
100
- timeout: 5,
100
+ timeout: 3,
101
101
  },
102
102
  ],
103
103
  },
package/dist/player.js CHANGED
@@ -63,7 +63,7 @@ export function playSync(buffer) {
63
63
  }
64
64
  execSync(`${cmd.command} ${cmd.args.map((a) => `"${a}"`).join(" ")}`, {
65
65
  stdio: "ignore",
66
- timeout: 5000,
66
+ timeout: 3000,
67
67
  });
68
68
  return {
69
69
  played: true,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Streak tracker — momentum and error escalation state.
3
+ *
4
+ * Tracks two independent counters persisted in a temp file:
5
+ * - streak: consecutive successful plays → drives intensity (harmonic layering)
6
+ * - errorRun: consecutive error-status plays → drives escalation (urgency)
7
+ *
8
+ * State resets:
9
+ * - streak resets on error, on idle (30s gap), or on session end
10
+ * - errorRun resets on success or on session end
11
+ */
12
+ /** Maximum intensity level (caps harmonic layering). */
13
+ export declare const MAX_INTENSITY = 5;
14
+ /** Maximum error escalation level (caps urgency increase). */
15
+ export declare const MAX_ESCALATION = 5;
16
+ export interface StreakState {
17
+ /** Current streak count (consecutive successful plays). */
18
+ streak: number;
19
+ /** Current error run count (consecutive errors). */
20
+ errorRun: number;
21
+ /** Timestamp of last play (for idle detection). */
22
+ lastPlayAt: number;
23
+ /** Total successful plays this session (for fanfare). */
24
+ sessionSuccesses: number;
25
+ /** Total error plays this session (for fanfare). */
26
+ sessionErrors: number;
27
+ }
28
+ /** Read the current streak state from disk. */
29
+ export declare function readStreak(): StreakState;
30
+ /** Write streak state to disk. */
31
+ export declare function writeStreak(state: StreakState): void;
32
+ /**
33
+ * Record a successful play and return the current intensity level (1-5).
34
+ * Resets error run. Resets streak if idle too long.
35
+ */
36
+ export declare function recordSuccess(verb: string): {
37
+ intensity: number;
38
+ state: StreakState;
39
+ };
40
+ /**
41
+ * Record an error play and return the current escalation level (1-5).
42
+ * Resets streak.
43
+ */
44
+ export declare function recordError(): {
45
+ escalation: number;
46
+ state: StreakState;
47
+ };
48
+ /** Convert streak count to intensity level (1-5). */
49
+ export declare function streakToIntensity(streak: number): number;
50
+ /** Convert error run count to escalation level (1-5). */
51
+ export declare function errorRunToEscalation(errorRun: number): number;
52
+ /**
53
+ * Get session outcome for fanfare generation.
54
+ * Returns a ratio (0.0 = all errors, 1.0 = all successes) and total count.
55
+ */
56
+ export declare function getSessionOutcome(): {
57
+ successRatio: number;
58
+ totalPlays: number;
59
+ successes: number;
60
+ errors: number;
61
+ };
62
+ /** Reset streak state (for session start or testing). */
63
+ export declare function resetStreak(): void;
package/dist/streak.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Streak tracker — momentum and error escalation state.
3
+ *
4
+ * Tracks two independent counters persisted in a temp file:
5
+ * - streak: consecutive successful plays → drives intensity (harmonic layering)
6
+ * - errorRun: consecutive error-status plays → drives escalation (urgency)
7
+ *
8
+ * State resets:
9
+ * - streak resets on error, on idle (30s gap), or on session end
10
+ * - errorRun resets on success or on session end
11
+ */
12
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ const STREAK_FILE = join(tmpdir(), "claude-sfx-streak.json");
16
+ /** Idle timeout — streak resets if no play within this window. */
17
+ const IDLE_TIMEOUT_MS = 30_000; // 30 seconds
18
+ /** Maximum intensity level (caps harmonic layering). */
19
+ export const MAX_INTENSITY = 5;
20
+ /** Maximum error escalation level (caps urgency increase). */
21
+ export const MAX_ESCALATION = 5;
22
+ /** Intensity thresholds — streak count required for each intensity level. */
23
+ const INTENSITY_THRESHOLDS = [1, 3, 6, 10, 15];
24
+ function defaultState() {
25
+ return {
26
+ streak: 0,
27
+ errorRun: 0,
28
+ lastPlayAt: 0,
29
+ sessionSuccesses: 0,
30
+ sessionErrors: 0,
31
+ };
32
+ }
33
+ /** Read the current streak state from disk. */
34
+ export function readStreak() {
35
+ if (!existsSync(STREAK_FILE))
36
+ return defaultState();
37
+ try {
38
+ const raw = readFileSync(STREAK_FILE, "utf-8");
39
+ const parsed = JSON.parse(raw);
40
+ return { ...defaultState(), ...parsed };
41
+ }
42
+ catch {
43
+ return defaultState();
44
+ }
45
+ }
46
+ /** Write streak state to disk. */
47
+ export function writeStreak(state) {
48
+ writeFileSync(STREAK_FILE, JSON.stringify(state));
49
+ }
50
+ /**
51
+ * Record a successful play and return the current intensity level (1-5).
52
+ * Resets error run. Resets streak if idle too long.
53
+ */
54
+ export function recordSuccess(verb) {
55
+ const state = readStreak();
56
+ const now = Date.now();
57
+ // Reset streak if idle too long
58
+ if (state.lastPlayAt > 0 && (now - state.lastPlayAt) > IDLE_TIMEOUT_MS) {
59
+ state.streak = 0;
60
+ }
61
+ state.streak++;
62
+ state.errorRun = 0;
63
+ state.lastPlayAt = now;
64
+ state.sessionSuccesses++;
65
+ writeStreak(state);
66
+ return { intensity: streakToIntensity(state.streak), state };
67
+ }
68
+ /**
69
+ * Record an error play and return the current escalation level (1-5).
70
+ * Resets streak.
71
+ */
72
+ export function recordError() {
73
+ const state = readStreak();
74
+ const now = Date.now();
75
+ state.streak = 0;
76
+ state.errorRun++;
77
+ state.lastPlayAt = now;
78
+ state.sessionErrors++;
79
+ writeStreak(state);
80
+ return { escalation: errorRunToEscalation(state.errorRun), state };
81
+ }
82
+ /** Convert streak count to intensity level (1-5). */
83
+ export function streakToIntensity(streak) {
84
+ let level = 1;
85
+ for (const threshold of INTENSITY_THRESHOLDS) {
86
+ if (streak >= threshold)
87
+ level++;
88
+ }
89
+ return Math.min(level, MAX_INTENSITY);
90
+ }
91
+ /** Convert error run count to escalation level (1-5). */
92
+ export function errorRunToEscalation(errorRun) {
93
+ // Escalation is more aggressive: 1, 2, 3, 4, 5+
94
+ return Math.min(Math.max(errorRun, 1), MAX_ESCALATION);
95
+ }
96
+ /**
97
+ * Get session outcome for fanfare generation.
98
+ * Returns a ratio (0.0 = all errors, 1.0 = all successes) and total count.
99
+ */
100
+ export function getSessionOutcome() {
101
+ const state = readStreak();
102
+ const total = state.sessionSuccesses + state.sessionErrors;
103
+ return {
104
+ successRatio: total > 0 ? state.sessionSuccesses / total : 1,
105
+ totalPlays: total,
106
+ successes: state.sessionSuccesses,
107
+ errors: state.sessionErrors,
108
+ };
109
+ }
110
+ /** Reset streak state (for session start or testing). */
111
+ export function resetStreak() {
112
+ try {
113
+ unlinkSync(STREAK_FILE);
114
+ }
115
+ catch { /* ignore */ }
116
+ }
package/dist/verbs.d.ts CHANGED
@@ -10,6 +10,10 @@ export interface PlayOptions {
10
10
  status?: Status;
11
11
  scope?: Scope;
12
12
  direction?: Direction;
13
+ /** Streak intensity level (1-5). Higher = richer harmonics, momentum feel. */
14
+ intensity?: number;
15
+ /** Error escalation level (1-5). Higher = more urgent, dissonant error tone. */
16
+ escalation?: number;
13
17
  }
14
18
  export type Verb = "intake" | "transform" | "commit" | "navigate" | "execute" | "move" | "sync";
15
19
  export declare const VERB_LABELS: Record<Verb, string>;
@@ -19,6 +23,21 @@ export declare const ALL_VERBS: Verb[];
19
23
  export declare function generateVerb(profile: Profile, verb: Verb, options?: PlayOptions): Float64Array;
20
24
  export declare function generateSessionStart(profile: Profile): Float64Array;
21
25
  export declare function generateSessionEnd(profile: Profile): Float64Array;
26
+ export interface SessionOutcome {
27
+ /** 0.0 = all errors, 1.0 = all successes */
28
+ successRatio: number;
29
+ /** Total plays in the session */
30
+ totalPlays: number;
31
+ }
32
+ /**
33
+ * Generate an outcome-aware session end sound.
34
+ *
35
+ * Great session (ratio >= 0.8, 5+ plays): triumphant ascending chord
36
+ * Good session (ratio >= 0.6): standard descending chime (existing behavior)
37
+ * Rough session (ratio < 0.6): muted, lower, shorter resolution
38
+ * Empty session (< 5 plays): standard chime (not enough data)
39
+ */
40
+ export declare function generateSessionEndWithOutcome(profile: Profile, outcome: SessionOutcome): Float64Array;
22
41
  /** Generate a single chunk of the ambient thinking drone. */
23
42
  export declare function generateAmbientChunk(profile: Profile): Float64Array;
24
43
  /** Generate the resolution stinger (two ascending notes). */
package/dist/verbs.js CHANGED
@@ -89,6 +89,119 @@ function applyWhooshScope(params, scope) {
89
89
  }
90
90
  return params;
91
91
  }
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
+ function applyWhooshIntensity(params, intensity) {
126
+ if (intensity <= 1)
127
+ return params;
128
+ const p = { ...params };
129
+ if (intensity >= 2) {
130
+ // Wider sweep range
131
+ p.freqEnd = Math.min(p.freqEnd * (1 + (intensity - 1) * 0.08), 8000);
132
+ }
133
+ if (intensity >= 3) {
134
+ // Tighter bandwidth = more defined whoosh
135
+ p.bandwidth = Math.max(p.bandwidth * 0.85, 0.3);
136
+ }
137
+ 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 };
179
+ }
180
+ return p;
181
+ }
182
+ function applyWhooshEscalation(params, escalation) {
183
+ if (escalation <= 1)
184
+ return params;
185
+ const p = { ...params };
186
+ if (escalation >= 2) {
187
+ p.freqStart *= 0.7;
188
+ p.freqEnd *= 0.7;
189
+ }
190
+ if (escalation >= 3) {
191
+ p.bandwidth = Math.min(p.bandwidth * 1.5, 2);
192
+ p.duration *= 1.2;
193
+ }
194
+ if (escalation >= 4) {
195
+ p.duration *= 1.2;
196
+ p.envelope = { ...p.envelope, release: p.envelope.release * 1.5 };
197
+ }
198
+ if (escalation >= 5) {
199
+ p.freqStart *= 0.8;
200
+ p.freqEnd *= 0.8;
201
+ p.duration *= 1.15;
202
+ }
203
+ return p;
204
+ }
92
205
  // --- Config → Params conversion ---
93
206
  function toneConfigToParams(cfg) {
94
207
  return {
@@ -129,6 +242,10 @@ export function generateVerb(profile, verb, options = {}) {
129
242
  wp = applyWhooshStatus(wp, options.status);
130
243
  if (options.scope)
131
244
  wp = applyWhooshScope(wp, options.scope);
245
+ if (options.intensity)
246
+ wp = applyWhooshIntensity(wp, options.intensity);
247
+ if (options.escalation)
248
+ wp = applyWhooshEscalation(wp, options.escalation);
132
249
  const whoosh = generateWhoosh(wp);
133
250
  // Tonal anchor (sync)
134
251
  if (cfg.tonalAnchor) {
@@ -151,6 +268,10 @@ export function generateVerb(profile, verb, options = {}) {
151
268
  params = applyToneStatus(params, options.status);
152
269
  if (options.scope)
153
270
  params = applyToneScope(params, options.scope);
271
+ if (options.intensity)
272
+ params = applyToneIntensity(params, options.intensity);
273
+ if (options.escalation)
274
+ params = applyToneEscalation(params, options.escalation);
154
275
  const primary = generateTone(params);
155
276
  // Noise burst layer (execute)
156
277
  if (cfg.noiseBurst) {
@@ -199,6 +320,94 @@ export function generateSessionStart(profile) {
199
320
  export function generateSessionEnd(profile) {
200
321
  return generateChime(profile.sessionEnd);
201
322
  }
323
+ /**
324
+ * 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)
330
+ */
331
+ export function generateSessionEndWithOutcome(profile, outcome) {
332
+ // Not enough data — use standard chime
333
+ if (outcome.totalPlays < 5) {
334
+ return generateChime(profile.sessionEnd);
335
+ }
336
+ if (outcome.successRatio >= 0.8) {
337
+ // Triumphant: 3-note ascending chord (C5 → E5 → G5) with harmonics
338
+ return generateFanfare(profile);
339
+ }
340
+ if (outcome.successRatio < 0.6) {
341
+ // Rough session: lower, shorter, muted resolution
342
+ return generateMutedEnd(profile);
343
+ }
344
+ // Normal session: standard chime
345
+ return generateChime(profile.sessionEnd);
346
+ }
347
+ /** Triumphant 3-note ascending fanfare. */
348
+ function generateFanfare(profile) {
349
+ const waveform = profile.sessionEnd.tone1.waveform;
350
+ // C5 → E5 → G5 (major triad ascending)
351
+ const notes = [523, 659, 784];
352
+ const buffers = [];
353
+ for (let i = 0; i < notes.length; i++) {
354
+ const isLast = i === notes.length - 1;
355
+ buffers.push(generateTone({
356
+ waveform,
357
+ frequency: notes[i],
358
+ duration: isLast ? 0.35 : 0.18,
359
+ 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,
364
+ },
365
+ gain: isLast ? 0.55 : 0.45,
366
+ harmonicGain: isLast ? 0.15 : 0.08,
367
+ }));
368
+ }
369
+ // Stagger: each note starts 70ms after the previous
370
+ const stagger = Math.floor(0.07 * SAMPLE_RATE);
371
+ const totalLen = stagger * (buffers.length - 1) + buffers[buffers.length - 1].length;
372
+ const total = new Float64Array(totalLen);
373
+ for (let ni = 0; ni < buffers.length; ni++) {
374
+ const buf = buffers[ni];
375
+ const offset = ni * stagger;
376
+ for (let i = 0; i < buf.length; i++) {
377
+ if (offset + i < total.length) {
378
+ total[offset + i] += buf[i];
379
+ }
380
+ }
381
+ }
382
+ return limitLoudness(total);
383
+ }
384
+ /** Muted session end — lower pitch, shorter, subdued. */
385
+ function generateMutedEnd(profile) {
386
+ 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);
410
+ }
202
411
  // --- Ambient (long-running thinking) ---
203
412
  /** Generate a single chunk of the ambient thinking drone. */
204
413
  export function generateAmbientChunk(profile) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/claude-sfx",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Procedural audio feedback for Claude Code — UX for agentic coding",
5
5
  "type": "module",
6
6
  "bin": {