vibeaudio 0.4.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/LICENSE +21 -0
- package/README.md +601 -0
- package/bin/vibeaudio.js +16 -0
- package/package.json +51 -0
- package/src/cli.js +993 -0
- package/src/hooks.js +980 -0
- package/src/hud.js +84 -0
- package/src/interactive.js +338 -0
- package/src/mcp.js +292 -0
- package/src/player.js +666 -0
- package/src/synth/chime.js +137 -0
- package/src/synth/chiptune.js +132 -0
- package/src/synth/drone.js +133 -0
- package/src/synth/electronic.js +133 -0
- package/src/synth/generator.js +179 -0
- package/src/synth/jazz.js +178 -0
- package/src/synth/lofi.js +148 -0
- package/src/synth/piano.js +158 -0
- package/src/synth/synthwave.js +170 -0
- package/src/synth/zen.js +148 -0
package/src/player.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Cache & Cross-Platform Playback Manager
|
|
3
|
+
* Supports Adaptive Time Escalation & Outcome-Aware Chimes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const { spawn, spawnSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const { generateLofiLoop } = require("./synth/lofi");
|
|
12
|
+
const { generateSynthwaveLoop } = require("./synth/synthwave");
|
|
13
|
+
const { generateChiptuneLoop } = require("./synth/chiptune");
|
|
14
|
+
const { generateElectronicLoop } = require("./synth/electronic");
|
|
15
|
+
const { generateZenLoop } = require("./synth/zen");
|
|
16
|
+
const { generateDroneLoop } = require("./synth/drone");
|
|
17
|
+
const { generatePianoLoop } = require("./synth/piano");
|
|
18
|
+
const { generateJazzLoop } = require("./synth/jazz");
|
|
19
|
+
const { generateSuccessChime, generateFailureChime, generateAttentionChime } = require("./synth/chime");
|
|
20
|
+
const { hashString } = require("./synth/generator");
|
|
21
|
+
const pkg = require("../package.json");
|
|
22
|
+
|
|
23
|
+
const CACHE_ROOT = path.join(os.homedir(), ".vibeaudio", "cache");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The cache key has to change whenever the audio would, or existing users keep
|
|
27
|
+
* hearing the previous render forever with nothing to tell them so.
|
|
28
|
+
*
|
|
29
|
+
* Keying it on the package version alone made that a thing someone had to
|
|
30
|
+
* remember, and the record for that was 0 for 40: eight of the ten generators
|
|
31
|
+
* changed across forty commits while the version sat still, so the fix that
|
|
32
|
+
* makes synth changes reach users had never once fired. Hashing the sources
|
|
33
|
+
* instead makes it an invariant - you cannot change a generator without
|
|
34
|
+
* invalidating the cache, because the generator *is* the key.
|
|
35
|
+
*
|
|
36
|
+
* The version stays in the directory name for legibility; the hash is what
|
|
37
|
+
* actually decides. Costs one read of ~50 kB at startup.
|
|
38
|
+
*/
|
|
39
|
+
function synthFingerprint(dir = path.join(__dirname, "synth")) {
|
|
40
|
+
try {
|
|
41
|
+
const parts = fs
|
|
42
|
+
.readdirSync(dir)
|
|
43
|
+
.filter((name) => name.endsWith(".js"))
|
|
44
|
+
.sort() // readdir order is not guaranteed; the key must be stable.
|
|
45
|
+
.map((name) => `${name}\u0000${fs.readFileSync(path.join(dir, name), "utf8")}`);
|
|
46
|
+
return hashString(parts.join("\u0000")).toString(16).padStart(8, "0");
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Unreadable sources are someone else's problem to report; falling back to
|
|
49
|
+
// the version keeps the old behaviour rather than failing to play at all.
|
|
50
|
+
return "nofp";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CACHE_DIR = path.join(CACHE_ROOT, `v${pkg.version}-${synthFingerprint()}`);
|
|
55
|
+
const AVAILABLE_GENRES = ["lofi", "synthwave", "8bit", "electronic", "jazz", "zen", "piano", "drone"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What `random` may land on. Drone is deliberately excluded: it is the "no
|
|
59
|
+
* melody at all" option people choose on purpose, not a mood in the same
|
|
60
|
+
* series as the others. Rolling it by chance reads as broken audio rather
|
|
61
|
+
* than as variety, so it stays opt-in by name (or via the noise/focus alias).
|
|
62
|
+
*/
|
|
63
|
+
const SHUFFLE_GENRES = AVAILABLE_GENRES.filter((g) => g !== "drone");
|
|
64
|
+
|
|
65
|
+
// Next loop starts slightly before the current one ends, so the per-loop
|
|
66
|
+
// boundary fades crossfade instead of leaving a process-spawn gap.
|
|
67
|
+
const LOOP_OVERLAP_MS = 120;
|
|
68
|
+
|
|
69
|
+
const TIER_2_AFTER_MS = 15000;
|
|
70
|
+
const TIER_3_AFTER_MS = 45000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Playback backends, in preference order. `volume` marks whether the backend
|
|
74
|
+
* can attenuate; the others play at system volume.
|
|
75
|
+
*/
|
|
76
|
+
const PLAYER_CANDIDATES = [
|
|
77
|
+
{ cmd: "afplay", volume: true, args: (file, vol) => ["-v", String(vol), file] },
|
|
78
|
+
{ cmd: "paplay", volume: true, args: (file, vol) => [`--volume=${Math.round(vol * 65536)}`, file] },
|
|
79
|
+
{
|
|
80
|
+
cmd: "ffplay",
|
|
81
|
+
volume: true,
|
|
82
|
+
args: (file, vol) => ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(Math.round(vol * 100)), file]
|
|
83
|
+
},
|
|
84
|
+
{ cmd: "aplay", volume: false, args: (file) => ["-q", file] }
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
let cachedPlayer;
|
|
88
|
+
let warnedNoPlayer = false;
|
|
89
|
+
|
|
90
|
+
function detectPlayer() {
|
|
91
|
+
if (cachedPlayer !== undefined) return cachedPlayer;
|
|
92
|
+
|
|
93
|
+
if (process.platform === "win32") {
|
|
94
|
+
cachedPlayer = {
|
|
95
|
+
cmd: "powershell",
|
|
96
|
+
volume: false,
|
|
97
|
+
args: (file) => [
|
|
98
|
+
"-NoProfile",
|
|
99
|
+
"-WindowStyle",
|
|
100
|
+
"Hidden",
|
|
101
|
+
"-Command",
|
|
102
|
+
`(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`
|
|
103
|
+
]
|
|
104
|
+
};
|
|
105
|
+
return cachedPlayer;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cachedPlayer = PLAYER_CANDIDATES.find(
|
|
109
|
+
(c) => spawnSync("which", [c.cmd], { stdio: "ignore" }).status === 0
|
|
110
|
+
) || null;
|
|
111
|
+
|
|
112
|
+
return cachedPlayer;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function warnNoPlayer() {
|
|
116
|
+
if (warnedNoPlayer) return;
|
|
117
|
+
warnedNoPlayer = true;
|
|
118
|
+
process.stderr.write(
|
|
119
|
+
"\x1b[33m[vibeaudio] No supported audio player found — running silently.\x1b[0m\n" +
|
|
120
|
+
" macOS: afplay ships with the system.\n" +
|
|
121
|
+
" Linux: install one of pulseaudio-utils (paplay), ffmpeg (ffplay), or alsa-utils (aplay).\n"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let sweptLegacyLoops = false;
|
|
126
|
+
|
|
127
|
+
function ensureCacheDir() {
|
|
128
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
129
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
130
|
+
pruneStaleCache();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Loops moved into per-seed subdirectories. Anything left at the top level
|
|
135
|
+
// was written by an older layout of this same version and is now orphaned -
|
|
136
|
+
// chimes legitimately live here, so only loop_*.wav is swept.
|
|
137
|
+
if (sweptLegacyLoops) return;
|
|
138
|
+
sweptLegacyLoops = true;
|
|
139
|
+
try {
|
|
140
|
+
for (const name of fs.readdirSync(CACHE_DIR)) {
|
|
141
|
+
if (/^loop_.*\.wav$/.test(name)) fs.rmSync(path.join(CACHE_DIR, name), { force: true });
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// Best-effort housekeeping.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Caches are keyed by version plus a hash of the generators, so a synth change
|
|
150
|
+
* reaches existing users. Anything from another key is dead weight and
|
|
151
|
+
* regenerates on demand.
|
|
152
|
+
*/
|
|
153
|
+
function pruneStaleCache() {
|
|
154
|
+
try {
|
|
155
|
+
for (const entry of fs.readdirSync(CACHE_ROOT, { withFileTypes: true })) {
|
|
156
|
+
const full = path.join(CACHE_ROOT, entry.name);
|
|
157
|
+
if (entry.isDirectory() && /^v\d/.test(entry.name) && entry.name !== path.basename(CACHE_DIR)) {
|
|
158
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
159
|
+
} else if (entry.isFile() && entry.name.endsWith(".wav")) {
|
|
160
|
+
fs.rmSync(full, { force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
// Pruning is best-effort; a stale cache is not worth failing a run over.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function clearCache() {
|
|
169
|
+
fs.rmSync(CACHE_ROOT, { recursive: true, force: true });
|
|
170
|
+
return CACHE_ROOT;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const GENRE_ALIASES = {
|
|
174
|
+
chiptune: "8bit",
|
|
175
|
+
downtempo: "electronic",
|
|
176
|
+
bossa: "jazz",
|
|
177
|
+
ambient: "zen",
|
|
178
|
+
noise: "drone",
|
|
179
|
+
focus: "drone",
|
|
180
|
+
sparse: "piano",
|
|
181
|
+
satie: "piano"
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
function isKnownGenre(genre) {
|
|
185
|
+
const normalized = (genre || "").toLowerCase();
|
|
186
|
+
return (
|
|
187
|
+
normalized === "random" ||
|
|
188
|
+
normalized === "shuffle" ||
|
|
189
|
+
AVAILABLE_GENRES.includes(normalized) ||
|
|
190
|
+
normalized in GENRE_ALIASES
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Volume reaches us as a string from env vars and CLI flags, and as a number
|
|
196
|
+
* from MCP tool arguments. One parser for all three: without it each caller
|
|
197
|
+
* writes its own clamp, and the one that forgets the NaN guard hands the
|
|
198
|
+
* player `NaN` for VIBE_VOLUME=loud.
|
|
199
|
+
*/
|
|
200
|
+
function normalizeVolume(raw, fallback = 0.4) {
|
|
201
|
+
if (raw === undefined || raw === null || raw === "") return fallback;
|
|
202
|
+
const parsed = parseInt(raw, 10);
|
|
203
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
204
|
+
return Math.max(5, Math.min(100, parsed)) / 100.0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveGenre(genre) {
|
|
208
|
+
const normalized = (genre || "lofi").toLowerCase();
|
|
209
|
+
if (normalized === "random" || normalized === "shuffle") {
|
|
210
|
+
return SHUFFLE_GENRES[Math.floor(Math.random() * SHUFFLE_GENRES.length)];
|
|
211
|
+
}
|
|
212
|
+
return GENRE_ALIASES[normalized] || normalized;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function generateLoop(genre, tier, seed) {
|
|
216
|
+
switch (genre) {
|
|
217
|
+
case "synthwave":
|
|
218
|
+
return generateSynthwaveLoop(6.8, tier, seed);
|
|
219
|
+
case "8bit":
|
|
220
|
+
return generateChiptuneLoop(7.5, tier, seed);
|
|
221
|
+
case "electronic":
|
|
222
|
+
return generateElectronicLoop(6.4, tier, seed);
|
|
223
|
+
case "jazz":
|
|
224
|
+
return generateJazzLoop(6.26, tier, seed);
|
|
225
|
+
case "zen":
|
|
226
|
+
return generateZenLoop(7.2, tier, seed);
|
|
227
|
+
case "drone":
|
|
228
|
+
return generateDroneLoop(7.0, tier, seed);
|
|
229
|
+
case "piano":
|
|
230
|
+
return generatePianoLoop(7.6, tier, seed);
|
|
231
|
+
case "lofi":
|
|
232
|
+
default:
|
|
233
|
+
return generateLofiLoop(6.4, tier, seed);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Seed from the project directory, so a repo always sounds like itself.
|
|
239
|
+
* Focus music should fade into the background, which familiarity helps and
|
|
240
|
+
* per-run novelty actively hurts - so this deliberately does not vary per run.
|
|
241
|
+
*/
|
|
242
|
+
function projectSeed(cwd = process.cwd()) {
|
|
243
|
+
if (process.env.VIBE_SEED) {
|
|
244
|
+
const explicit = parseInt(process.env.VIBE_SEED, 10);
|
|
245
|
+
if (!isNaN(explicit)) return explicit >>> 0;
|
|
246
|
+
}
|
|
247
|
+
return hashString(cwd);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function seedDir(seed) {
|
|
251
|
+
return path.join(CACHE_DIR, `s${seed >>> 0}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Keep a small working set of projects cached; regenerating costs ~100ms. */
|
|
255
|
+
function pruneSeedDirs(keep = 3) {
|
|
256
|
+
try {
|
|
257
|
+
const dirs = fs
|
|
258
|
+
.readdirSync(CACHE_DIR, { withFileTypes: true })
|
|
259
|
+
.filter((e) => e.isDirectory() && /^s\d+$/.test(e.name))
|
|
260
|
+
.map((e) => {
|
|
261
|
+
const full = path.join(CACHE_DIR, e.name);
|
|
262
|
+
return { full, mtime: fs.statSync(full).mtimeMs };
|
|
263
|
+
})
|
|
264
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
265
|
+
|
|
266
|
+
for (const stale of dirs.slice(keep)) {
|
|
267
|
+
fs.rmSync(stale.full, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
// Best-effort housekeeping.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `aplay` and PowerShell's SoundPlayer expose no volume parameter, so on those
|
|
276
|
+
* backends --volume used to be silently ignored. Scaling the PCM ourselves
|
|
277
|
+
* gives every platform working volume control; the file is cached under its
|
|
278
|
+
* own gain so a 15% render is never served to someone asking for 40%.
|
|
279
|
+
*
|
|
280
|
+
* Returns 1 when the backend can attenuate on its own - that path keeps the
|
|
281
|
+
* existing cache files and stays bit-identical.
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* A global mute. Hooks and config stay exactly as they are - this is for a
|
|
285
|
+
* screen share or a meeting, where uninstalling and reinstalling hooks is too
|
|
286
|
+
* much ceremony for ten minutes of quiet.
|
|
287
|
+
*
|
|
288
|
+
* Read at playback time rather than cached, so exporting it takes effect on
|
|
289
|
+
* the next prompt. It deliberately does not gag `--preview`: that path spawns
|
|
290
|
+
* the player directly and is an explicit request to hear something.
|
|
291
|
+
*/
|
|
292
|
+
/**
|
|
293
|
+
* The mute switch has to be a file, not just an env var.
|
|
294
|
+
*
|
|
295
|
+
* A hook runs as a child of the agent and inherits the environment the agent
|
|
296
|
+
* had when it launched, so `export VIBE_DISABLE=1` in some other terminal
|
|
297
|
+
* reaches nothing already running - which is exactly the moment you need it,
|
|
298
|
+
* when a call is ringing and restarting the agent is not an option. A file
|
|
299
|
+
* crosses that boundary; an environment variable cannot.
|
|
300
|
+
*/
|
|
301
|
+
const MUTE_FILE = path.join(os.homedir(), ".vibeaudio", "muted");
|
|
302
|
+
|
|
303
|
+
// A call is a bounded thing, so a mute should be too. An indefinite one you
|
|
304
|
+
// forget about is worse than no mute at all: the tool just stops working and
|
|
305
|
+
// nothing ever tells you why. Expiring by default means the failure mode is
|
|
306
|
+
// "music came back sooner than I wanted", not "silently broken for a week".
|
|
307
|
+
const DEFAULT_MUTE_MINUTES = 60;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Reads the mute flag, expiring it in passing. Returns null when not muted.
|
|
311
|
+
* `until` is null for a mute the user explicitly asked to last indefinitely.
|
|
312
|
+
*/
|
|
313
|
+
function muteState() {
|
|
314
|
+
let raw;
|
|
315
|
+
try {
|
|
316
|
+
raw = fs.readFileSync(MUTE_FILE, "utf8").trim();
|
|
317
|
+
} catch (e) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let state;
|
|
322
|
+
try {
|
|
323
|
+
state = JSON.parse(raw);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
// A file written before mutes could expire held a bare timestamp.
|
|
326
|
+
state = { since: raw, until: null };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (state.until && Date.now() >= state.until) {
|
|
330
|
+
// Self-healing: whoever notices first clears it, so an expired mute can
|
|
331
|
+
// never sit there looking like a live one.
|
|
332
|
+
fs.rmSync(MUTE_FILE, { force: true });
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return state;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function playbackDisabled() {
|
|
339
|
+
const raw = (process.env.VIBE_DISABLE || "").trim().toLowerCase();
|
|
340
|
+
if (raw === "1" || raw === "true" || raw === "yes" || raw === "on") return true;
|
|
341
|
+
// Checked per playback rather than cached, so unmuting - or an expiry -
|
|
342
|
+
// lands on the next loop or prompt without restarting anything.
|
|
343
|
+
return muteState() !== null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `minutes` of 0 means indefinite, which the user has to ask for explicitly.
|
|
348
|
+
*/
|
|
349
|
+
function setMuted(muted, minutes = DEFAULT_MUTE_MINUTES) {
|
|
350
|
+
if (!muted) {
|
|
351
|
+
fs.rmSync(MUTE_FILE, { force: true });
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const state = {
|
|
356
|
+
since: new Date().toISOString(),
|
|
357
|
+
until: minutes > 0 ? Date.now() + minutes * 60000 : null
|
|
358
|
+
};
|
|
359
|
+
fs.mkdirSync(path.dirname(MUTE_FILE), { recursive: true });
|
|
360
|
+
fs.writeFileSync(MUTE_FILE, `${JSON.stringify(state, null, 2)}\n`);
|
|
361
|
+
return state;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function muteRemainingText(state) {
|
|
365
|
+
if (!state) return null;
|
|
366
|
+
if (!state.until) return "indefinitely — until you run: vibe --unmute";
|
|
367
|
+
const mins = Math.max(1, Math.round((state.until - Date.now()) / 60000));
|
|
368
|
+
return `for ${mins} more minute${mins === 1 ? "" : "s"}, then music returns on its own`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function bakedGain(backend, volume) {
|
|
372
|
+
if (!backend || backend.volume) return 1;
|
|
373
|
+
return Math.max(0.05, Math.min(1, volume));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function gainSuffix(gain) {
|
|
377
|
+
return gain === 1 ? "" : `_g${Math.round(gain * 100)}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Scales 16-bit PCM in place, skipping the 44-byte canonical header our
|
|
382
|
+
* generators write.
|
|
383
|
+
*
|
|
384
|
+
* ponytail: re-quantises rather than scaling the Float samples before they
|
|
385
|
+
* reach 16-bit. Measured SNR at the lowest preset (15%) is 65dB, with error
|
|
386
|
+
* capped at half an LSB - inaudible under background music. Thread the gain
|
|
387
|
+
* into createWavBuffer if that ever stops being true.
|
|
388
|
+
*/
|
|
389
|
+
function applyGain(wav, gain) {
|
|
390
|
+
if (gain === 1) return wav;
|
|
391
|
+
if (wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF") {
|
|
392
|
+
throw new Error("applyGain expects a canonical 44-byte-header WAV");
|
|
393
|
+
}
|
|
394
|
+
for (let offset = 44; offset + 1 < wav.length; offset += 2) {
|
|
395
|
+
const scaled = Math.round(wav.readInt16LE(offset) * gain);
|
|
396
|
+
wav.writeInt16LE(Math.max(-32768, Math.min(32767, scaled)), offset);
|
|
397
|
+
}
|
|
398
|
+
return wav;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A cache file must never be visible half-written. `existsSync` goes true the
|
|
403
|
+
* instant the file is created, so a plain writeFileSync of a ~1 MB loop leaves
|
|
404
|
+
* a window in which another process - a second agent's daemon, a wrapper run,
|
|
405
|
+
* the MCP server - sees the path, spawns the backend at it and plays a
|
|
406
|
+
* truncated WAV. Writing beside it and renaming closes that window: rename is
|
|
407
|
+
* atomic within a directory, so the path either isn't there or is complete.
|
|
408
|
+
*/
|
|
409
|
+
function writeCacheFileAtomic(filePath, buffer) {
|
|
410
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
411
|
+
try {
|
|
412
|
+
fs.writeFileSync(tmp, buffer);
|
|
413
|
+
fs.renameSync(tmp, filePath);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
fs.rmSync(tmp, { force: true });
|
|
416
|
+
|
|
417
|
+
// POSIX rename replaces the destination silently. Windows does not: it
|
|
418
|
+
// fails with EPERM/EACCES when another process holds the destination
|
|
419
|
+
// open - which is precisely the concurrent case this function exists to
|
|
420
|
+
// make safe, so throwing here would trade a torn read for a crash.
|
|
421
|
+
//
|
|
422
|
+
// Losing the race is not a failure. Generation is deterministic, so the
|
|
423
|
+
// process that won wrote byte-identical content; the destination simply
|
|
424
|
+
// existing is the success condition.
|
|
425
|
+
if (fs.existsSync(filePath)) return;
|
|
426
|
+
throw e;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1) {
|
|
431
|
+
ensureCacheDir();
|
|
432
|
+
const normalizedGenre = resolveGenre(genre);
|
|
433
|
+
const safeTier = Math.max(1, Math.min(3, tier));
|
|
434
|
+
|
|
435
|
+
const dir = seedDir(seed);
|
|
436
|
+
const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${gainSuffix(gain)}.wav`);
|
|
437
|
+
|
|
438
|
+
if (!fs.existsSync(filePath)) {
|
|
439
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
440
|
+
writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0), gain));
|
|
441
|
+
pruneSeedDirs();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return filePath;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const CHIMES = {
|
|
448
|
+
success: generateSuccessChime,
|
|
449
|
+
failure: generateFailureChime,
|
|
450
|
+
attention: generateAttentionChime
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
function getChimePath(outcome = "success", gain = 1) {
|
|
454
|
+
ensureCacheDir();
|
|
455
|
+
const kind = outcome === "error" ? "failure" : CHIMES[outcome] ? outcome : "success";
|
|
456
|
+
const filePath = path.join(CACHE_DIR, `chime_${kind}${gainSuffix(gain)}.wav`);
|
|
457
|
+
|
|
458
|
+
if (!fs.existsSync(filePath)) {
|
|
459
|
+
writeCacheFileAtomic(filePath, applyGain(CHIMES[kind](), gain));
|
|
460
|
+
}
|
|
461
|
+
return filePath;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function wavDurationMs(filePath) {
|
|
465
|
+
try {
|
|
466
|
+
const fd = fs.openSync(filePath, "r");
|
|
467
|
+
const header = Buffer.alloc(44);
|
|
468
|
+
fs.readSync(fd, header, 0, 44, 0);
|
|
469
|
+
fs.closeSync(fd);
|
|
470
|
+
|
|
471
|
+
const byteRate = header.readUInt32LE(28);
|
|
472
|
+
const dataSize = header.readUInt32LE(40);
|
|
473
|
+
return byteRate > 0 ? (dataSize / byteRate) * 1000 : null;
|
|
474
|
+
} catch (e) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Playback outlives the event loop unless killed, so an abrupt exit must not
|
|
480
|
+
// leave a detached player running.
|
|
481
|
+
const activePlayers = new Set();
|
|
482
|
+
let exitHookInstalled = false;
|
|
483
|
+
|
|
484
|
+
function installExitHook() {
|
|
485
|
+
if (exitHookInstalled) return;
|
|
486
|
+
exitHookInstalled = true;
|
|
487
|
+
process.on("exit", () => {
|
|
488
|
+
for (const player of activePlayers) player.killProcs();
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
class AudioPlayer {
|
|
493
|
+
constructor() {
|
|
494
|
+
this.isPlaying = false;
|
|
495
|
+
this.procs = new Set();
|
|
496
|
+
this.startTime = 0;
|
|
497
|
+
this.genre = "lofi";
|
|
498
|
+
this.volume = 0.42;
|
|
499
|
+
this.seed = projectSeed();
|
|
500
|
+
this.intensity = null;
|
|
501
|
+
this.currentTier = 1;
|
|
502
|
+
this.nextTimer = null;
|
|
503
|
+
this.watchdog = null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Returns true if playback actually started. Restarts when called with
|
|
508
|
+
* different settings while playing, so a genre switch is not silently dropped.
|
|
509
|
+
*/
|
|
510
|
+
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null } = {}) {
|
|
511
|
+
const resolved = resolveGenre(genre);
|
|
512
|
+
const targetVolume = Math.max(0.05, Math.min(1.0, volume));
|
|
513
|
+
|
|
514
|
+
if (this.isPlaying) {
|
|
515
|
+
if (resolved === this.genre && targetVolume === this.volume) return false;
|
|
516
|
+
this.stop({ playChime: false });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (playbackDisabled()) return false;
|
|
520
|
+
|
|
521
|
+
if (!detectPlayer()) {
|
|
522
|
+
warnNoPlayer();
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
this.isPlaying = true;
|
|
527
|
+
this.startTime = Date.now();
|
|
528
|
+
this.genre = resolved;
|
|
529
|
+
this.volume = targetVolume;
|
|
530
|
+
this.seed = projectSeed();
|
|
531
|
+
this.intensity = intensity;
|
|
532
|
+
|
|
533
|
+
installExitHook();
|
|
534
|
+
activePlayers.add(this);
|
|
535
|
+
|
|
536
|
+
if (maxDurationMs) {
|
|
537
|
+
this.watchdog = setTimeout(() => this.stop({ playChime: false }), maxDurationMs);
|
|
538
|
+
if (this.watchdog.unref) this.watchdog.unref();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
this.playLoop();
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
playLoop() {
|
|
546
|
+
if (!this.isPlaying) return;
|
|
547
|
+
|
|
548
|
+
// Muting mid-run has to reach a wrapper too, which has no daemon for
|
|
549
|
+
// `--mute` to stop. Silence starts at this loop boundary rather than
|
|
550
|
+
// cutting the current bar off.
|
|
551
|
+
if (playbackDisabled()) {
|
|
552
|
+
this.stop({ playChime: false });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Adaptive Time Escalation:
|
|
557
|
+
// 0 - 15s -> Tier 1 (Ambient intro / gentle pads)
|
|
558
|
+
// 15 - 45s -> Tier 2 (Main progression & bassline)
|
|
559
|
+
// 45s+ -> Tier 3 (Deep focus / peak energy)
|
|
560
|
+
// An intensity source (reactive mode) overrides this when it has a signal.
|
|
561
|
+
const elapsed = Date.now() - this.startTime;
|
|
562
|
+
const byTime = elapsed > TIER_3_AFTER_MS ? 3 : elapsed > TIER_2_AFTER_MS ? 2 : 1;
|
|
563
|
+
this.currentTier = (this.intensity && this.intensity()) || byTime;
|
|
564
|
+
|
|
565
|
+
// Either the backend attenuates, or the file is rendered pre-attenuated -
|
|
566
|
+
// never both, or the volume would be applied twice.
|
|
567
|
+
const backend = detectPlayer();
|
|
568
|
+
const gain = bakedGain(backend, this.volume);
|
|
569
|
+
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain);
|
|
570
|
+
const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
|
|
571
|
+
|
|
572
|
+
this.procs.add(proc);
|
|
573
|
+
proc.on("close", () => this.procs.delete(proc));
|
|
574
|
+
proc.on("error", () => {
|
|
575
|
+
this.procs.delete(proc);
|
|
576
|
+
this.isPlaying = false;
|
|
577
|
+
warnNoPlayer();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const durationMs = wavDurationMs(audioFile) || 6500;
|
|
581
|
+
this.nextTimer = setTimeout(() => this.playLoop(), Math.max(250, durationMs - LOOP_OVERLAP_MS));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
killProcs() {
|
|
585
|
+
for (const proc of this.procs) {
|
|
586
|
+
try {
|
|
587
|
+
proc.kill("SIGTERM");
|
|
588
|
+
} catch (e) {
|
|
589
|
+
// Process already closed
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
this.procs.clear();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `detach` plays the chime in a process that outlives this one, for a hook
|
|
597
|
+
* that must hand control back at once - an agent waits on its hooks, and a
|
|
598
|
+
* blocking chime held the permission dialog back for its whole length.
|
|
599
|
+
*/
|
|
600
|
+
stop({ playChime = true, outcome = "success", volume = 0.35, chimeVolume = null, detach = false } = {}) {
|
|
601
|
+
const wasPlaying = this.isPlaying;
|
|
602
|
+
this.isPlaying = false;
|
|
603
|
+
|
|
604
|
+
if (this.nextTimer) {
|
|
605
|
+
clearTimeout(this.nextTimer);
|
|
606
|
+
this.nextTimer = null;
|
|
607
|
+
}
|
|
608
|
+
if (this.watchdog) {
|
|
609
|
+
clearTimeout(this.watchdog);
|
|
610
|
+
this.watchdog = null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this.killProcs();
|
|
614
|
+
activePlayers.delete(this);
|
|
615
|
+
|
|
616
|
+
if (playChime && !playbackDisabled()) {
|
|
617
|
+
const backend = detectPlayer();
|
|
618
|
+
if (!backend) return wasPlaying;
|
|
619
|
+
|
|
620
|
+
const targetVol = chimeVolume !== null ? chimeVolume : Math.min(0.65, Math.max(0.35, volume * 1.1));
|
|
621
|
+
const clamped = Math.max(0.05, Math.min(1.0, targetVol));
|
|
622
|
+
const chimeFile = getChimePath(outcome, bakedGain(backend, clamped));
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
if (detach) {
|
|
626
|
+
spawn(backend.cmd, backend.args(chimeFile, clamped), { stdio: "ignore", detached: true })
|
|
627
|
+
.on("error", () => {})
|
|
628
|
+
.unref();
|
|
629
|
+
} else {
|
|
630
|
+
spawnSync(backend.cmd, backend.args(chimeFile, clamped), { stdio: "ignore", timeout: 2500 });
|
|
631
|
+
}
|
|
632
|
+
} catch (e) {
|
|
633
|
+
// Ignore timeout
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return wasPlaying;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
module.exports = {
|
|
642
|
+
AudioPlayer,
|
|
643
|
+
getAudioPath,
|
|
644
|
+
getChimePath,
|
|
645
|
+
clearCache,
|
|
646
|
+
detectPlayer,
|
|
647
|
+
bakedGain,
|
|
648
|
+
applyGain,
|
|
649
|
+
playbackDisabled,
|
|
650
|
+
resolveGenre,
|
|
651
|
+
isKnownGenre,
|
|
652
|
+
normalizeVolume,
|
|
653
|
+
projectSeed,
|
|
654
|
+
wavDurationMs,
|
|
655
|
+
generateLoop,
|
|
656
|
+
AVAILABLE_GENRES,
|
|
657
|
+
SHUFFLE_GENRES,
|
|
658
|
+
synthFingerprint,
|
|
659
|
+
setMuted,
|
|
660
|
+
muteState,
|
|
661
|
+
muteRemainingText,
|
|
662
|
+
DEFAULT_MUTE_MINUTES,
|
|
663
|
+
MUTE_FILE,
|
|
664
|
+
CACHE_ROOT,
|
|
665
|
+
CACHE_DIR
|
|
666
|
+
};
|