vibeaudio 0.11.0 → 0.12.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/README.md +5 -4
- package/package.json +1 -1
- package/src/cli.js +21 -6
- package/src/hooks.js +50 -8
- package/src/mac-player.jxa +6 -0
- package/src/player.js +17 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**[Install](#-install)** · **[Agent hooks](#-agent-hooks-no-wrapper-needed)** · **[Genres](#-music-genres)** · **[Flags](#-options--flags)** · **[Troubleshooting](#-troubleshooting)** · **[Uninstall](#-uninstall)**
|
|
10
10
|
|
|
11
|
-
> **🔊 [Listen to every genre →](https://kiril6.github.io/vibeaudio/)** — hear all
|
|
11
|
+
> **🔊 [Listen to every genre →](https://kiril6.github.io/vibeaudio/)** — hear all 10 genres, the tier escalation, and the three chimes, rendered from the real synth.
|
|
12
12
|
|
|
13
13
|
> **In a hurry?** `npm i -g vibeaudio`, then `vibe --install-hooks`. Your next prompt has music.
|
|
14
14
|
|
|
@@ -25,6 +25,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
|
|
|
25
25
|
* 📈 **Escalating layers.** Tier 1 (0–15s) gentle intro → Tier 2 (15–45s) main groove → Tier 3 (45s+) deep focus. You can hear how deep into the task the agent is.
|
|
26
26
|
* 🔁 **A phrase, not a loop.** Each piece is three bars that rotate through your project's own progressions, so a long turn moves through a ~21-second phrase instead of replaying one 7-second bar for half an hour.
|
|
27
27
|
* 🎛️ **Music that follows the work** ([reactive mode](#reactive-mode-opt-in), opt-in). Calm while the agent reads, fuller while it edits, busiest when it runs commands or hands work to sub-agents — you can hear *what* it's doing, not just for how long.
|
|
28
|
+
* 🌅 **It fades, it doesn't cut.** On macOS the music eases in and out instead of starting and stopping mid-note, and `vibe --volume` reaches music that is already playing within a second.
|
|
28
29
|
* 🔔 **Outcome-aware chimes.** Ascending on success, a soft descending minor chord on failure, and **silence on `Ctrl+C`** — an abort is never reported as done.
|
|
29
30
|
* ✋ **A "your turn" chime.** When Claude Code stops to ask permission (or an MCP server asks for input), the music pauses and a rising two-note chime asks for you; it picks back up once you've answered.
|
|
30
31
|
* 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code and Windsurf**; MCP for **Claude Desktop and Antigravity**; the wrapper (`vibe <command>`) for anything else.
|
|
@@ -276,7 +277,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
276
277
|
| | |
|
|
277
278
|
| :--- | :--- |
|
|
278
279
|
| **Five agents tell you when they're waiting on you** | When a permission dialog opens the music stops rather than sounding busy while the agent is stuck on you, and it resumes once the thing you answered has run. Claude Code covers the terminal, desktop app and IDEs alike, plus MCP servers asking for input. Copilot CLI's own `PermissionRequest` fires before *every* permission check — dialog or not — so VibeAudio listens for its permission-prompt notification instead. Cursor and Grok have no such event that's been verified, so they keep playing through a prompt. |
|
|
279
|
-
| **Claude Code turns that never reach `Stop` still end the music** | An API error or rate limit ends the turn with `StopFailure` instead, which plays the failure chime. Interrupting (Esc, or the stop button in the desktop app) fires no hook at all, so the background player watches the session transcript for Claude Code's interrupt entry and stops silently within half a second — whether the agent was writing or running a tool. Closing the session mid-turn stops it too — but only if that session started the music, so closing an idle terminal never silences another one. |
|
|
280
|
+
| **Claude Code turns that never reach `Stop` still end the music** | An API error or rate limit ends the turn with `StopFailure` instead, which plays the failure chime. Interrupting (Esc, or the stop button in the desktop app) fires no hook at all, so the background player watches the session transcript for Claude Code's interrupt entry and stops silently within half a second — whether the agent was writing or running a tool. A prompt that another hook blocks (a token-saving proxy, say) never reaches `Stop` either, so the same watcher ends the music on Claude Code's "prompt blocked" entry — or never starts it, when the blocker was quicker than we were. Closing the session mid-turn stops it too — but only if that session started the music, so closing an idle terminal never silences another one. |
|
|
280
281
|
| **Codex asks you to trust the hook once** | Codex keeps a per-hook trust hash in `~/.codex/config.toml` and won't run a hook it hasn't been told to trust, so the install isn't live until you approve each one the first time it fires. |
|
|
281
282
|
| **Only Cursor can play the failure chime** | Its stop event reports whether the turn completed, aborted or errored. The others send no verdict, so a turn there always ends on the success chime — VibeAudio won't invent a failure the agent never claimed. |
|
|
282
283
|
| **Grok and Copilot CLI get a file of their own** | Each reads every `*.json` in its `hooks/` directory, so VibeAudio writes `vibeaudio.json` rather than merging into anyone else's — which makes uninstalling it a delete, and leaves no backup file behind. Copilot's honours `COPILOT_HOME`. |
|
|
@@ -350,7 +351,7 @@ vibe --reactive --install-hooks # on
|
|
|
350
351
|
vibe --install-hooks # off again
|
|
351
352
|
```
|
|
352
353
|
|
|
353
|
-
Music already playing keeps the old genre until the next prompt swaps the daemon — `vibe --stop` cuts it short.
|
|
354
|
+
Music already playing keeps the old genre until the next prompt swaps the daemon — `vibe --stop` cuts it short. A new **volume** is the exception: the music playing now eases to it within a second.
|
|
354
355
|
|
|
355
356
|
Removing them is one command:
|
|
356
357
|
|
|
@@ -681,7 +682,7 @@ This needs a PulseAudio-compatible sound server on **your local machine**: a Lin
|
|
|
681
682
|
If the second connection fails with the socket "already in use", an earlier session left it behind: `rm /tmp/vibe-pulse.sock` on the server, or set `StreamLocalBindUnlink yes` in the server's `sshd_config`. If `paplay` says access denied, your local PulseAudio requires its cookie — copy `~/.config/pulse/cookie` to the same path on the server.
|
|
682
683
|
|
|
683
684
|
**Volume flag does nothing**
|
|
684
|
-
Shouldn't happen any more — where the player can't attenuate (`aplay`, PowerShell), the gain is baked into the audio instead. A `--volume` change
|
|
685
|
+
Shouldn't happen any more — where the player can't attenuate (`aplay`, PowerShell), the gain is baked into the audio instead. A `--volume` change reaches music already playing within a second on macOS hooks (it eases there), at the next loop boundary elsewhere, and in the wrapper or MCP on their next run; `vibe --status` shows the volume in effect and what set it.
|
|
685
686
|
|
|
686
687
|
---
|
|
687
688
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think — a different arrangement per project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibeaudio": "bin/vibeaudio.js",
|
package/src/cli.js
CHANGED
|
@@ -183,6 +183,7 @@ function parseArgs(argv) {
|
|
|
183
183
|
let hookAction = null;
|
|
184
184
|
let mcp = false;
|
|
185
185
|
let reactive = false;
|
|
186
|
+
let followVolume = false;
|
|
186
187
|
let here_flag = false;
|
|
187
188
|
let dryRun = false;
|
|
188
189
|
let tools = null;
|
|
@@ -352,6 +353,14 @@ function parseArgs(argv) {
|
|
|
352
353
|
continue;
|
|
353
354
|
}
|
|
354
355
|
|
|
356
|
+
// Internal, set by the hooks: a daemon with no volume of its own follows
|
|
357
|
+
// the saved one while it plays.
|
|
358
|
+
if (arg === "--follow-volume") {
|
|
359
|
+
followVolume = true;
|
|
360
|
+
i += 1;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
355
364
|
if (arg === "--dry-run") {
|
|
356
365
|
dryRun = true;
|
|
357
366
|
i += 1;
|
|
@@ -461,6 +470,7 @@ function parseArgs(argv) {
|
|
|
461
470
|
hookAction,
|
|
462
471
|
mcp,
|
|
463
472
|
reactive,
|
|
473
|
+
followVolume,
|
|
464
474
|
dryRun,
|
|
465
475
|
tools,
|
|
466
476
|
typed,
|
|
@@ -751,8 +761,9 @@ function printStatus() {
|
|
|
751
761
|
// What it was started with, which is not necessarily what is saved: a
|
|
752
762
|
// daemon keeps its settings until the next prompt replaces it, and
|
|
753
763
|
// "I changed the genre and nothing happened" is that gap.
|
|
764
|
+
const level = playing.follows ? Math.round(volumeNow() * 100) : playing.volume; // A following daemon plays what is saved.
|
|
754
765
|
const now = playing.genre
|
|
755
|
-
? ` ${on(playing.genre)}${
|
|
766
|
+
? ` ${on(playing.genre)}${level === null ? "" : on(` @ ${level}%`)}${playing.reactive ? off(", reactive") : ""}`
|
|
756
767
|
: "";
|
|
757
768
|
console.log(` ${on("playing")}${now}${off(` pid ${pid}`)}`);
|
|
758
769
|
if (playing.genre && playing.genre !== genreNow()) {
|
|
@@ -1128,6 +1139,9 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1128
1139
|
if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
|
|
1129
1140
|
console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
|
|
1130
1141
|
}
|
|
1142
|
+
if (playing && playing.follows && patch.volume !== undefined) {
|
|
1143
|
+
console.log(` \x1b[90mThe music playing now follows the new volume within a second.\x1b[0m`);
|
|
1144
|
+
}
|
|
1131
1145
|
|
|
1132
1146
|
// An install from before the settings moved out of the hook command line
|
|
1133
1147
|
// still carries its own genre, and a flag beats this file. Saying nothing
|
|
@@ -1259,18 +1273,18 @@ function renderToFile(target, genre) {
|
|
|
1259
1273
|
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
1260
1274
|
}
|
|
1261
1275
|
|
|
1262
|
-
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
|
|
1276
|
+
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, tools, dryRun, typed = {} }) {
|
|
1263
1277
|
const hooks = require("./hooks");
|
|
1264
1278
|
|
|
1265
1279
|
switch (action) {
|
|
1266
1280
|
case "daemon":
|
|
1267
|
-
return hooks.runDaemon(genre, volume, { reactive });
|
|
1281
|
+
return hooks.runDaemon(genre, volume, { reactive, volumeSource: followVolume ? volumeNow : null });
|
|
1268
1282
|
|
|
1269
1283
|
case "hook-start":
|
|
1270
1284
|
// The payload names the session (so SessionEnd can tell this session's
|
|
1271
1285
|
// music from another's) and the transcript (so an interrupt, which
|
|
1272
1286
|
// fires no hook, can still stop it).
|
|
1273
|
-
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, turn: hooks.newTurn(raw) }));
|
|
1287
|
+
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
|
|
1274
1288
|
return;
|
|
1275
1289
|
|
|
1276
1290
|
case "hook-stop":
|
|
@@ -1297,7 +1311,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1297
1311
|
return;
|
|
1298
1312
|
|
|
1299
1313
|
case "hook-resume":
|
|
1300
|
-
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive }));
|
|
1314
|
+
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive, follow: typed.volume === undefined }));
|
|
1301
1315
|
return;
|
|
1302
1316
|
|
|
1303
1317
|
case "hook-end":
|
|
@@ -1483,6 +1497,7 @@ async function run() {
|
|
|
1483
1497
|
hookAction,
|
|
1484
1498
|
mcp,
|
|
1485
1499
|
reactive,
|
|
1500
|
+
followVolume,
|
|
1486
1501
|
dryRun,
|
|
1487
1502
|
tools,
|
|
1488
1503
|
typed,
|
|
@@ -1500,7 +1515,7 @@ async function run() {
|
|
|
1500
1515
|
|
|
1501
1516
|
if (hookAction) {
|
|
1502
1517
|
try {
|
|
1503
|
-
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed });
|
|
1518
|
+
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, tools, dryRun, typed });
|
|
1504
1519
|
} catch (e) {
|
|
1505
1520
|
// Settings problems are the user's to fix — report them, don't stack-trace.
|
|
1506
1521
|
console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
|
package/src/hooks.js
CHANGED
|
@@ -410,7 +410,8 @@ function daemonPlaying() {
|
|
|
410
410
|
pid,
|
|
411
411
|
genre: genre ? genre[1] : null,
|
|
412
412
|
volume: volume ? Number(volume[1]) : null,
|
|
413
|
-
reactive: /--reactive/.test(argv)
|
|
413
|
+
reactive: /--reactive/.test(argv),
|
|
414
|
+
follows: /--follow-volume/.test(argv)
|
|
414
415
|
};
|
|
415
416
|
}
|
|
416
417
|
|
|
@@ -450,7 +451,44 @@ function fileSize(file) {
|
|
|
450
451
|
function newTurn(raw) {
|
|
451
452
|
const payload = parsePayload(raw);
|
|
452
453
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
453
|
-
|
|
454
|
+
const offset = transcript ? fileSize(transcript) : 0;
|
|
455
|
+
return { session: String(payloadSession(payload) || ""), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const BLOCK_LOOKBACK_MS = 3000;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Whether another hook has already rejected this very prompt. Hooks run side
|
|
462
|
+
* by side and a native blocker is faster to start than we are, so its transcript
|
|
463
|
+
* entry can land *before* the offset taken above - where the daemon, which reads
|
|
464
|
+
* only what follows, would never see it. Looked at the tail instead, and only
|
|
465
|
+
* for an entry stamped within the last moments, so an earlier blocked prompt
|
|
466
|
+
* does not silence this one.
|
|
467
|
+
*/
|
|
468
|
+
function blockedJustBefore(transcript, offset) {
|
|
469
|
+
if (!transcript || !offset) return false;
|
|
470
|
+
try {
|
|
471
|
+
const len = Math.min(offset, 16384);
|
|
472
|
+
const fd = fs.openSync(transcript, "r");
|
|
473
|
+
const buf = Buffer.alloc(len);
|
|
474
|
+
try {
|
|
475
|
+
fs.readSync(fd, buf, 0, len, offset - len);
|
|
476
|
+
} finally {
|
|
477
|
+
fs.closeSync(fd);
|
|
478
|
+
}
|
|
479
|
+
const lines = buf.toString("utf8").split("\n");
|
|
480
|
+
if (len < offset) lines.shift(); // Began mid-line.
|
|
481
|
+
return lines.some((line) => {
|
|
482
|
+
if (!isInterruptEntry(line)) return false;
|
|
483
|
+
try {
|
|
484
|
+
return Date.now() - Date.parse(JSON.parse(line).timestamp) <= BLOCK_LOOKBACK_MS;
|
|
485
|
+
} catch (e) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
} catch (e) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
454
492
|
}
|
|
455
493
|
|
|
456
494
|
// The id ends up in a file name.
|
|
@@ -573,7 +611,7 @@ const INTERRUPT_POLL_MS = 500;
|
|
|
573
611
|
* Internal mode: plays while any session is working. The player's own loop
|
|
574
612
|
* timer keeps the event loop alive.
|
|
575
613
|
*/
|
|
576
|
-
function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
614
|
+
function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}) {
|
|
577
615
|
const watchers = new Map(); // session id -> its transcript poll
|
|
578
616
|
|
|
579
617
|
// Drops sessions whose transcript shows an interrupt (Esc fires no hook, so
|
|
@@ -624,6 +662,8 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
|
624
662
|
setTimeout(shutdown, MAX_DAEMON_MS);
|
|
625
663
|
|
|
626
664
|
setInterval(() => {
|
|
665
|
+
// A volume saved while this plays reaches it now, not at the next prompt.
|
|
666
|
+
if (volumeSource) player.setVolume(volumeSource());
|
|
627
667
|
if (sweep()) return;
|
|
628
668
|
player.stop({ playChime: false });
|
|
629
669
|
endIdle();
|
|
@@ -635,9 +675,10 @@ function daemonRunning() {
|
|
|
635
675
|
return pid !== null && isOurDaemon(pid);
|
|
636
676
|
}
|
|
637
677
|
|
|
638
|
-
function spawnDaemon(genre, volume, reactive) {
|
|
678
|
+
function spawnDaemon(genre, volume, reactive, follow = false) {
|
|
639
679
|
const args = [CLI_ENTRY, "--daemon", "--genre", genre, "--volume", String(Math.round(volume * 100))];
|
|
640
680
|
if (reactive) args.push("--reactive");
|
|
681
|
+
if (follow) args.push("--follow-volume");
|
|
641
682
|
|
|
642
683
|
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
|
|
643
684
|
child.unref();
|
|
@@ -652,7 +693,8 @@ function spawnDaemon(genre, volume, reactive) {
|
|
|
652
693
|
* always did, so a genre change lands on the next prompt. With one working the
|
|
653
694
|
* music carries on: restarting it would cut every other session's stream.
|
|
654
695
|
*/
|
|
655
|
-
function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
696
|
+
function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
|
|
697
|
+
if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
|
|
656
698
|
const id = sessionId(turn && turn.session);
|
|
657
699
|
// Before the spawn: the daemon reads it on startup.
|
|
658
700
|
writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
|
|
@@ -660,7 +702,7 @@ function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
|
660
702
|
|
|
661
703
|
stopDaemon({ keepSessions: true });
|
|
662
704
|
fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
|
|
663
|
-
return spawnDaemon(genre, volume, reactive);
|
|
705
|
+
return spawnDaemon(genre, volume, reactive, follow);
|
|
664
706
|
}
|
|
665
707
|
|
|
666
708
|
/**
|
|
@@ -851,7 +893,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
851
893
|
* one needing approval, can resume early - after the chime already did its
|
|
852
894
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
853
895
|
*/
|
|
854
|
-
function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
896
|
+
function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
|
|
855
897
|
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
856
898
|
const session = readSession(id);
|
|
857
899
|
if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
|
|
@@ -861,7 +903,7 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
|
861
903
|
|
|
862
904
|
const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
|
|
863
905
|
writeSession(id, { ...session, waiting: null, waitStart: null, blockedMs });
|
|
864
|
-
if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
|
|
906
|
+
if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
|
|
865
907
|
return true;
|
|
866
908
|
}
|
|
867
909
|
|
package/src/mac-player.jxa
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// VibeAudio macOS player: one long-lived process driving AVAudioPlayer, run by
|
|
2
2
|
// `osascript -l JavaScript`. Lines on stdin:
|
|
3
3
|
// play <volume 0-1> <fade-in seconds> <path to wav>
|
|
4
|
+
// volume <volume 0-1> <ramp seconds> (everything sounding now)
|
|
4
5
|
// End of input - a closed pipe, or the daemon dying - fades out and exits, so
|
|
5
6
|
// the audio can never outlive whoever started it.
|
|
6
7
|
ObjC.import("Foundation");
|
|
@@ -26,6 +27,11 @@ function play(volume, fadeIn, path) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function handle(line) {
|
|
30
|
+
const v = /^volume (\S+) (\S+)$/.exec(line);
|
|
31
|
+
if (v) {
|
|
32
|
+
for (const p of players) if (p.playing) p.setVolumeFadeDuration(parseFloat(v[1]), parseFloat(v[2]));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
29
35
|
const m = /^play (\S+) (\S+) (.+)$/.exec(line);
|
|
30
36
|
if (m) play(parseFloat(m[1]), parseFloat(m[2]), m[3]);
|
|
31
37
|
}
|
package/src/player.js
CHANGED
|
@@ -116,6 +116,7 @@ let warnedNoPlayer = false;
|
|
|
116
116
|
// run, and it is used for the rest of the process once one has failed.
|
|
117
117
|
const MAC_HELPER = path.join(__dirname, "mac-player.jxa");
|
|
118
118
|
const FADE_IN_S = 0.5;
|
|
119
|
+
const VOLUME_RAMP_S = 0.3;
|
|
119
120
|
let helperFailed = false;
|
|
120
121
|
|
|
121
122
|
function helperUsable(backend) {
|
|
@@ -808,6 +809,22 @@ class AudioPlayer {
|
|
|
808
809
|
this.nextTimer = setTimeout(() => this.playLoop(), Math.max(250, durationMs - LOOP_OVERLAP_MS));
|
|
809
810
|
}
|
|
810
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Changes the volume of music already playing. The helper eases the loops
|
|
814
|
+
* that are sounding to it; any other backend takes it at the next loop, which
|
|
815
|
+
* is as live as a player that reads its volume once at spawn can be.
|
|
816
|
+
*/
|
|
817
|
+
setVolume(volume) {
|
|
818
|
+
const target = Math.max(0.05, Math.min(1.0, Number(volume))); // Already a fraction, unlike normalizeVolume's percent.
|
|
819
|
+
if (!this.isPlaying || !Number.isFinite(target) || target === this.volume) return;
|
|
820
|
+
this.volume = target;
|
|
821
|
+
try {
|
|
822
|
+
if (this.helper) this.helper.stdin.write(`volume ${this.volume} ${VOLUME_RAMP_S}\n`);
|
|
823
|
+
} catch (e) {
|
|
824
|
+
// The helper's own exit handler deals with a dead pipe.
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
811
828
|
spawnLoop(audioFile, backend) {
|
|
812
829
|
const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
|
|
813
830
|
|