vibeaudio 0.12.0 → 0.13.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 +77 -6
- package/package.json +2 -1
- package/src/cli.js +78 -7
- package/src/history.js +36 -5
- package/src/hooks.js +197 -16
- package/src/player.js +12 -5
- package/src/synth/tension.js +67 -0
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
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
|
+
>
|
|
15
|
+
> **Claude Code only?** Skip npm: `claude plugin marketplace add kiril6/vibeaudio`, then `claude plugin install vibeaudio@vibeaudio` ([details](#as-a-claude-code-plugin)).
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
@@ -18,6 +20,15 @@
|
|
|
18
20
|
|
|
19
21
|
AI coding agents take 15–45 seconds to reason, read files and write code. Staring at a blank cursor feels slow; tabbing away means checking back to see if it's done. VibeAudio fills that gap with music while the agent thinks, and a chime when your output is ready to read.
|
|
20
22
|
|
|
23
|
+
It is built as **calm technology**, in the sense of Mark Weiser and John Seely Brown's *Designing Calm Technology* (Xerox PARC, 1995): information that stays at the edge of your attention and only comes to the front when it matters. You stop noticing the music, but you notice when it changes.
|
|
24
|
+
|
|
25
|
+
| Principle | In VibeAudio |
|
|
26
|
+
| :--- | :--- |
|
|
27
|
+
| Inform without demanding attention | The music sits in the background. What you notice is it *stopping*. |
|
|
28
|
+
| Come to the front only when it matters | Chimes for done, failed and "needs you" only. The music doesn't react to every tool call unless you ask it to ([reactive mode](#reactive-mode-opt-in)). |
|
|
29
|
+
| Make the background informative | Layers build as a turn runs long, and a heartbeat joins when an agent keeps failing. That fires in under 1% of turns, by design. |
|
|
30
|
+
| Calm, not alarming | "Needs you" is a soft rising interval, not an alarm. A mute ends on its own after an hour. |
|
|
31
|
+
|
|
21
32
|
**How it behaves:**
|
|
22
33
|
|
|
23
34
|
* 🧮 **Pure synthesis, zero MP3s.** Every note, chord and pad is generated in code — no audio assets, no npm dependencies, no `node-gyp`.
|
|
@@ -27,6 +38,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
|
|
|
27
38
|
* 🎛️ **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
39
|
* 🌅 **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.
|
|
29
40
|
* 🔔 **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.
|
|
41
|
+
* 💓 **A heartbeat when an agent looks stuck.** If 4 of a session's last 8 tool calls fail — the test-edit-test loop that goes nowhere — a soft pulse on the music's tonic joins the music until things start passing again. It's rare by design: on 4,497 real turns it fired in under 1%.
|
|
30
42
|
* ✋ **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.
|
|
31
43
|
* 🔌 **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.
|
|
32
44
|
|
|
@@ -248,8 +260,8 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
248
260
|
|
|
249
261
|
| | File | Music starts | Music stops + chime | Reactive (opt-in) |
|
|
250
262
|
| :--- | :--- | :--- | :--- | :--- |
|
|
251
|
-
| **Claude Code** | `~/.claude/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
252
|
-
| **Codex** | `~/.codex/hooks.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
263
|
+
| **Claude Code** | `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
264
|
+
| **Codex** | `~/.codex/hooks.json` (or `$CODEX_HOME`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
253
265
|
| **Cursor** | `~/.cursor/hooks.json` | `beforeSubmitPrompt` | `stop` | `preToolUse` |
|
|
254
266
|
| **Grok** | `~/.grok/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
255
267
|
| **Gemini CLI** | `~/.gemini/settings.json` | `BeforeAgent` | `AfterAgent` | `BeforeTool` |
|
|
@@ -262,7 +274,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
262
274
|
| | Music pauses + "your turn" chime | Music resumes | Failure chime (API error) | Session closes mid-turn (silent) |
|
|
263
275
|
| :--- | :--- | :--- | :--- | :--- |
|
|
264
276
|
| **Claude Code** | `PermissionRequest`, `Elicitation` | `PostToolUse`, `PostToolUseFailure`, `ElicitationResult` | `StopFailure` | `SessionEnd` |
|
|
265
|
-
| **Codex** | `PermissionRequest` | `PostToolUse` | — |
|
|
277
|
+
| **Codex** | `PermissionRequest` | `PostToolUse` | — | `SessionEnd`, and `Interrupt` (Esc) |
|
|
266
278
|
| **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
|
|
267
279
|
| **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
|
|
268
280
|
| **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
|
|
@@ -280,6 +292,8 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
280
292
|
| **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. |
|
|
281
293
|
| **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. |
|
|
282
294
|
| **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. |
|
|
295
|
+
| **Upgraded, and a new feature isn't there?** | Hooks are written into your agent's config once, at install time, and an upgrade doesn't touch them. When a new version listens for more events (0.13.1 added Codex's `Interrupt` and `SessionEnd`), `vibe --doctor` and `vibe --status` name what's missing, and `vibe --install-hooks` adds it. |
|
|
296
|
+
| **Moved your config folder?** | VibeAudio follows `CLAUDE_CONFIG_DIR` and `CODEX_HOME` the way the agents do, so hooks (and `/vibe`) go where your agent actually reads them. Set the variable in the shell you run `vibe --install-hooks` from. |
|
|
283
297
|
| **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`. |
|
|
284
298
|
|
|
285
299
|
</details>
|
|
@@ -306,13 +320,29 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
306
320
|
|
|
307
321
|
> **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.
|
|
308
322
|
|
|
309
|
-
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json
|
|
323
|
+
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/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).)
|
|
310
324
|
|
|
311
325
|
> **One player is shared.** Prompt two agents at once and the last prompt owns the music. One person, one set of speakers — deliberate, not a limitation being worked around.
|
|
312
326
|
|
|
327
|
+
### As a Claude Code plugin
|
|
328
|
+
|
|
329
|
+
If Claude Code is the only agent you use, the plugin installs the same hooks with no npm step:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
claude plugin marketplace add kiril6/vibeaudio
|
|
333
|
+
claude plugin install vibeaudio@vibeaudio
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
It needs Node 18+ on your `PATH` (the hooks run `node`), and it also adds `/vibeaudio:vibe`, the plugin's spelling of [`/vibe`](#vibe-inside-claude-code). Update with `claude plugin update vibeaudio@vibeaudio`; remove with `claude plugin uninstall vibeaudio@vibeaudio` (music already playing stops on its own within 15 minutes, or run `/vibeaudio:vibe stop` first).
|
|
337
|
+
|
|
338
|
+
- **Settings are the same file.** `vibe --genre jazz` and the rest work as always, but the `vibe` command comes from `npm i -g vibeaudio`; the plugin alone puts nothing on your `PATH`. Use `/vibeaudio:vibe genre jazz` instead.
|
|
339
|
+
- **Both at once is safe.** If `--install-hooks` has also been run, the plugin's hooks step aside and the installed ones play, so there's no double chime. Remove the installed hooks first if you want the plugin to own it.
|
|
340
|
+
- **Claude Code only.** Codex, Cursor, Gemini and the others still use `vibe --install-hooks`.
|
|
341
|
+
- **Not reactive.** `--reactive` is an `--install-hooks` option; the plugin doesn't carry it.
|
|
342
|
+
|
|
313
343
|
### `/vibe` inside Claude Code
|
|
314
344
|
|
|
315
|
-
Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`), so you can control the music without leaving the session:
|
|
345
|
+
Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`, or under `$CLAUDE_CONFIG_DIR`), so you can control the music without leaving the session:
|
|
316
346
|
|
|
317
347
|
```text
|
|
318
348
|
/vibe what's installed and playing
|
|
@@ -368,14 +398,24 @@ Hooks log each finished turn — which project, how long, how it ended, and how
|
|
|
368
398
|
You waited 3h 55m on agents, 7m 51s per turn
|
|
369
399
|
Agents waited 5m 35s on you — permission dialogs and questions
|
|
370
400
|
Typical turn 2m 57s median
|
|
401
|
+
Back to it 41s median from done to your next prompt, 27 times
|
|
402
|
+
32s with a chime 1m 50s without (muted or --no-chime)
|
|
371
403
|
```
|
|
372
404
|
|
|
373
|
-
plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged — a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
|
|
405
|
+
plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. "Back to it" is the other direction: how long a finished turn sat before your next prompt in the same session — gaps over 30 minutes count as breaks and are left out. Once both sides have five turns, it is split by whether a chime announced the turn, so you can see what the chime is worth to you. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged — a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
|
|
374
406
|
|
|
375
407
|
### The chime is in the music's key
|
|
376
408
|
|
|
377
409
|
The success chime is the tonic chord of whatever key your genre sits in — C major for lofi, 8bit, jazz and piano, D minor for synthwave and electronic, D major for zen, A minor for drone — so it lands as the resolution of the piece rather than a bell over it. `rain`, `ocean` and `random` have no single key to match and keep the original C major chime.
|
|
378
410
|
|
|
411
|
+
### When an agent looks stuck
|
|
412
|
+
|
|
413
|
+
The worst stretch of agent work is twenty minutes of the same thing failing while the music says all is well. So when **4 of a session's last 8 tool calls fail**, a soft heartbeat — two low beats about once a second, on the tonic of your genre — joins the music at the next loop boundary, and leaves once enough calls succeed. With `--notify` on, you also get one banner when the session crosses the line ("api: looks stuck"), not one per failure.
|
|
414
|
+
|
|
415
|
+
The threshold was picked against 4,497 real Claude Code turns (46,032 tool calls). It fires in about 1% of turns, at around minute 3, and those turns typically ran for another 3 minutes — time you could have spent stepping in. "3 in a row" was rejected because the usual loop has a successful edit between every failing test run. Each new prompt starts with a clean slate, and an interrupt (Esc) never counts as a failure.
|
|
416
|
+
|
|
417
|
+
It needs the agent to report failed tool calls, which **Claude Code, Copilot CLI and Qwen Code** do (`PostToolUseFailure`). Codex, Cursor, Gemini, Grok and Windsurf don't, so for them the music never adds the heartbeat.
|
|
418
|
+
|
|
379
419
|
### Reactive mode (opt-in)
|
|
380
420
|
|
|
381
421
|
```bash
|
|
@@ -409,6 +449,35 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
|
|
|
409
449
|
|
|
410
450
|
---
|
|
411
451
|
|
|
452
|
+
### Build on it: `vibe --state` and `vibe --events`
|
|
453
|
+
|
|
454
|
+
The hard part of VibeAudio isn't the music. It's turning eight agents' different hook events into one set of states that mean the same thing everywhere. The music is one consumer of those states, and anything else can be another: a smart light, a menu bar icon, a tmux status line, a Stream Deck key.
|
|
455
|
+
|
|
456
|
+
```bash
|
|
457
|
+
vibe --state
|
|
458
|
+
# {"v":1,"status":"waiting","sessions":[{"session":"…","project":"/work/api","state":"waiting","since":1791026124771,"tool":"Bash"}]}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiting` (on you: a permission dialog or a question). `status` is the machine's overall state: the most urgent of its sessions, in the order `waiting` > `stuck` > `working` > `idle`. One session waiting on you matters more than three working.
|
|
462
|
+
|
|
463
|
+
`vibe --events` prints that snapshot once, then a JSON line for every change, as it happens:
|
|
464
|
+
|
|
465
|
+
```json
|
|
466
|
+
{"v":1,"at":1791026130000,"event":"waiting","session":"…","project":"/work/api","tool":"Bash","status":"waiting"}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended`. Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field. Some examples:
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
# tmux: show the state in the status bar
|
|
473
|
+
set -g status-right '#(vibe --state | jq -r .status)'
|
|
474
|
+
|
|
475
|
+
# macOS: say it out loud when any agent needs you
|
|
476
|
+
vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | while read p; do say "$(basename "$p") needs you"; done
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
The stream is a local file (`~/.vibeaudio/events.jsonl`, rotated at 256 KB), so it never leaves your machine. It keeps updating while you're muted, because a mute silences sound and a light isn't sound. The `v` field is the format version, and any breaking change will increment it.
|
|
480
|
+
|
|
412
481
|
## 🖥️ Everything Else (Claude Desktop, Antigravity… via MCP)
|
|
413
482
|
|
|
414
483
|
**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.
|
|
@@ -587,6 +656,8 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
587
656
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
|
|
588
657
|
| `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
|
|
589
658
|
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
659
|
+
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
|
|
660
|
+
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
|
|
590
661
|
| `--stop` | Stop the background player, then exit | — |
|
|
591
662
|
| `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
|
|
592
663
|
| `--unmute` | Resume normal playback, then exit | — |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node test/test-synth.js",
|
|
12
12
|
"start": "node bin/vibeaudio.js",
|
|
13
|
+
"version": "node -e \"const f='.claude-plugin/plugin.json',j=require('./'+f);j.version=require('./package.json').version;require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
|
|
13
14
|
"postinstall": "node scripts/postinstall.js"
|
|
14
15
|
},
|
|
15
16
|
"publishConfig": {
|
package/src/cli.js
CHANGED
|
@@ -78,6 +78,8 @@ Procedural focus music while your AI coding tools think.
|
|
|
78
78
|
--status Show what is installed, running and detected, then exit
|
|
79
79
|
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
|
|
80
80
|
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
81
|
+
--state Print what every agent is doing as JSON: idle, working, stuck or waiting
|
|
82
|
+
--events Stream agent state changes as JSON lines, until stopped
|
|
81
83
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
82
84
|
--stop Stop the background player, then exit
|
|
83
85
|
--mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
|
|
@@ -174,6 +176,8 @@ function parseArgs(argv) {
|
|
|
174
176
|
let render = null;
|
|
175
177
|
let clearCacheFlag = false;
|
|
176
178
|
let statusFlag = false;
|
|
179
|
+
let stateFlag = false;
|
|
180
|
+
let eventsFlag = false;
|
|
177
181
|
let doctorFlag = false;
|
|
178
182
|
let notifyFlag = null;
|
|
179
183
|
let reportDays = null;
|
|
@@ -184,6 +188,7 @@ function parseArgs(argv) {
|
|
|
184
188
|
let mcp = false;
|
|
185
189
|
let reactive = false;
|
|
186
190
|
let followVolume = false;
|
|
191
|
+
let plugin = false;
|
|
187
192
|
let here_flag = false;
|
|
188
193
|
let dryRun = false;
|
|
189
194
|
let tools = null;
|
|
@@ -288,6 +293,13 @@ function parseArgs(argv) {
|
|
|
288
293
|
continue;
|
|
289
294
|
}
|
|
290
295
|
|
|
296
|
+
if (arg === "--state" || arg === "--events") {
|
|
297
|
+
if (arg === "--state") stateFlag = true;
|
|
298
|
+
else eventsFlag = true;
|
|
299
|
+
i += 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
291
303
|
if (arg === "--report") {
|
|
292
304
|
// Optional window in days, like --mute's minutes: `vibe --report 30`.
|
|
293
305
|
i += 1;
|
|
@@ -361,6 +373,14 @@ function parseArgs(argv) {
|
|
|
361
373
|
continue;
|
|
362
374
|
}
|
|
363
375
|
|
|
376
|
+
// Internal, set by the plugin's hooks.json: yield to hooks that
|
|
377
|
+
// --install-hooks already wrote, so both being present is not a double chime.
|
|
378
|
+
if (arg === "--plugin") {
|
|
379
|
+
plugin = true;
|
|
380
|
+
i += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
364
384
|
if (arg === "--dry-run") {
|
|
365
385
|
dryRun = true;
|
|
366
386
|
i += 1;
|
|
@@ -461,6 +481,8 @@ function parseArgs(argv) {
|
|
|
461
481
|
render,
|
|
462
482
|
clearCache: clearCacheFlag,
|
|
463
483
|
status: statusFlag,
|
|
484
|
+
state: stateFlag,
|
|
485
|
+
events: eventsFlag,
|
|
464
486
|
doctor: doctorFlag,
|
|
465
487
|
notify: notifyFlag,
|
|
466
488
|
report: reportDays,
|
|
@@ -471,6 +493,7 @@ function parseArgs(argv) {
|
|
|
471
493
|
mcp,
|
|
472
494
|
reactive,
|
|
473
495
|
followVolume,
|
|
496
|
+
plugin,
|
|
474
497
|
dryRun,
|
|
475
498
|
tools,
|
|
476
499
|
typed,
|
|
@@ -577,6 +600,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
|
|
|
577
600
|
if (events.resume) console.log(` ${events.resume[0].padEnd(19)}→ music resumes once you've answered`);
|
|
578
601
|
if (events.failure) console.log(` ${events.failure.padEnd(19)}→ music stops + failure chime (API error)`);
|
|
579
602
|
if (events.end) console.log(` ${events.end.padEnd(19)}→ music stops if that session started it`);
|
|
603
|
+
if (events.interrupt) console.log(` ${events.interrupt.padEnd(19)}→ music stops silently when you interrupt`);
|
|
580
604
|
|
|
581
605
|
if (id === "claude") {
|
|
582
606
|
const slash = hooks.installSlashCommand({ dryRun });
|
|
@@ -747,6 +771,10 @@ function printStatus() {
|
|
|
747
771
|
if (readVibeHooks(t.file(), t).some(({ command }) => /--genre /.test(command))) {
|
|
748
772
|
console.log(` \x1b[33m↑ pinned by an older install — vibe --install-hooks makes it follow Sound above\x1b[0m`);
|
|
749
773
|
}
|
|
774
|
+
const absent = hooks.expectedEvents(id).filter((e) => !installed.some(({ event }) => event === e));
|
|
775
|
+
if (absent.length) {
|
|
776
|
+
console.log(` \x1b[33m+ ${absent.join(", ")} added since this install — vibe --install-hooks picks them up\x1b[0m`);
|
|
777
|
+
}
|
|
750
778
|
}
|
|
751
779
|
|
|
752
780
|
// Background player
|
|
@@ -860,8 +888,11 @@ function doctorChecks() {
|
|
|
860
888
|
const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
861
889
|
for (const p of tokens.slice(0, 2)) if (!fs.existsSync(p)) missing.add(p);
|
|
862
890
|
}
|
|
891
|
+
const absent = hooks.expectedEvents(id).filter((e) => !installed.some(({ event }) => event === e));
|
|
863
892
|
if (missing.size) {
|
|
864
893
|
add("fail", label, `points at a file that no longer exists: ${[...missing].join(", ")}`, "vibe --install-hooks");
|
|
894
|
+
} else if (absent.length) {
|
|
895
|
+
add("warn", label, `installed by an older version, missing ${absent.join(", ")}`, "vibe --install-hooks");
|
|
865
896
|
} else if (installed.some(({ command }) => /--genre /.test(command))) {
|
|
866
897
|
add("warn", label, "pinned to a genre/volume by an older install, which overrides your saved settings", "vibe --install-hooks");
|
|
867
898
|
} else {
|
|
@@ -872,15 +903,20 @@ function doctorChecks() {
|
|
|
872
903
|
// config.toml under "<file>:<event>:<group>:<index>".
|
|
873
904
|
if (id === "codex") {
|
|
874
905
|
let toml = "";
|
|
875
|
-
try { toml = fs.readFileSync(path.join(
|
|
906
|
+
try { toml = fs.readFileSync(path.join(hooks.codexHome(), "config.toml"), "utf8"); } catch (e) { /* none yet */ }
|
|
876
907
|
const pending = [];
|
|
877
908
|
try {
|
|
878
909
|
const file = t.file();
|
|
910
|
+
// Codex canonicalizes CODEX_HOME before keying (find_codex_home()), so
|
|
911
|
+
// a symlinked one - /tmp on macOS - is recorded under its real path.
|
|
912
|
+
let real = file;
|
|
913
|
+
try { real = fs.realpathSync(file); } catch (e) { /* checked below */ }
|
|
879
914
|
for (const [event, entries] of Object.entries(JSON.parse(fs.readFileSync(file, "utf8")).hooks || {})) {
|
|
880
915
|
(entries || []).forEach((entry, g) => {
|
|
881
916
|
t.commands(entry).forEach((command, i) => {
|
|
882
|
-
const
|
|
883
|
-
|
|
917
|
+
const suffix = `:${event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()}:${g}:${i}`;
|
|
918
|
+
const trusted = [file, real].some((f) => toml.includes(`"${f}${suffix}"`));
|
|
919
|
+
if (hooks.VIBE_HOOK_FLAG.test(command || "") && !trusted) pending.push(event);
|
|
884
920
|
});
|
|
885
921
|
});
|
|
886
922
|
}
|
|
@@ -926,10 +962,12 @@ function formatDuration(ms) {
|
|
|
926
962
|
* The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
|
|
927
963
|
* working time - a turn's length minus the stretches it spent blocked on a
|
|
928
964
|
* dialog of yours - because the two are different complaints: one is the
|
|
929
|
-
* agent being slow, the other is you being away.
|
|
965
|
+
* agent being slow, the other is you being away. "Back to it" is the other
|
|
966
|
+
* direction: how long a finished turn waited for your next prompt, with gaps
|
|
967
|
+
* over RETURN_WINDOW_MS read as breaks and left out.
|
|
930
968
|
*/
|
|
931
969
|
function printReport(days, now = Date.now()) {
|
|
932
|
-
const { readTurns } = require("./history");
|
|
970
|
+
const { readTurns, returnTimes } = require("./history");
|
|
933
971
|
const turns = readTurns(days, now);
|
|
934
972
|
const dim = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
935
973
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -968,6 +1006,19 @@ function printReport(days, now = Date.now()) {
|
|
|
968
1006
|
row("Typical turn", formatDuration(median), "median");
|
|
969
1007
|
row("Longest turn", formatDuration(longest.ms), label(longest));
|
|
970
1008
|
|
|
1009
|
+
// How fast you came back once a turn was done - the human half of the loop.
|
|
1010
|
+
const middle = (xs) => xs.slice().sort((a, b) => a - b)[Math.floor(xs.length / 2)];
|
|
1011
|
+
const gaps = returnTimes(turns);
|
|
1012
|
+
if (gaps.length) {
|
|
1013
|
+
row("Back to it", formatDuration(middle(gaps.map((g) => g.ms))), `median from done to your next prompt, ${gaps.length} ${gaps.length === 1 ? "time" : "times"}`);
|
|
1014
|
+
// Only once both sides have enough to mean something; three turns is an anecdote.
|
|
1015
|
+
const withChime = gaps.filter((g) => g.chimed === true).map((g) => g.ms);
|
|
1016
|
+
const without = gaps.filter((g) => g.chimed === false).map((g) => g.ms);
|
|
1017
|
+
if (withChime.length >= 5 && without.length >= 5) {
|
|
1018
|
+
row("", `${formatDuration(middle(withChime))} with a chime`, `${formatDuration(middle(without))} without (muted or --no-chime)`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
971
1022
|
const byProject = new Map();
|
|
972
1023
|
for (const t of turns) {
|
|
973
1024
|
const p = byProject.get(label(t)) || { ms: 0, n: 0 };
|
|
@@ -1273,9 +1324,14 @@ function renderToFile(target, genre) {
|
|
|
1273
1324
|
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
1274
1325
|
}
|
|
1275
1326
|
|
|
1276
|
-
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, tools, dryRun, typed = {} }) {
|
|
1327
|
+
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed = {} }) {
|
|
1277
1328
|
const hooks = require("./hooks");
|
|
1278
1329
|
|
|
1330
|
+
// The plugin's hooks and --install-hooks' hooks are the same events: with both
|
|
1331
|
+
// present every prompt would restart the music and every turn chime twice.
|
|
1332
|
+
// The installed ones win because they carry the user's --reactive choice.
|
|
1333
|
+
if (plugin && action.startsWith("hook-") && hooks.userHooksInstalled()) return;
|
|
1334
|
+
|
|
1279
1335
|
switch (action) {
|
|
1280
1336
|
case "daemon":
|
|
1281
1337
|
return hooks.runDaemon(genre, volume, { reactive, volumeSource: followVolume ? volumeNow : null });
|
|
@@ -1488,6 +1544,8 @@ async function run() {
|
|
|
1488
1544
|
render,
|
|
1489
1545
|
clearCache: shouldClear,
|
|
1490
1546
|
status: showStatus,
|
|
1547
|
+
state: showState,
|
|
1548
|
+
events: followEvents,
|
|
1491
1549
|
doctor: showDoctor,
|
|
1492
1550
|
notify: notifyChange,
|
|
1493
1551
|
report: reportDays,
|
|
@@ -1498,6 +1556,7 @@ async function run() {
|
|
|
1498
1556
|
mcp,
|
|
1499
1557
|
reactive,
|
|
1500
1558
|
followVolume,
|
|
1559
|
+
plugin,
|
|
1501
1560
|
dryRun,
|
|
1502
1561
|
tools,
|
|
1503
1562
|
typed,
|
|
@@ -1515,7 +1574,7 @@ async function run() {
|
|
|
1515
1574
|
|
|
1516
1575
|
if (hookAction) {
|
|
1517
1576
|
try {
|
|
1518
|
-
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, tools, dryRun, typed });
|
|
1577
|
+
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed });
|
|
1519
1578
|
} catch (e) {
|
|
1520
1579
|
// Settings problems are the user's to fix — report them, don't stack-trace.
|
|
1521
1580
|
console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
|
|
@@ -1579,6 +1638,18 @@ async function run() {
|
|
|
1579
1638
|
return printDoctor();
|
|
1580
1639
|
}
|
|
1581
1640
|
|
|
1641
|
+
// Machine-readable, for lights, menu bars and status lines: see "Build on it" in the README.
|
|
1642
|
+
if (showState) {
|
|
1643
|
+
console.log(JSON.stringify(require("./hooks").agentState()));
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
if (followEvents) {
|
|
1648
|
+
process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
|
|
1649
|
+
require("./hooks").followEvents();
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1582
1653
|
if (reportDays !== null) {
|
|
1583
1654
|
return printReport(reportDays);
|
|
1584
1655
|
}
|
package/src/history.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A local log of finished turns - what `vibe --report` reads. One JSON object
|
|
3
3
|
* per line in ~/.vibeaudio/history.jsonl: when the turn ended, which project,
|
|
4
|
-
* how long it took, how much of that the agent spent blocked on you,
|
|
5
|
-
*
|
|
4
|
+
* how long it took, how much of that the agent spent blocked on you, how it
|
|
5
|
+
* ended, which session it was and whether a chime announced it. It never
|
|
6
|
+
* leaves the machine; VIBE_NO_HISTORY=1 turns it off.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
const fs = require("fs");
|
|
@@ -14,12 +15,12 @@ const MAX_BYTES = 1024 * 1024;
|
|
|
14
15
|
const KEEP_LINES = 4000;
|
|
15
16
|
|
|
16
17
|
/** Appends one turn. Never throws: a log is not worth failing a hook over. */
|
|
17
|
-
function recordTurn({ project, ms, blockedMs = 0, outcome = "success", at = Date.now() }, file = HISTORY_FILE) {
|
|
18
|
+
function recordTurn({ project, ms, blockedMs = 0, outcome = "success", session, chimed, at = Date.now() }, file = HISTORY_FILE) {
|
|
18
19
|
if (process.env.VIBE_NO_HISTORY) return false;
|
|
19
20
|
if (!Number.isFinite(ms) || ms < 0) return false;
|
|
20
21
|
try {
|
|
21
22
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
-
fs.appendFileSync(file, `${JSON.stringify({ at, project, ms, blockedMs: Math.min(Math.max(blockedMs, 0), ms), outcome })}\n`);
|
|
23
|
+
fs.appendFileSync(file, `${JSON.stringify({ at, project, ms, blockedMs: Math.min(Math.max(blockedMs, 0), ms), outcome, session, chimed })}\n`);
|
|
23
24
|
// Bounded, so a year of use is still a small file. Rewritten rarely, from
|
|
24
25
|
// the tail, so the newest turns are always the ones kept.
|
|
25
26
|
if (fs.statSync(file).size > MAX_BYTES) {
|
|
@@ -54,4 +55,34 @@ function readTurns(days, now = Date.now(), file = HISTORY_FILE) {
|
|
|
54
55
|
return turns;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
// A gap longer than this is a break - lunch, a meeting, the end of the day -
|
|
59
|
+
// not someone coming back to a finished turn, and would swamp the median.
|
|
60
|
+
const RETURN_WINDOW_MS = 30 * 60 * 1000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How long each finished turn sat before the next prompt in the same session:
|
|
64
|
+
* the time it took you to come back. A turn's start is `at - ms`. Sessions,
|
|
65
|
+
* not projects, are what pair up - two agents in one repo interleave - so a
|
|
66
|
+
* line from before sessions were logged (or an `anon` one) falls back to its
|
|
67
|
+
* project. `chimed` is the finished turn's, since that is the chime you were
|
|
68
|
+
* answering.
|
|
69
|
+
*/
|
|
70
|
+
function returnTimes(turns) {
|
|
71
|
+
const groups = new Map();
|
|
72
|
+
for (const t of turns) {
|
|
73
|
+
const key = t.session && t.session !== "anon" ? `s:${t.session}` : `p:${t.project}`;
|
|
74
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
75
|
+
groups.get(key).push(t);
|
|
76
|
+
}
|
|
77
|
+
const gaps = [];
|
|
78
|
+
for (const list of groups.values()) {
|
|
79
|
+
list.sort((a, b) => a.at - b.at);
|
|
80
|
+
for (let i = 1; i < list.length; i++) {
|
|
81
|
+
const ms = list[i].at - list[i].ms - list[i - 1].at;
|
|
82
|
+
if (ms >= 0 && ms <= RETURN_WINDOW_MS) gaps.push({ ms, chimed: list[i - 1].chimed });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return gaps;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { HISTORY_FILE, RETURN_WINDOW_MS, recordTurn, readTurns, returnTimes };
|
package/src/hooks.js
CHANGED
|
@@ -25,6 +25,7 @@ const INTENSITY_FILE = path.join(STATE_DIR, "intensity");
|
|
|
25
25
|
// them is working; each holds where its transcript began and, while paused for
|
|
26
26
|
// the user, what will resume it.
|
|
27
27
|
const SESSIONS_DIR = path.join(STATE_DIR, "sessions");
|
|
28
|
+
const EVENTS_FILE = path.join(STATE_DIR, "events.jsonl");
|
|
28
29
|
// Payloads that name no session share this one slot.
|
|
29
30
|
const ANON_SESSION = "anon";
|
|
30
31
|
const CLI_ENTRY = path.join(__dirname, "..", "bin", "vibeaudio.js");
|
|
@@ -152,15 +153,35 @@ const MAX_DAEMON_MS = 15 * 60 * 1000;
|
|
|
152
153
|
* - failure: a turn ending in error *instead of* the stop event
|
|
153
154
|
* - end: the session closing, which can cut a turn off before stop
|
|
154
155
|
*
|
|
156
|
+
* - interrupt: the user stopped the turn (Esc), a silent end like `end`
|
|
157
|
+
*
|
|
155
158
|
* `seed` is the root object to write when the file does not exist yet. Cursor
|
|
156
159
|
* and Copilot require a schema version; Codex rejects unknown root keys
|
|
157
160
|
* outright, so nothing may be added there beyond `hooks`.
|
|
158
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* Where Claude Code and Codex keep their config. Both move with an env var,
|
|
164
|
+
* and a user who sets one has hooks read from there only: writing to the
|
|
165
|
+
* default path left VibeAudio installed in a file nothing read, and silent.
|
|
166
|
+
* Claude Code: `CLAUDE_CONFIG_DIR ?? ~/.claude`, NFC-normalized (cn() in
|
|
167
|
+
* 2.1.195's bundle; settings.json and commands/ both live under it).
|
|
168
|
+
* Codex: `CODEX_HOME`, empty treated as unset, else `~/.codex`
|
|
169
|
+
* (find_codex_home(), 0.160 source). Empty is unset for both here, so an
|
|
170
|
+
* exported-but-blank variable cannot turn the path relative.
|
|
171
|
+
*/
|
|
172
|
+
function claudeConfigDir() {
|
|
173
|
+
return (process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")).normalize("NFC");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function codexHome() {
|
|
177
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
178
|
+
}
|
|
179
|
+
|
|
159
180
|
const TARGETS = {
|
|
160
181
|
claude: {
|
|
161
182
|
name: "Claude Code",
|
|
162
183
|
cmd: "claude",
|
|
163
|
-
file: () => path.join(
|
|
184
|
+
file: () => path.join(claudeConfigDir(), "settings.json"),
|
|
164
185
|
// - wait: PermissionRequest fires when the dialog is shown in the terminal,
|
|
165
186
|
// the SDK (desktop app, IDEs) and print mode alike - Notification's
|
|
166
187
|
// permission_prompt is raised by the terminal UI alone, after 6s idle.
|
|
@@ -181,17 +202,23 @@ const TARGETS = {
|
|
|
181
202
|
codex: {
|
|
182
203
|
name: "Codex",
|
|
183
204
|
cmd: "codex",
|
|
184
|
-
file: () => path.join(
|
|
205
|
+
file: () => path.join(codexHome(), "hooks.json"),
|
|
185
206
|
// PermissionRequest runs only when Codex is about to ask for approval,
|
|
186
|
-
// with tool_name, and is present in 0.125's binary.
|
|
187
|
-
//
|
|
188
|
-
//
|
|
207
|
+
// with tool_name, and is present in 0.125's binary. SessionEnd arrived in
|
|
208
|
+
// 0.145 and Interrupt in 0.150 (source of each release tag). Listing them
|
|
209
|
+
// is safe on older versions: HookEventsToml has never denied unknown
|
|
210
|
+
// fields (checked 0.125 through 0.160), so an older Codex ignores an
|
|
211
|
+
// event it does not know - only the file's root is strict. Interrupt runs
|
|
212
|
+
// on the turn-abort path, not the one that runs Stop, with session_id,
|
|
213
|
+
// cwd and transcript_path.
|
|
189
214
|
events: {
|
|
190
215
|
start: "UserPromptSubmit",
|
|
191
216
|
stop: "Stop",
|
|
192
217
|
tool: "PreToolUse",
|
|
193
218
|
wait: ["PermissionRequest"],
|
|
194
|
-
resume: ["PostToolUse"]
|
|
219
|
+
resume: ["PostToolUse"],
|
|
220
|
+
interrupt: "Interrupt",
|
|
221
|
+
end: "SessionEnd"
|
|
195
222
|
},
|
|
196
223
|
entry: (command) => ({ hooks: [{ type: "command", command, timeout: 5 }] }),
|
|
197
224
|
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
@@ -345,6 +372,18 @@ function detectTargets() {
|
|
|
345
372
|
});
|
|
346
373
|
}
|
|
347
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Every event an install by this version writes for a target, `tool` aside
|
|
377
|
+
* (reactive mode only). A hook file is written once and outlives upgrades,
|
|
378
|
+
* so an event added in a later version - Codex's Interrupt and SessionEnd in
|
|
379
|
+
* 0.13.1 - never reaches someone who installed before it unless they
|
|
380
|
+
* reinstall. `--doctor` and `--status` compare against this to say so.
|
|
381
|
+
*/
|
|
382
|
+
function expectedEvents(id) {
|
|
383
|
+
const ev = TARGETS[id].events;
|
|
384
|
+
return [ev.start, ev.stop, ...(ev.wait || []), ...(ev.resume || []), ev.failure, ev.end, ev.interrupt].filter(Boolean);
|
|
385
|
+
}
|
|
386
|
+
|
|
348
387
|
function settingsPath() {
|
|
349
388
|
return TARGETS.claude.file();
|
|
350
389
|
}
|
|
@@ -452,7 +491,7 @@ function newTurn(raw) {
|
|
|
452
491
|
const payload = parsePayload(raw);
|
|
453
492
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
454
493
|
const offset = transcript ? fileSize(transcript) : 0;
|
|
455
|
-
return { session: String(payloadSession(payload) || ""), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
|
|
494
|
+
return { session: String(payloadSession(payload) || ""), project: payload.cwd || process.cwd(), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
|
|
456
495
|
}
|
|
457
496
|
|
|
458
497
|
const BLOCK_LOOKBACK_MS = 3000;
|
|
@@ -545,6 +584,102 @@ function listSessions() {
|
|
|
545
584
|
// Working, as opposed to paused for the user.
|
|
546
585
|
const working = (sessions) => sessions.filter((s) => s.waiting == null);
|
|
547
586
|
|
|
587
|
+
// "Stuck": STUCK_FAILURES of a session's last STUCK_WINDOW tool calls failed.
|
|
588
|
+
// Measured on 4,497 real Claude Code turns (46,032 tool calls, 3.8% failing):
|
|
589
|
+
// this fires in 0.9% of turns, at minute 3 by the median, and those turns ran
|
|
590
|
+
// a median 3.3 minutes more - time someone could have stepped in. "3 of 8"
|
|
591
|
+
// fired in 2.4%, and "3 in a row" misses the usual loop, where an edit that
|
|
592
|
+
// succeeds sits between every failing test run. Rare is the point: a signal
|
|
593
|
+
// that fires often is one people learn to tune out.
|
|
594
|
+
const STUCK_WINDOW = 8;
|
|
595
|
+
const STUCK_FAILURES = 4;
|
|
596
|
+
const isStuck = (session) => (session.recent || []).filter(Boolean).length >= STUCK_FAILURES;
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The public face of the session files: what every agent on the machine is
|
|
600
|
+
* doing, in five words that mean the same thing whichever agent it is. The
|
|
601
|
+
* hard part of this project is mapping eight agents' hook events onto those
|
|
602
|
+
* states; the music is one consumer of them, and `vibe --state` / `--events`
|
|
603
|
+
* let anything else be another - a light, a menu bar, a tmux status line.
|
|
604
|
+
* `status` is the machine's: the most urgent of its sessions, since one
|
|
605
|
+
* session waiting on you matters more than three working.
|
|
606
|
+
*/
|
|
607
|
+
const STATE_RANK = ["idle", "working", "stuck", "waiting"];
|
|
608
|
+
const sessionState = (s) => (s.waiting != null ? "waiting" : isStuck(s) ? "stuck" : "working");
|
|
609
|
+
|
|
610
|
+
function agentState(sessions = listSessions()) {
|
|
611
|
+
const list = sessions.map((s) => ({
|
|
612
|
+
session: s.id,
|
|
613
|
+
project: s.project || null,
|
|
614
|
+
state: sessionState(s),
|
|
615
|
+
since: s.started || null,
|
|
616
|
+
...(s.waiting ? { tool: s.waiting } : {})
|
|
617
|
+
}));
|
|
618
|
+
const status = list.reduce((a, s) => (STATE_RANK.indexOf(s.state) > STATE_RANK.indexOf(a) ? s.state : a), "idle");
|
|
619
|
+
return { v: 1, status, sessions: list };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const EVENTS_MAX_BYTES = 256 * 1024;
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Appends one transition to ~/.vibeaudio/events.jsonl, after the session file
|
|
626
|
+
* already reflects it, so `status` is the state the event left behind. Not
|
|
627
|
+
* gated by a mute: a mute silences sound, and a light watching this is not
|
|
628
|
+
* sound. Rotated to `.1` rather than trimmed in place, so a follower sees a
|
|
629
|
+
* fresh file instead of re-reading the lines a trim kept. Never throws.
|
|
630
|
+
*/
|
|
631
|
+
function emitEvent(event, session, project, extra = {}) {
|
|
632
|
+
try {
|
|
633
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
634
|
+
try {
|
|
635
|
+
if (fs.statSync(EVENTS_FILE).size > EVENTS_MAX_BYTES) fs.renameSync(EVENTS_FILE, `${EVENTS_FILE}.1`);
|
|
636
|
+
} catch (e) { /* no file yet */ }
|
|
637
|
+
const line = { v: 1, at: Date.now(), event, session, project: project || null, ...extra, status: agentState().status };
|
|
638
|
+
fs.appendFileSync(EVENTS_FILE, `${JSON.stringify(line)}\n`); // One write under PIPE_BUF: appends do not interleave.
|
|
639
|
+
return true;
|
|
640
|
+
} catch (e) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* `vibe --events`: the current state as one line, then every event as it is
|
|
647
|
+
* written, until killed. Polled rather than fs.watch'd, which is unreliable on
|
|
648
|
+
* the platforms this runs on; a quarter second is well inside a turn.
|
|
649
|
+
*/
|
|
650
|
+
function followEvents(write = (s) => process.stdout.write(s), pollMs = 250) {
|
|
651
|
+
write(`${JSON.stringify({ event: "state", at: Date.now(), ...agentState() })}\n`);
|
|
652
|
+
const stat = () => {
|
|
653
|
+
try {
|
|
654
|
+
const st = fs.statSync(EVENTS_FILE);
|
|
655
|
+
return { size: st.size, ino: st.ino };
|
|
656
|
+
} catch (e) {
|
|
657
|
+
return { size: 0, ino: null };
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
let { size: pos, ino } = stat();
|
|
661
|
+
let partial = "";
|
|
662
|
+
return setInterval(() => {
|
|
663
|
+
const now = stat();
|
|
664
|
+
if (now.ino !== ino || now.size < pos) {
|
|
665
|
+
ino = now.ino; // Rotated: the new file holds only what came after.
|
|
666
|
+
pos = 0;
|
|
667
|
+
partial = "";
|
|
668
|
+
}
|
|
669
|
+
if (now.size <= pos) return;
|
|
670
|
+
try {
|
|
671
|
+
const fd = fs.openSync(EVENTS_FILE, "r");
|
|
672
|
+
const buf = Buffer.alloc(now.size - pos);
|
|
673
|
+
fs.readSync(fd, buf, 0, buf.length, pos);
|
|
674
|
+
fs.closeSync(fd);
|
|
675
|
+
pos = now.size;
|
|
676
|
+
const lines = (partial + buf.toString("utf8")).split("\n");
|
|
677
|
+
partial = lines.pop();
|
|
678
|
+
for (const l of lines) if (l) write(`${l}\n`);
|
|
679
|
+
} catch (e) { /* removed between stat and open: next poll */ }
|
|
680
|
+
}, pollMs);
|
|
681
|
+
}
|
|
682
|
+
|
|
548
683
|
// Claude Code's entry for Esc / the stop button: a user message whose text is
|
|
549
684
|
// "[Request interrupted by user]" or "... for tool use]".
|
|
550
685
|
const INTERRUPT_MARK = "[Request interrupted by user";
|
|
@@ -627,6 +762,7 @@ function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}
|
|
|
627
762
|
if (watchers.get(s.id)()) {
|
|
628
763
|
fs.rmSync(sessionFile(s.id), { force: true });
|
|
629
764
|
watchers.delete(s.id);
|
|
765
|
+
emitEvent("interrupted", s.id, s.project);
|
|
630
766
|
}
|
|
631
767
|
}
|
|
632
768
|
return working(listSessions()).length > 0;
|
|
@@ -648,7 +784,9 @@ function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}
|
|
|
648
784
|
// The same piece with more of it, as time escalation already does: two
|
|
649
785
|
// sessions working is tier 2 at least, three or more is tier 3. Read at
|
|
650
786
|
// each loop boundary, so it lands cleanly and eases off as they finish.
|
|
651
|
-
minTier: () => Math.min(3, working(listSessions()).length)
|
|
787
|
+
minTier: () => Math.min(3, working(listSessions()).length),
|
|
788
|
+
// A heartbeat under the music while any working session looks stuck.
|
|
789
|
+
tension: () => working(listSessions()).some(isStuck)
|
|
652
790
|
});
|
|
653
791
|
if (!started) process.exit(0);
|
|
654
792
|
|
|
@@ -697,7 +835,9 @@ function hookStart(genre, volume, { reactive = false, turn = null, follow = fals
|
|
|
697
835
|
if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
|
|
698
836
|
const id = sessionId(turn && turn.session);
|
|
699
837
|
// Before the spawn: the daemon reads it on startup.
|
|
700
|
-
|
|
838
|
+
const project = (turn && turn.project) || process.cwd();
|
|
839
|
+
writeSession(id, { project, transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
|
|
840
|
+
emitEvent("started", id, project);
|
|
701
841
|
if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
|
|
702
842
|
|
|
703
843
|
stopDaemon({ keepSessions: true });
|
|
@@ -809,11 +949,14 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
|
|
|
809
949
|
const session = readSession(id);
|
|
810
950
|
const tracked = session !== null;
|
|
811
951
|
fs.rmSync(sessionFile(id), { force: true });
|
|
952
|
+
if (tracked) emitEvent("finished", id, parsePayload(raw).cwd || session.project, { outcome });
|
|
812
953
|
if (session && Number.isFinite(session.started)) {
|
|
813
954
|
const now = Date.now();
|
|
814
955
|
// A turn that ends while still paused for you has been blocked since then.
|
|
815
956
|
const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
|
|
816
|
-
|
|
957
|
+
// ponytail: assumes a playback backend exists; a machine with none hears no music either.
|
|
958
|
+
const chimed = !noChime && !playbackDisabled();
|
|
959
|
+
recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
|
|
817
960
|
}
|
|
818
961
|
|
|
819
962
|
// The music is every working session's, so it ends with the last of them.
|
|
@@ -873,6 +1016,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
873
1016
|
if (!session || session.waiting != null) return false;
|
|
874
1017
|
|
|
875
1018
|
writeSession(id, { ...session, waiting: waitKey(raw), waitStart: Date.now() });
|
|
1019
|
+
emitEvent("waiting", id, session.project, waitKey(raw) ? { tool: waitKey(raw) } : {});
|
|
876
1020
|
if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
|
|
877
1021
|
const tool = payloadToolName(raw);
|
|
878
1022
|
notify(raw, tool ? `needs you (${tool})` : "needs you");
|
|
@@ -894,15 +1038,34 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
894
1038
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
895
1039
|
*/
|
|
896
1040
|
function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
|
|
897
|
-
const
|
|
1041
|
+
const payload = parsePayload(raw);
|
|
1042
|
+
const id = sessionId(payloadSession(payload));
|
|
898
1043
|
const session = readSession(id);
|
|
899
|
-
if (!session
|
|
1044
|
+
if (!session) return false;
|
|
1045
|
+
|
|
1046
|
+
// Every tool call lands here, so this is where the stuck signal is kept.
|
|
1047
|
+
// An interrupt is the user stopping a tool, not the tool failing.
|
|
1048
|
+
// ponytail: two parallel tool calls in one session can each read, then write,
|
|
1049
|
+
// and one result is lost. A heuristic over eight calls survives that.
|
|
1050
|
+
const failed = payload.hook_event_name === "PostToolUseFailure" && !payload.is_interrupt;
|
|
1051
|
+
const recent = [...(session.recent || []), failed ? 1 : 0].slice(-STUCK_WINDOW);
|
|
1052
|
+
const next = { ...session, recent };
|
|
1053
|
+
const crossing = isStuck(next) === isStuck(session) ? null : isStuck(next) ? "stuck" : "recovered";
|
|
1054
|
+
if (crossing === "stuck") notify(raw, `looks stuck - ${STUCK_FAILURES} of its last ${STUCK_WINDOW} tool calls failed`);
|
|
1055
|
+
|
|
900
1056
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
901
1057
|
// to finish is the first sign of work carrying on.
|
|
902
|
-
|
|
1058
|
+
const resumes = session.waiting != null && (session.waiting === "" || session.waiting === waitKey(raw));
|
|
1059
|
+
if (!resumes) {
|
|
1060
|
+
writeSession(id, next);
|
|
1061
|
+
if (crossing) emitEvent(crossing, id, session.project);
|
|
1062
|
+
return false; // Not waiting - the common case, on every tool call.
|
|
1063
|
+
}
|
|
903
1064
|
|
|
904
1065
|
const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
|
|
905
|
-
writeSession(id, { ...
|
|
1066
|
+
writeSession(id, { ...next, waiting: null, waitStart: null, blockedMs });
|
|
1067
|
+
emitEvent("resumed", id, session.project);
|
|
1068
|
+
if (crossing) emitEvent(crossing, id, session.project);
|
|
906
1069
|
if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
|
|
907
1070
|
return true;
|
|
908
1071
|
}
|
|
@@ -918,9 +1081,11 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
|
|
|
918
1081
|
*/
|
|
919
1082
|
function hookEnd(raw) {
|
|
920
1083
|
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
921
|
-
|
|
1084
|
+
const session = readSession(id);
|
|
1085
|
+
if (!session) return false;
|
|
922
1086
|
|
|
923
1087
|
fs.rmSync(sessionFile(id), { force: true });
|
|
1088
|
+
emitEvent(parsePayload(raw).hook_event_name === "Interrupt" ? "interrupted" : "ended", id, session.project);
|
|
924
1089
|
if (working(listSessions()).length === 0) {
|
|
925
1090
|
stopDaemon({ keepSessions: true });
|
|
926
1091
|
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
@@ -1002,6 +1167,11 @@ function readVibeEntryCount(file, id) {
|
|
|
1002
1167
|
}
|
|
1003
1168
|
}
|
|
1004
1169
|
|
|
1170
|
+
/** True when --install-hooks has already written our entries for Claude Code. */
|
|
1171
|
+
function userHooksInstalled(file = null) {
|
|
1172
|
+
return readVibeEntryCount(file || TARGETS.claude.file(), "claude") > 0;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1005
1175
|
function loadSettings(file, t = null) {
|
|
1006
1176
|
if (!fs.existsSync(file)) return { settings: t ? t.seed() : {}, raw: null };
|
|
1007
1177
|
const raw = fs.readFileSync(file, "utf8");
|
|
@@ -1114,6 +1284,8 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
|
|
|
1114
1284
|
}
|
|
1115
1285
|
if (ev.failure) setHook(settings.hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
|
|
1116
1286
|
if (ev.end) setHook(settings.hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
|
|
1287
|
+
// An interrupt ends the turn the same silent way a closed session does.
|
|
1288
|
+
if (ev.interrupt) setHook(settings.hooks, ev.interrupt, hookCommand("--hook-end", genre, volume), id);
|
|
1117
1289
|
|
|
1118
1290
|
const after = `${JSON.stringify(settings, null, 2)}\n`;
|
|
1119
1291
|
if (!dryRun) fs.writeFileSync(file, after);
|
|
@@ -1129,7 +1301,7 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
|
|
|
1129
1301
|
const SLASH_MARK = "<!-- vibeaudio:slash-command -->";
|
|
1130
1302
|
|
|
1131
1303
|
function slashCommandFile() {
|
|
1132
|
-
return path.join(
|
|
1304
|
+
return path.join(claudeConfigDir(), "commands", "vibe.md");
|
|
1133
1305
|
}
|
|
1134
1306
|
|
|
1135
1307
|
function slashCommandText() {
|
|
@@ -1208,12 +1380,20 @@ function uninstallHooks(file = null, { id = "claude" } = {}) {
|
|
|
1208
1380
|
|
|
1209
1381
|
module.exports = {
|
|
1210
1382
|
shellQuote,
|
|
1383
|
+
claudeConfigDir,
|
|
1384
|
+
codexHome,
|
|
1385
|
+
expectedEvents,
|
|
1211
1386
|
runDaemon,
|
|
1212
1387
|
hookStart,
|
|
1213
1388
|
hookStop,
|
|
1214
1389
|
hookTool,
|
|
1215
1390
|
hookWait,
|
|
1216
1391
|
hookResume,
|
|
1392
|
+
isStuck,
|
|
1393
|
+
STUCK_WINDOW,
|
|
1394
|
+
agentState,
|
|
1395
|
+
followEvents,
|
|
1396
|
+
EVENTS_FILE,
|
|
1217
1397
|
hookEnd,
|
|
1218
1398
|
newTurn,
|
|
1219
1399
|
outcomeFromPayload,
|
|
@@ -1231,6 +1411,7 @@ module.exports = {
|
|
|
1231
1411
|
uninstallHooks,
|
|
1232
1412
|
settingsPath,
|
|
1233
1413
|
isVibeHook,
|
|
1414
|
+
userHooksInstalled,
|
|
1234
1415
|
hookEntries,
|
|
1235
1416
|
installSlashCommand,
|
|
1236
1417
|
uninstallSlashCommand,
|
package/src/player.js
CHANGED
|
@@ -20,6 +20,7 @@ const { generatePianoLoop } = require("./synth/piano");
|
|
|
20
20
|
const { generateJazzLoop } = require("./synth/jazz");
|
|
21
21
|
const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
22
22
|
const { hashString } = require("./synth/generator");
|
|
23
|
+
const { addTension } = require("./synth/tension");
|
|
23
24
|
const pkg = require("../package.json");
|
|
24
25
|
|
|
25
26
|
const CACHE_ROOT = path.join(os.homedir(), ".vibeaudio", "cache");
|
|
@@ -617,7 +618,7 @@ function writeCacheFileAtomic(filePath, buffer) {
|
|
|
617
618
|
}
|
|
618
619
|
}
|
|
619
620
|
|
|
620
|
-
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0) {
|
|
621
|
+
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0, tension = false) {
|
|
621
622
|
ensureCacheDir();
|
|
622
623
|
const normalizedGenre = resolveGenre(genre);
|
|
623
624
|
const safeTier = Math.max(1, Math.min(3, tier));
|
|
@@ -629,11 +630,14 @@ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0)
|
|
|
629
630
|
// into this opens on exactly the bar it has always opened on.
|
|
630
631
|
const barSuffix = safeBar === 0 ? "" : `_b${safeBar}`;
|
|
631
632
|
const dir = seedDir(seed);
|
|
632
|
-
|
|
633
|
+
// The stuck heartbeat is its own file, rendered only once an agent is stuck.
|
|
634
|
+
const tensionSuffix = tension ? "_x" : "";
|
|
635
|
+
const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${barSuffix}${tensionSuffix}${gainSuffix(gain)}.wav`);
|
|
633
636
|
|
|
634
637
|
if (!fs.existsSync(filePath)) {
|
|
635
638
|
fs.mkdirSync(dir, { recursive: true });
|
|
636
|
-
|
|
639
|
+
const loop = generateLoop(normalizedGenre, safeTier, seed >>> 0, safeBar);
|
|
640
|
+
writeCacheFileAtomic(filePath, applyGain(tension ? addTension(loop, GENRE_KEYS[normalizedGenre]) : loop, gain));
|
|
637
641
|
pruneSeedDirs();
|
|
638
642
|
}
|
|
639
643
|
|
|
@@ -728,6 +732,7 @@ class AudioPlayer {
|
|
|
728
732
|
this.seed = projectSeed();
|
|
729
733
|
this.intensity = null;
|
|
730
734
|
this.minTier = null;
|
|
735
|
+
this.tension = null;
|
|
731
736
|
this.currentTier = 1;
|
|
732
737
|
this.bar = 0;
|
|
733
738
|
this.nextTimer = null;
|
|
@@ -738,7 +743,7 @@ class AudioPlayer {
|
|
|
738
743
|
* Returns true if playback actually started. Restarts when called with
|
|
739
744
|
* different settings while playing, so a genre switch is not silently dropped.
|
|
740
745
|
*/
|
|
741
|
-
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null } = {}) {
|
|
746
|
+
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null } = {}) {
|
|
742
747
|
const resolved = resolveGenre(genre);
|
|
743
748
|
const targetVolume = Math.max(0.05, Math.min(1.0, volume));
|
|
744
749
|
|
|
@@ -762,6 +767,7 @@ class AudioPlayer {
|
|
|
762
767
|
this.bar = 0; // Every run opens on the project's own bar.
|
|
763
768
|
this.intensity = intensity;
|
|
764
769
|
this.minTier = minTier;
|
|
770
|
+
this.tension = tension;
|
|
765
771
|
|
|
766
772
|
installExitHook();
|
|
767
773
|
activePlayers.add(this);
|
|
@@ -800,7 +806,8 @@ class AudioPlayer {
|
|
|
800
806
|
// never both, or the volume would be applied twice.
|
|
801
807
|
const backend = detectPlayer();
|
|
802
808
|
const gain = bakedGain(backend, this.volume);
|
|
803
|
-
|
|
809
|
+
// An agent that looks stuck adds a heartbeat, landing at this loop boundary.
|
|
810
|
+
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar, Boolean(this.tension && this.tension()));
|
|
804
811
|
// Advanced after the choice, so the bar that plays first is bar 0.
|
|
805
812
|
this.bar = (this.bar + 1) % LOOP_BARS;
|
|
806
813
|
if (!(helperUsable(backend) && this.playViaHelper(audioFile, backend))) this.spawnLoop(audioFile, backend);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "stuck" layer: a soft heartbeat under the loop, played while an agent's
|
|
3
|
+
* recent tool calls keep failing. More of the same music would say "working
|
|
4
|
+
* hard", which is not the news; a pulse that was not there before changes the
|
|
5
|
+
* music's character, and a heartbeat is the most widely read tension cue there
|
|
6
|
+
* is. Pitched on the genre's tonic so it sits in the key rather than against it.
|
|
7
|
+
*
|
|
8
|
+
* Pure like every other synth module: a WAV buffer in, a new one out.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { noteToFreq, SAMPLE_RATE } = require("./generator");
|
|
12
|
+
|
|
13
|
+
const BEAT_S = 1.1; // About 55 bpm: a resting heart, not a racing one.
|
|
14
|
+
const DUB_DELAY_S = 0.24;
|
|
15
|
+
const EDGE_S = 0.15; // Clear of the loop's boundary fades, so seams stay clean.
|
|
16
|
+
const LEVEL = 0.22;
|
|
17
|
+
|
|
18
|
+
// Laptop speakers roll off below ~120 Hz, and a pulse nobody hears is no
|
|
19
|
+
// signal, so the tonic sits between 110 and 220 Hz with a second harmonic.
|
|
20
|
+
function pulseFreq(key) {
|
|
21
|
+
const tonic = String(key || "A minor").split(" ")[0];
|
|
22
|
+
let f = noteToFreq(`${tonic}3`);
|
|
23
|
+
while (f > 220) f /= 2;
|
|
24
|
+
while (f < 110) f *= 2;
|
|
25
|
+
return f;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One thump: a short pitch drop, like a soft kick, decaying in ~0.2s.
|
|
29
|
+
function thump(t, f) {
|
|
30
|
+
if (t < 0 || t > 0.35) return 0;
|
|
31
|
+
const phase = f * t + (f * 0.5 * 0.04) * (1 - Math.exp(-t / 0.04)); // f*1.5 falling to f
|
|
32
|
+
const env = Math.min(1, t / 0.004) * Math.exp(-t / 0.075);
|
|
33
|
+
return env * (Math.sin(2 * Math.PI * phase) + 0.45 * Math.sin(4 * Math.PI * phase));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mixes the heartbeat into a canonical 16-bit WAV (mono or stereo) and returns a new buffer. */
|
|
37
|
+
function addTension(wav, key) {
|
|
38
|
+
if (wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF") {
|
|
39
|
+
throw new Error("addTension expects a canonical 44-byte-header WAV");
|
|
40
|
+
}
|
|
41
|
+
const channels = wav.readUInt16LE(22);
|
|
42
|
+
const rate = wav.readUInt32LE(24) || SAMPLE_RATE;
|
|
43
|
+
const frames = Math.floor((wav.length - 44) / (2 * channels));
|
|
44
|
+
const duration = frames / rate;
|
|
45
|
+
const f = pulseFreq(key);
|
|
46
|
+
|
|
47
|
+
// A whole number of beats per loop, so the pulse keeps time across loops.
|
|
48
|
+
const beats = Math.max(1, Math.round((duration - 2 * EDGE_S) / BEAT_S));
|
|
49
|
+
const period = (duration - 2 * EDGE_S) / beats;
|
|
50
|
+
const out = Buffer.from(wav);
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < frames; i++) {
|
|
53
|
+
const t = i / rate - EDGE_S;
|
|
54
|
+
if (t < 0 || t > duration - 2 * EDGE_S) continue;
|
|
55
|
+
const local = t % period;
|
|
56
|
+
const s = LEVEL * (thump(local, f) + 0.7 * thump(local - DUB_DELAY_S, f));
|
|
57
|
+
if (s === 0) continue;
|
|
58
|
+
for (let c = 0; c < channels; c++) {
|
|
59
|
+
const offset = 44 + (i * channels + c) * 2;
|
|
60
|
+
const mixed = Math.round(out.readInt16LE(offset) + s * 32767);
|
|
61
|
+
out.writeInt16LE(Math.max(-32768, Math.min(32767, mixed)), offset);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { addTension, pulseFreq };
|