@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 +20 -0
- package/README.md +39 -5
- package/dist/ambient.js +14 -7
- package/dist/cli.js +56 -2
- package/dist/hook-handler.js +24 -3
- package/dist/hooks.js +6 -6
- package/dist/player.js +1 -1
- package/dist/streak.d.ts +63 -0
- package/dist/streak.js +116 -0
- package/dist/verbs.d.ts +19 -0
- package/dist/verbs.js +209 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 +
|
|
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
|
package/dist/hook-handler.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
130
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
100
|
+
timeout: 3,
|
|
101
101
|
},
|
|
102
102
|
],
|
|
103
103
|
},
|
package/dist/player.js
CHANGED
package/dist/streak.d.ts
ADDED
|
@@ -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) {
|