vibeaudio 0.10.0 → 0.11.1
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 +14 -12
- package/package.json +1 -1
- package/src/cli.js +2 -1
- package/src/hooks.js +75 -7
- package/src/interactive.js +1 -0
- package/src/mac-player.jxa +46 -0
- package/src/player.js +65 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
> **Procedural focus music while your AI coding tools think.**
|
|
6
6
|
> Every project gets its own arrangement. Zero dependencies, zero audio files.
|
|
7
|
-
> Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Aider — and any terminal command.
|
|
7
|
+
> Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf, Aider — and any terminal command.
|
|
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
|
|
|
@@ -27,7 +27,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
|
|
|
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
28
|
* 🔔 **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
29
|
* ✋ **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
|
-
* 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI
|
|
30
|
+
* 🔌 **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.
|
|
31
31
|
|
|
32
32
|
<details>
|
|
33
33
|
<summary><b>And the quieter four</b> — silence on fast commands, several sessions at once, exit codes, the HUD</summary>
|
|
@@ -223,7 +223,7 @@ Adding an entry to the launcher is one line in [`src/interactive.js`](src/intera
|
|
|
223
223
|
|
|
224
224
|
## 🪝 Agent Hooks (no wrapper needed)
|
|
225
225
|
|
|
226
|
-
> **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI
|
|
226
|
+
> **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code and Windsurf.** Everything else uses the [wrapper or MCP](#-everything-else-claude-desktop-antigravity-via-mcp) instead.
|
|
227
227
|
|
|
228
228
|
Wrapping (`vibe claude`) infers "the AI is thinking" from how long the process runs. Hooks know for certain — so music starts the moment you submit a prompt and stops the moment the agent finishes, with no grace-window guessing and no aliases.
|
|
229
229
|
|
|
@@ -236,12 +236,12 @@ vibe --genre jazz --volume 25 --install-hooks # install, and save these as yo
|
|
|
236
236
|
vibe --install-hooks --dry-run # show what would change, write nothing
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
-
**It auto-detects.** With no `--tools`, VibeAudio wires up each supported agent it finds on your machine — one counts as present when its config directory exists or its CLI is on your `PATH`. `--tools claude,codex,cursor,grok,gemini,copilot,qwen` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
|
|
239
|
+
**It auto-detects.** With no `--tools`, VibeAudio wires up each supported agent it finds on your machine — one counts as present when its config directory exists or its CLI is on your `PATH`. `--tools claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
|
|
240
240
|
|
|
241
241
|
That's it — run your agent normally, with no `vibe` prefix.
|
|
242
242
|
|
|
243
243
|
<details>
|
|
244
|
-
<summary><b>Which file and which events, per agent</b> —
|
|
244
|
+
<summary><b>Which file and which events, per agent</b> — eight dialects, all written for you</summary>
|
|
245
245
|
|
|
246
246
|
Each tool spells its events its own way, and VibeAudio writes whichever dialect the file expects:
|
|
247
247
|
|
|
@@ -254,6 +254,7 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
254
254
|
| **Gemini CLI** | `~/.gemini/settings.json` | `BeforeAgent` | `AfterAgent` | `BeforeTool` |
|
|
255
255
|
| **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
256
256
|
| **Qwen Code** | `~/.qwen/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
257
|
+
| **Windsurf** | `~/.codeium/windsurf/hooks.json` | `pre_user_prompt` | `post_cascade_response` | `pre_run_command` |
|
|
257
258
|
|
|
258
259
|
Where an agent reports more than start and stop, VibeAudio listens for that too — and only where the event was confirmed against the tool itself:
|
|
259
260
|
|
|
@@ -264,7 +265,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
264
265
|
| **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
|
|
265
266
|
| **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
|
|
266
267
|
| **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
|
|
267
|
-
| **Cursor, Grok** | — | — | — | — |
|
|
268
|
+
| **Cursor, Grok, Windsurf** | — | — | — | — |
|
|
268
269
|
|
|
269
270
|
</details>
|
|
270
271
|
|
|
@@ -302,7 +303,7 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
302
303
|
|
|
303
304
|
</details>
|
|
304
305
|
|
|
305
|
-
> **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI
|
|
306
|
+
> **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code or Windsurf session does the same hasn't been checked, so start a new session there to be sure.
|
|
306
307
|
|
|
307
308
|
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json`. (The separate **Claude Desktop** chat app is a different product with no hooks; that one needs [MCP](#-everything-else-claude-desktop-antigravity-via-mcp).)
|
|
308
309
|
|
|
@@ -409,9 +410,9 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
|
|
|
409
410
|
|
|
410
411
|
## 🖥️ Everything Else (Claude Desktop, Antigravity… via MCP)
|
|
411
412
|
|
|
412
|
-
**Claude Code, [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com/docs/hooks), [Grok](https://docs.x.ai/build/features/hooks), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Copilot CLI](https://docs.github.com/en/copilot/reference/hooks-configuration)
|
|
413
|
+
**Claude Code, [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com/docs/hooks), [Grok](https://docs.x.ai/build/features/hooks), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Copilot CLI](https://docs.github.com/en/copilot/reference/hooks-configuration), [Qwen Code](https://github.com/QwenLM/qwen-code) and [Windsurf](https://docs.devin.ai/desktop/cascade/hooks) have hook systems, and `--install-hooks` writes to all eight** — use [hooks](#-agent-hooks-no-wrapper-needed) there, they're strictly better. This section is for everything else. MCP is the way in: it's plain stdio JSON-RPC, so the setup is identical everywhere and only the config file differs.
|
|
413
414
|
|
|
414
|
-
> **This is weaker than hooks, by nature.** Hooks fire on an event; MCP tools are *model-invoked*, so the assistant has to decide to call `vibe_play` and remember `vibe_stop`. Expect the occasional silent turn — say "play some focus music while you work on this" if you want it reliably. **On any of the
|
|
415
|
+
> **This is weaker than hooks, by nature.** Hooks fire on an event; MCP tools are *model-invoked*, so the assistant has to decide to call `vibe_play` and remember `vibe_stop`. Expect the occasional silent turn — say "play some focus music while you work on this" if you want it reliably. **On any of the eight agents above, use [hooks](#-agent-hooks-no-wrapper-needed) instead.**
|
|
415
416
|
|
|
416
417
|
Use an **absolute path**, not the bare `vibe` command: GUI apps launched from Finder don't inherit your shell's `PATH`, and version managers like `fnm` or `nvm` put `vibe` on a per-shell path that won't resolve. Print yours with:
|
|
417
418
|
|
|
@@ -591,7 +592,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
591
592
|
| `--clear-cache` | Delete all cached audio, then exit | — |
|
|
592
593
|
| `--mcp` | Run as an MCP stdio server for desktop apps | — |
|
|
593
594
|
| `--install-hooks` | Wire music into your agent's hooks (no wrapper needed) | — |
|
|
594
|
-
| `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen` | auto-detect |
|
|
595
|
+
| `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` | auto-detect |
|
|
595
596
|
| `--reactive` | With `--install-hooks`: intensity follows the tool in use | off |
|
|
596
597
|
| `--dry-run` | With `--install-hooks`: show what would change in each file, write nothing | off |
|
|
597
598
|
| `--uninstall-hooks` | Remove the hooks again, from every agent | — |
|
|
@@ -612,6 +613,7 @@ export VIBE_DISABLE=1 # Mute, without uninstalling anything
|
|
|
612
613
|
export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
|
|
613
614
|
export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
|
|
614
615
|
export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
|
|
616
|
+
export VIBE_NO_FADE=1 # macOS: play with plain afplay, no fade in/out
|
|
615
617
|
```
|
|
616
618
|
|
|
617
619
|
**`VIBE_DISABLE=1` is for a shell you always want quiet** — a CI job, a shared machine, a terminal profile you keep silent. It's read at playback time and covers hooks, wrapper and MCP alike.
|
|
@@ -738,7 +740,7 @@ rm -rf ~/.vibeaudio # 3. optional: cached audio, saved settings, dae
|
|
|
738
740
|
|
|
739
741
|
Step 1 also **stops a background player that's still going**. That matters: once the hooks are gone nothing will ever send the `Stop` event, and after step 2 there's no `vibe` left to stop it with — music would simply play on until its 15-minute cap. If you ever need to do it by hand: `pkill -f "vibeaudio.js --daemon"`.
|
|
740
742
|
|
|
741
|
-
> **Order matters.** `npm rm -g` deletes the binary but not your hook config. Removing the package first strands hook entries that point at a path that no longer exists, and your agent will run a failing hook on every prompt. If you already did it in the wrong order, reinstall, run `vibe --uninstall-hooks`, then remove again — or delete the `vibeaudio` entries from `~/.claude/settings.json`, `~/.codex/hooks.json`, `~/.cursor/hooks.json`, `~/.gemini/settings.json` and `~/.
|
|
743
|
+
> **Order matters.** `npm rm -g` deletes the binary but not your hook config. Removing the package first strands hook entries that point at a path that no longer exists, and your agent will run a failing hook on every prompt. If you already did it in the wrong order, reinstall, run `vibe --uninstall-hooks`, then remove again — or delete the `vibeaudio` entries from `~/.claude/settings.json`, `~/.codex/hooks.json`, `~/.cursor/hooks.json`, `~/.gemini/settings.json`, `~/.qwen/settings.json` and `~/.codeium/windsurf/hooks.json` by hand, and delete `~/.grok/hooks/vibeaudio.json`, `~/.copilot/hooks/vibeaudio.json` and `~/.claude/commands/vibe.md`.
|
|
742
744
|
|
|
743
745
|
Step 3 reclaims disk — the audio cache, pruned to the 3 most recent projects — and drops your saved genre and volume. Both come back on their own, so skip it if you're reinstalling.
|
|
744
746
|
|
|
@@ -746,7 +748,7 @@ Step 3 reclaims disk — the audio cache, pruned to the 3 most recent projects
|
|
|
746
748
|
|
|
747
749
|
| Leftover | Why, and how to remove it |
|
|
748
750
|
| :--- | :--- |
|
|
749
|
-
| `*.vibeaudio.bak` next to each shared hook config | Your config as it was before the first install — one each for Claude Code, Codex, Cursor, Gemini CLI
|
|
751
|
+
| `*.vibeaudio.bak` next to each shared hook config | Your config as it was before the first install — one each for Claude Code, Codex, Cursor, Gemini CLI, Qwen Code and Windsurf. A safety net we won't delete for you; `rm` them once you're happy the real files are correct, and `vibe --uninstall-hooks` prints the path of every one it finds. (Grok and Copilot CLI leave nothing: their files are ours alone, so uninstall deletes them outright.) |
|
|
750
752
|
| `vibeaudio` entries in other apps' MCP configs | VibeAudio never edits those files, so it can't clean them either. Drop the entry from [whichever config you added it to](#-everything-else-claude-desktop-antigravity-via-mcp). |
|
|
751
753
|
|
|
752
754
|
Apart from those two, the three commands above remove everything VibeAudio writes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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
|
@@ -85,7 +85,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
85
85
|
--clear-cache Delete cached audio, then exit
|
|
86
86
|
--mcp Run as Model Context Protocol (MCP) server for Desktop apps
|
|
87
87
|
--install-hooks Wire music into your agent's hooks (no wrapper needed)
|
|
88
|
-
--tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen (auto-detect)
|
|
88
|
+
--tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen,windsurf (auto-detect)
|
|
89
89
|
--reactive With --install-hooks: intensity follows the tool in use
|
|
90
90
|
--dry-run With --install-hooks: show what would change, write nothing
|
|
91
91
|
--uninstall-hooks Remove the hooks again, from every agent
|
|
@@ -1085,6 +1085,7 @@ function detectAiTools(hooked = new Set()) {
|
|
|
1085
1085
|
{ cmd: "gemini", name: "Gemini CLI", integration: viaHooks("gemini") },
|
|
1086
1086
|
{ cmd: "copilot", name: "GitHub Copilot CLI", integration: viaHooks("copilot") },
|
|
1087
1087
|
{ cmd: "qwen", name: "Qwen Code", integration: viaHooks("qwen") },
|
|
1088
|
+
{ cmd: "windsurf", name: "Windsurf", integration: viaHooks("windsurf") },
|
|
1088
1089
|
{ cmd: "aider", name: "Aider", integration: "wrapper — vibe aider" },
|
|
1089
1090
|
{ cmd: "ollama", name: "Ollama", integration: "wrapper — vibe ollama run <model>" }
|
|
1090
1091
|
];
|
package/src/hooks.js
CHANGED
|
@@ -100,7 +100,14 @@ function payloadToolName(raw) {
|
|
|
100
100
|
const payload = parsePayload(raw);
|
|
101
101
|
// Claude Code, Codex and Cursor send tool_name; Grok sends toolName.
|
|
102
102
|
// Reading only one of them would silently pin that agent to tier 2.
|
|
103
|
-
|
|
103
|
+
// Windsurf names no tool: the event is the signal, and only the shell one is wired.
|
|
104
|
+
const byEvent = payload.agent_action_name === "pre_run_command" ? "Shell" : "";
|
|
105
|
+
return String(payload.tool_name || payload.toolName || byEvent);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Windsurf calls the conversation `trajectory_id`.
|
|
109
|
+
function payloadSession(payload) {
|
|
110
|
+
return payload.session_id || payload.trajectory_id;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
/**
|
|
@@ -292,6 +299,24 @@ const TARGETS = {
|
|
|
292
299
|
// Unlike the four above, not verified to re-read hooks mid-session.
|
|
293
300
|
liveReload: false,
|
|
294
301
|
note: "Not checked whether an open Qwen Code session reloads hooks — start a new one to be sure."
|
|
302
|
+
},
|
|
303
|
+
windsurf: {
|
|
304
|
+
name: "Windsurf",
|
|
305
|
+
cmd: "windsurf",
|
|
306
|
+
// Checked against Windsurf's hooks documentation only: it was not installed
|
|
307
|
+
// here, so nothing was run. User-level file, a flat {command} entry with no
|
|
308
|
+
// timeout field, and the conversation named `trajectory_id` rather than
|
|
309
|
+
// session_id. post_cascade_response is the end-of-reply event; Windsurf has
|
|
310
|
+
// no permission-dialog, failure or session-end event, so none is wired, and
|
|
311
|
+
// its pre-tool events are per kind - pre_run_command is the one reactive
|
|
312
|
+
// mode listens to.
|
|
313
|
+
file: () => path.join(os.homedir(), ".codeium", "windsurf", "hooks.json"),
|
|
314
|
+
events: { start: "pre_user_prompt", stop: "post_cascade_response", tool: "pre_run_command" },
|
|
315
|
+
entry: (command) => ({ command }),
|
|
316
|
+
commands: (entry) => (entry.command ? [entry.command] : []),
|
|
317
|
+
seed: () => ({}),
|
|
318
|
+
liveReload: false,
|
|
319
|
+
note: "Windsurf's hooks do not load in Restricted Mode, and whether an open session reloads them is not documented — start a new one to be sure."
|
|
295
320
|
}
|
|
296
321
|
};
|
|
297
322
|
|
|
@@ -425,7 +450,44 @@ function fileSize(file) {
|
|
|
425
450
|
function newTurn(raw) {
|
|
426
451
|
const payload = parsePayload(raw);
|
|
427
452
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
428
|
-
|
|
453
|
+
const offset = transcript ? fileSize(transcript) : 0;
|
|
454
|
+
return { session: String(payloadSession(payload) || ""), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const BLOCK_LOOKBACK_MS = 3000;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Whether another hook has already rejected this very prompt. Hooks run side
|
|
461
|
+
* by side and a native blocker is faster to start than we are, so its transcript
|
|
462
|
+
* entry can land *before* the offset taken above - where the daemon, which reads
|
|
463
|
+
* only what follows, would never see it. Looked at the tail instead, and only
|
|
464
|
+
* for an entry stamped within the last moments, so an earlier blocked prompt
|
|
465
|
+
* does not silence this one.
|
|
466
|
+
*/
|
|
467
|
+
function blockedJustBefore(transcript, offset) {
|
|
468
|
+
if (!transcript || !offset) return false;
|
|
469
|
+
try {
|
|
470
|
+
const len = Math.min(offset, 16384);
|
|
471
|
+
const fd = fs.openSync(transcript, "r");
|
|
472
|
+
const buf = Buffer.alloc(len);
|
|
473
|
+
try {
|
|
474
|
+
fs.readSync(fd, buf, 0, len, offset - len);
|
|
475
|
+
} finally {
|
|
476
|
+
fs.closeSync(fd);
|
|
477
|
+
}
|
|
478
|
+
const lines = buf.toString("utf8").split("\n");
|
|
479
|
+
if (len < offset) lines.shift(); // Began mid-line.
|
|
480
|
+
return lines.some((line) => {
|
|
481
|
+
if (!isInterruptEntry(line)) return false;
|
|
482
|
+
try {
|
|
483
|
+
return Date.now() - Date.parse(JSON.parse(line).timestamp) <= BLOCK_LOOKBACK_MS;
|
|
484
|
+
} catch (e) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
} catch (e) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
429
491
|
}
|
|
430
492
|
|
|
431
493
|
// The id ends up in a file name.
|
|
@@ -485,11 +547,16 @@ const working = (sessions) => sessions.filter((s) => s.waiting == null);
|
|
|
485
547
|
// Claude Code's entry for Esc / the stop button: a user message whose text is
|
|
486
548
|
// "[Request interrupted by user]" or "... for tool use]".
|
|
487
549
|
const INTERRUPT_MARK = "[Request interrupted by user";
|
|
550
|
+
const BLOCKED_MARK = "UserPromptSubmit operation blocked by hook";
|
|
488
551
|
|
|
489
552
|
function isInterruptEntry(line) {
|
|
490
|
-
if (!line.includes(INTERRUPT_MARK)) return false; // Cheap filter before parsing.
|
|
553
|
+
if (!line.includes(INTERRUPT_MARK) && !line.includes(BLOCKED_MARK)) return false; // Cheap filter before parsing.
|
|
491
554
|
try {
|
|
492
555
|
const entry = JSON.parse(line);
|
|
556
|
+
// A prompt another hook blocked: hooks run side by side, so ours had already
|
|
557
|
+
// started the music, and no Stop will ever follow. The turn is over before
|
|
558
|
+
// it began, which is the same silent end as an interrupt.
|
|
559
|
+
if (entry.type === "system" && typeof entry.content === "string") return entry.content.startsWith(BLOCKED_MARK);
|
|
493
560
|
if (entry.type !== "user" || !entry.message) return false;
|
|
494
561
|
// Structural, not substring: a transcript that merely quotes the phrase -
|
|
495
562
|
// in a tool result, a file, a prompt about this very feature - is not one.
|
|
@@ -623,6 +690,7 @@ function spawnDaemon(genre, volume, reactive) {
|
|
|
623
690
|
* music carries on: restarting it would cut every other session's stream.
|
|
624
691
|
*/
|
|
625
692
|
function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
693
|
+
if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
|
|
626
694
|
const id = sessionId(turn && turn.session);
|
|
627
695
|
// Before the spawn: the daemon reads it on startup.
|
|
628
696
|
writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
|
|
@@ -731,7 +799,7 @@ function notify(raw, message) {
|
|
|
731
799
|
}
|
|
732
800
|
|
|
733
801
|
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre, raw = "" } = {}) {
|
|
734
|
-
const id = sessionId(parsePayload(raw)
|
|
802
|
+
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
735
803
|
// A turn that ends while paused for the user - a denied tool that nothing
|
|
736
804
|
// resumed after - still finished, so its session counts either way.
|
|
737
805
|
const session = readSession(id);
|
|
@@ -796,7 +864,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
796
864
|
const type = payload.notification_type ?? payload.notificationType;
|
|
797
865
|
if (type !== undefined && !WAIT_NOTIFICATIONS.has(String(type))) return false;
|
|
798
866
|
|
|
799
|
-
const id = sessionId(payload
|
|
867
|
+
const id = sessionId(payloadSession(payload));
|
|
800
868
|
const session = readSession(id);
|
|
801
869
|
if (!session || session.waiting != null) return false;
|
|
802
870
|
|
|
@@ -822,7 +890,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
822
890
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
823
891
|
*/
|
|
824
892
|
function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
825
|
-
const id = sessionId(parsePayload(raw)
|
|
893
|
+
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
826
894
|
const session = readSession(id);
|
|
827
895
|
if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
|
|
828
896
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
@@ -845,7 +913,7 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
|
845
913
|
* applies.
|
|
846
914
|
*/
|
|
847
915
|
function hookEnd(raw) {
|
|
848
|
-
const id = sessionId(parsePayload(raw)
|
|
916
|
+
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
849
917
|
if (!readSession(id)) return false;
|
|
850
918
|
|
|
851
919
|
fs.rmSync(sessionFile(id), { force: true });
|
package/src/interactive.js
CHANGED
|
@@ -35,6 +35,7 @@ const AI_TOOLS = [
|
|
|
35
35
|
{ name: "Cursor CLI", cmd: ["cursor-agent"], check: "cursor-agent", hookTarget: "cursor" },
|
|
36
36
|
{ name: "GitHub Copilot CLI", cmd: ["copilot"], check: "copilot", hookTarget: "copilot" },
|
|
37
37
|
{ name: "Qwen Code", cmd: ["qwen"], check: "qwen", hookTarget: "qwen" },
|
|
38
|
+
{ name: "Windsurf", cmd: ["windsurf"], check: "windsurf", hookTarget: "windsurf" },
|
|
38
39
|
{ name: "Aider", cmd: ["aider"], check: "aider" },
|
|
39
40
|
{ name: "Ollama (Llama 3)", cmd: ["ollama", "run", "llama3"], check: "ollama" },
|
|
40
41
|
{ name: "Custom command...", cmd: null }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// VibeAudio macOS player: one long-lived process driving AVAudioPlayer, run by
|
|
2
|
+
// `osascript -l JavaScript`. Lines on stdin:
|
|
3
|
+
// play <volume 0-1> <fade-in seconds> <path to wav>
|
|
4
|
+
// End of input - a closed pipe, or the daemon dying - fades out and exits, so
|
|
5
|
+
// the audio can never outlive whoever started it.
|
|
6
|
+
ObjC.import("Foundation");
|
|
7
|
+
ObjC.import("AVFoundation");
|
|
8
|
+
|
|
9
|
+
const FADE_OUT_S = 0.3;
|
|
10
|
+
const players = [];
|
|
11
|
+
|
|
12
|
+
function sleep(seconds) {
|
|
13
|
+
$.NSThread.sleepForTimeInterval(seconds);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function play(volume, fadeIn, path) {
|
|
17
|
+
const url = $.NSURL.fileURLWithPath(path);
|
|
18
|
+
const p = $.AVAudioPlayer.alloc.initWithContentsOfURLError(url, null);
|
|
19
|
+
if (!p || !p.prepareToPlay) return;
|
|
20
|
+
p.volume = fadeIn > 0 ? 0 : volume;
|
|
21
|
+
p.play;
|
|
22
|
+
if (fadeIn > 0) p.setVolumeFadeDuration(volume, fadeIn);
|
|
23
|
+
players.push(p);
|
|
24
|
+
// Finished loops are done with: keep only what is still sounding.
|
|
25
|
+
for (let i = players.length - 2; i >= 0; i--) if (!players[i].playing) players.splice(i, 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handle(line) {
|
|
29
|
+
const m = /^play (\S+) (\S+) (.+)$/.exec(line);
|
|
30
|
+
if (m) play(parseFloat(m[1]), parseFloat(m[2]), m[3]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stdin = $.NSFileHandle.fileHandleWithStandardInput;
|
|
34
|
+
let pending = "";
|
|
35
|
+
for (;;) {
|
|
36
|
+
const data = stdin.availableData; // Blocks; playback runs on its own thread.
|
|
37
|
+
if (Number(data.length) === 0) break; // JXA hands NSData.length back as a string.
|
|
38
|
+
pending += $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;
|
|
39
|
+
const lines = pending.split("\n");
|
|
40
|
+
pending = lines.pop();
|
|
41
|
+
lines.forEach(handle);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const p of players) if (p.playing) p.setVolumeFadeDuration(0, FADE_OUT_S);
|
|
45
|
+
sleep(FADE_OUT_S + 0.05);
|
|
46
|
+
for (const p of players) p.stop;
|
package/src/player.js
CHANGED
|
@@ -110,6 +110,18 @@ const PLAYER_CANDIDATES = [
|
|
|
110
110
|
let cachedPlayer;
|
|
111
111
|
let warnedNoPlayer = false;
|
|
112
112
|
|
|
113
|
+
// macOS: one long-lived AVAudioPlayer process instead of an afplay per loop,
|
|
114
|
+
// which is what lets music fade in and out rather than start and end mid-note.
|
|
115
|
+
// afplay stays the fallback: a helper that cannot start is not worth a silent
|
|
116
|
+
// run, and it is used for the rest of the process once one has failed.
|
|
117
|
+
const MAC_HELPER = path.join(__dirname, "mac-player.jxa");
|
|
118
|
+
const FADE_IN_S = 0.5;
|
|
119
|
+
let helperFailed = false;
|
|
120
|
+
|
|
121
|
+
function helperUsable(backend) {
|
|
122
|
+
return process.platform === "darwin" && backend.cmd === "afplay" && !helperFailed && !process.env.VIBE_NO_FADE;
|
|
123
|
+
}
|
|
124
|
+
|
|
113
125
|
function detectPlayer() {
|
|
114
126
|
if (cachedPlayer !== undefined) return cachedPlayer;
|
|
115
127
|
|
|
@@ -708,6 +720,7 @@ class AudioPlayer {
|
|
|
708
720
|
constructor() {
|
|
709
721
|
this.isPlaying = false;
|
|
710
722
|
this.procs = new Set();
|
|
723
|
+
this.helper = null;
|
|
711
724
|
this.startTime = 0;
|
|
712
725
|
this.genre = "lofi";
|
|
713
726
|
this.volume = 0.42;
|
|
@@ -789,6 +802,13 @@ class AudioPlayer {
|
|
|
789
802
|
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar);
|
|
790
803
|
// Advanced after the choice, so the bar that plays first is bar 0.
|
|
791
804
|
this.bar = (this.bar + 1) % LOOP_BARS;
|
|
805
|
+
if (!(helperUsable(backend) && this.playViaHelper(audioFile, backend))) this.spawnLoop(audioFile, backend);
|
|
806
|
+
|
|
807
|
+
const durationMs = wavDurationMs(audioFile) || 6500;
|
|
808
|
+
this.nextTimer = setTimeout(() => this.playLoop(), Math.max(250, durationMs - LOOP_OVERLAP_MS));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
spawnLoop(audioFile, backend) {
|
|
792
812
|
const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
|
|
793
813
|
|
|
794
814
|
this.procs.add(proc);
|
|
@@ -798,12 +818,55 @@ class AudioPlayer {
|
|
|
798
818
|
this.isPlaying = false;
|
|
799
819
|
warnNoPlayer();
|
|
800
820
|
});
|
|
821
|
+
}
|
|
801
822
|
|
|
802
|
-
|
|
803
|
-
|
|
823
|
+
/**
|
|
824
|
+
* Hands one loop to the macOS helper, starting it on first use. Returns false
|
|
825
|
+
* when the helper could not be started, so the caller plays it with afplay.
|
|
826
|
+
* A helper that dies while we are playing is dropped and the loop that was
|
|
827
|
+
* meant for it replayed, so a failure costs one restart, not the music.
|
|
828
|
+
*/
|
|
829
|
+
playViaHelper(audioFile, backend) {
|
|
830
|
+
try {
|
|
831
|
+
if (!this.helper) {
|
|
832
|
+
const helper = spawn("osascript", ["-l", "JavaScript", MAC_HELPER], { stdio: ["pipe", "ignore", "ignore"] });
|
|
833
|
+
helper.stdin.on("error", () => {}); // EPIPE once it is gone.
|
|
834
|
+
const dropped = () => {
|
|
835
|
+
if (this.helper !== helper) return;
|
|
836
|
+
this.helper = null;
|
|
837
|
+
helperFailed = true;
|
|
838
|
+
if (this.isPlaying) this.spawnLoop(audioFile, backend);
|
|
839
|
+
};
|
|
840
|
+
helper.on("error", dropped);
|
|
841
|
+
helper.on("exit", dropped);
|
|
842
|
+
this.helper = helper;
|
|
843
|
+
this.helperFirst = true;
|
|
844
|
+
}
|
|
845
|
+
const fade = this.helperFirst ? FADE_IN_S : 0; // Later loops carry their own boundary fades.
|
|
846
|
+
this.helperFirst = false;
|
|
847
|
+
this.helper.stdin.write(`play ${this.volume} ${fade} ${audioFile}\n`);
|
|
848
|
+
return true;
|
|
849
|
+
} catch (e) {
|
|
850
|
+
helperFailed = true;
|
|
851
|
+
this.helper = null;
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
804
854
|
}
|
|
805
855
|
|
|
806
856
|
killProcs() {
|
|
857
|
+
// Closing the helper's input is the stop: it fades out and exits on its own,
|
|
858
|
+
// so the music ends on a decay, and a daemon that dies without cleanup closes
|
|
859
|
+
// the pipe just the same.
|
|
860
|
+
if (this.helper) {
|
|
861
|
+
const helper = this.helper;
|
|
862
|
+
this.helper = null;
|
|
863
|
+
try {
|
|
864
|
+
helper.stdin.end();
|
|
865
|
+
helper.unref();
|
|
866
|
+
} catch (e) {
|
|
867
|
+
// Already gone.
|
|
868
|
+
}
|
|
869
|
+
}
|
|
807
870
|
for (const proc of this.procs) {
|
|
808
871
|
try {
|
|
809
872
|
proc.kill("SIGTERM");
|