vibeaudio 0.11.1 → 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 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 8 genres, the tier escalation, and the three chimes, rendered from the real synth.
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 lands at the next loop boundary, and on hooks at your next prompt; `vibe --status` shows the volume in effect and what set it.
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.11.1",
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)}${playing.volume === null ? "" : on(` @ ${playing.volume}%`)}${playing.reactive ? off(", reactive") : ""}`
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
 
@@ -610,7 +611,7 @@ const INTERRUPT_POLL_MS = 500;
610
611
  * Internal mode: plays while any session is working. The player's own loop
611
612
  * timer keeps the event loop alive.
612
613
  */
613
- function runDaemon(genre, volume, { reactive = false } = {}) {
614
+ function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}) {
614
615
  const watchers = new Map(); // session id -> its transcript poll
615
616
 
616
617
  // Drops sessions whose transcript shows an interrupt (Esc fires no hook, so
@@ -661,6 +662,8 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
661
662
  setTimeout(shutdown, MAX_DAEMON_MS);
662
663
 
663
664
  setInterval(() => {
665
+ // A volume saved while this plays reaches it now, not at the next prompt.
666
+ if (volumeSource) player.setVolume(volumeSource());
664
667
  if (sweep()) return;
665
668
  player.stop({ playChime: false });
666
669
  endIdle();
@@ -672,9 +675,10 @@ function daemonRunning() {
672
675
  return pid !== null && isOurDaemon(pid);
673
676
  }
674
677
 
675
- function spawnDaemon(genre, volume, reactive) {
678
+ function spawnDaemon(genre, volume, reactive, follow = false) {
676
679
  const args = [CLI_ENTRY, "--daemon", "--genre", genre, "--volume", String(Math.round(volume * 100))];
677
680
  if (reactive) args.push("--reactive");
681
+ if (follow) args.push("--follow-volume");
678
682
 
679
683
  const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
680
684
  child.unref();
@@ -689,7 +693,7 @@ function spawnDaemon(genre, volume, reactive) {
689
693
  * always did, so a genre change lands on the next prompt. With one working the
690
694
  * music carries on: restarting it would cut every other session's stream.
691
695
  */
692
- function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
696
+ function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
693
697
  if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
694
698
  const id = sessionId(turn && turn.session);
695
699
  // Before the spawn: the daemon reads it on startup.
@@ -698,7 +702,7 @@ function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
698
702
 
699
703
  stopDaemon({ keepSessions: true });
700
704
  fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
701
- return spawnDaemon(genre, volume, reactive);
705
+ return spawnDaemon(genre, volume, reactive, follow);
702
706
  }
703
707
 
704
708
  /**
@@ -889,7 +893,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
889
893
  * one needing approval, can resume early - after the chime already did its
890
894
  * job. Match on tool_input as well if that ever shows up in practice.
891
895
  */
892
- function hookResume(raw, genre, volume, { reactive = false } = {}) {
896
+ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
893
897
  const id = sessionId(payloadSession(parsePayload(raw)));
894
898
  const session = readSession(id);
895
899
  if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
@@ -899,7 +903,7 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
899
903
 
900
904
  const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
901
905
  writeSession(id, { ...session, waiting: null, waitStart: null, blockedMs });
902
- if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
906
+ if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
903
907
  return true;
904
908
  }
905
909
 
@@ -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