vibeaudio 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -139,7 +139,7 @@ vibe npm test
139
139
  vibe claude -p "explain this repo"
140
140
  ```
141
141
 
142
- **Lost?** `vibe --help` lists every flag, and `vibe --status` reads your live setup back to you — what's installed, what's playing, and which agents it found.
142
+ **Lost?** `vibe --help` lists every flag, and `vibe --status` reads your live setup back to you — what's installed, what's playing, and which agents it found. If something seems broken, `vibe --doctor` checks it and prints the fix for each problem.
143
143
 
144
144
  > **Which one you want:** the wrapper plays music for as long as the wrapped process lives. That's exactly right for a command that exits when its work is done — and wrong for an interactive REPL like `claude`, where the process stays alive while you read and type, so the music never stops. Hooks know when the agent is actually thinking; the wrapper can only time the process.
145
145
 
@@ -289,6 +289,8 @@ Every session of an agent is tracked separately, by the session id in its hook p
289
289
  * **The music plays while any session is working.** A second prompt doesn't restart it, and it stops only when the last working session finishes.
290
290
  * **Every session gets its own chime.** A quick question that finishes while another agent is still busy chimes "done" and the music carries on underneath.
291
291
 
292
+ * **Which one?** With three terminals going, a chime says something finished and leaves you alt-tabbing to find out what. `vibe --notify` adds a desktop banner — `api: finished`, `web: needs you (Bash)` — named after the project the session runs in. Off by default, since a banner is more intrusive than a sound; macOS (`osascript`) and Linux (`notify-send`, needs a notification daemon), nothing on Windows yet. A mute silences the banner too.
293
+
292
294
  <details>
293
295
  <summary><b>The rest of how sessions share one stream</b></summary>
294
296
 
@@ -458,7 +460,7 @@ VIBE_VOLUME = "25"
458
460
  Restart the app afterwards — this one is read from the environment the server was launched with, so unlike the saved default it can't change under a running client.
459
461
 
460
462
  #### Exposed MCP Tools:
461
- * `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `random`; `volume`: `5-100`).
463
+ * `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`).
462
464
  * `vibe_stop`: Stop music and play the completion chime (`outcome`: `success` or `failure`).
463
465
  * `vibe_status`: Return current playback state and active tier.
464
466
 
@@ -480,7 +482,9 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
480
482
  | `zen` | 🎋 **Zen Ambient** | Meditative Tibetan singing bowls & celestial drone (zero rhythm) |
481
483
  | `piano` | 🎹 **Sparse Piano** | Single struck notes and long silences — Satie-ish |
482
484
  | `drone` | 🌫️ **Deep Drone** | A held tone and filtered noise — **no melody at all** |
483
- | `random` | 🎲 **Shuffle Mode** | Picks a surprise genre for the run — **never `drone`** |
485
+ | `rain` | 🌧️ **Rain** | Band-passed rainfall with sparse droplets — **no melody at all** |
486
+ | `ocean` | 🌊 **Ocean** | Low surf on a slow swell that rises and drains — **no melody at all** |
487
+ | `random` | 🎲 **Shuffle Mode** | Picks a surprise genre for the run — **never `drone`, `rain` or `ocean`** |
484
488
 
485
489
  **Aliases also work**, so you can ask for a genre the way you'd say it — `vibe --preview chill` is `lofi`:
486
490
 
@@ -494,14 +498,16 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
494
498
  | `zen` | `ambient`, `calm`, `meditation` |
495
499
  | `piano` | `sparse`, `satie`, `keys`, `minimal` |
496
500
  | `drone` | `noise`, `focus`, `hum`, `whitenoise`, `white-noise` |
501
+ | `rain` | `storm`, `drizzle` |
502
+ | `ocean` | `waves`, `sea`, `surf` |
497
503
 
498
504
  Matching is case-insensitive, so `BOSSA` works too.
499
505
 
500
- > **If any melody distracts you, use `drone`.** Every other genre plays something — notes, a progression, a bass line — and some people can't read while that happens. `drone` holds one low tone under a slow-breathing noise bed and never moves: closer to a fan or rainfall than to music. Tiers add weight rather than movement.
506
+ > **If any melody distracts you, use `drone`.** Every other genre plays something — notes, a progression, a bass line — and some people can't read while that happens. `drone` holds one low tone under a slow-breathing noise bed and never moves: closer to a fan or rainfall than to music. Tiers add weight rather than movement. If you want the fan-and-rainfall idea literally, `rain` and `ocean` are synthesized the same way (seeded noise, no recorded files): rain adds more droplets as the turn runs longer, ocean adds a second swell and then foam.
501
507
  >
502
508
  > **`piano` is the gentler version of that idea.** It still plays notes — two in eight seconds at tier 1 — but they're single struck tones with silence between them and nothing running underneath. Higher tiers fill the gaps rather than adding a groove. Try it before `drone` if you want *something* there.
503
509
  >
504
- > For the same reason **`random` never picks `drone`**. Shuffle is for a surprise *mood*, and drone isn't one — landing on a fan noise when you asked for variety reads as broken audio, not as range. Ask for it by name (or `noise` / `focus`) when you want it.
510
+ > For the same reason **`random` never picks `drone`, `rain` or `ocean`**. Shuffle is for a surprise *mood*, and drone isn't one — landing on a fan noise when you asked for variety reads as broken audio, not as range. Ask for it by name (or `noise` / `focus`) when you want it.
505
511
 
506
512
  ### Usage Examples:
507
513
  ```bash
@@ -520,7 +526,7 @@ vibe --genre jazz --volume 25
520
526
  Saved to `~/.vibeaudio/config.json` and read by all three ways of running VibeAudio — wrapper, agent hooks, MCP. It applies on your next prompt.
521
527
 
522
528
  ```bash
523
- vibe --genre jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random
529
+ vibe --genre jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random
524
530
  vibe --volume 25 # or --whisper / --quiet / --loud
525
531
  vibe --chime-volume 70
526
532
  vibe --status # what's saved, and what's overriding it
@@ -545,7 +551,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
545
551
 
546
552
  | Flag | Description | Default |
547
553
  | :--- | :--- | :--- |
548
- | `-g, --genre <name>` | Music style: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `random`. With no command after it, saves your default | `lofi` |
554
+ | `-g, --genre <name>` | Music style: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`. With no command after it, saves your default | `lofi` |
549
555
  | `-v, --volume <5-100>` | Set playback volume. With no command after it, saves your default | `40` |
550
556
  | `-cv, --chime-volume <5-100>` | Set independent completion chime volume | `volume × 1.1`, kept within 35–65 |
551
557
  | `--grace <ms>` | Silence window before music starts | `1500` |
@@ -557,8 +563,10 @@ The full order, highest first: **a flag** → **an environment variable** → **
557
563
  | `--no-chime` | Disable the resolution completion chime | `false` |
558
564
  | `--no-hud` | Disable terminal window/tab title animation | `false` |
559
565
  | `--preview <genre>` | Play one loop of a genre and exit | — |
560
- | `--render [file]` | Write this project's music to a `.wav` and exit | `vibeaudio-<genre>.wav` |
566
+ | `--render [file]` | Write this project's music to a `.wav` and exit (full scale, ignores `--volume`) | `vibeaudio-<genre>.wav` |
561
567
  | `--status` | Show what's installed, running and detected, then exit | — |
568
+ | `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
569
+ | `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
562
570
  | `--stop` | Stop the background player, then exit | — |
563
571
  | `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
564
572
  | `--unmute` | Resume normal playback, then exit | — |
@@ -577,12 +585,13 @@ The full order, highest first: **a flag** → **an environment variable** → **
577
585
  **Defaults live in `~/.vibeaudio/config.json` now** — `vibe --genre jazz` is the short way to set one. These override it for a single shell, which is what you want for one terminal that should sound different, or for a machine you don't want writing config at all:
578
586
 
579
587
  ```bash
580
- export VIBE_GENRE=jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random
588
+ export VIBE_GENRE=jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random
581
589
  export VIBE_VOLUME=25 # Background music at 25%
582
590
  export VIBE_CHIME_VOLUME=70 # Crisp completion chime at 70%
583
591
  export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
584
592
  export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
585
593
  export VIBE_DISABLE=1 # Mute, without uninstalling anything
594
+ export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
586
595
  export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
587
596
  ```
588
597
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think — a different arrangement per project.",
5
5
  "bin": {
6
6
  "vibeaudio": "bin/vibeaudio.js",
package/src/cli.js CHANGED
@@ -62,7 +62,7 @@ Procedural focus music while your AI coding tools think.
62
62
  vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
63
63
 
64
64
  \x1b[1mOPTIONS:\x1b[0m
65
- -g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, random
65
+ -g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random
66
66
  -v, --volume <5-100> Set playback volume (default: 40)
67
67
  -cv, --chime-volume <5-100> Set independent completion chime volume
68
68
  --grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
@@ -74,8 +74,10 @@ Procedural focus music while your AI coding tools think.
74
74
  --no-chime Disable the resolution completion chime
75
75
  --no-hud Disable terminal window/tab title animation
76
76
  --preview <genre> Play one loop of a genre and exit
77
- --render [file] Write this project's music to a .wav and exit
77
+ --render [file] Write this project's music to a .wav and exit (full scale, ignores --volume)
78
78
  --status Show what is installed, running and detected, then exit
79
+ --notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
80
+ --doctor Check the setup; each problem comes with its fix (exit 1 if any)
79
81
  --stop Stop the background player, then exit
80
82
  --mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
81
83
  --unmute Resume normal playback, then exit
@@ -171,6 +173,8 @@ function parseArgs(argv) {
171
173
  let render = null;
172
174
  let clearCacheFlag = false;
173
175
  let statusFlag = false;
176
+ let doctorFlag = false;
177
+ let notifyFlag = null;
174
178
  let stopFlag = false;
175
179
  let muteFlag = null;
176
180
  let muteMinutes = null;
@@ -281,6 +285,18 @@ function parseArgs(argv) {
281
285
  continue;
282
286
  }
283
287
 
288
+ if (arg === "--notify" || arg === "--no-notify") {
289
+ notifyFlag = arg === "--notify";
290
+ i += 1;
291
+ continue;
292
+ }
293
+
294
+ if (arg === "--doctor") {
295
+ doctorFlag = true;
296
+ i += 1;
297
+ continue;
298
+ }
299
+
284
300
  if (arg === "--stop") {
285
301
  stopFlag = true;
286
302
  i += 1;
@@ -423,6 +439,8 @@ function parseArgs(argv) {
423
439
  render,
424
440
  clearCache: clearCacheFlag,
425
441
  status: statusFlag,
442
+ doctor: doctorFlag,
443
+ notify: notifyFlag,
426
444
  stop: stopFlag,
427
445
  mute: muteFlag,
428
446
  muteMinutes,
@@ -660,6 +678,7 @@ function printStatus() {
660
678
  console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
661
679
  console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
662
680
  console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
681
+ console.log(` notify ${hooks.notifyEnabled() ? on("on") : off("off")}${off(" desktop banner naming the project — vibe --notify / --no-notify")}`);
663
682
 
664
683
  // Audio backend
665
684
  const backend = detect();
@@ -742,6 +761,130 @@ function printStatus() {
742
761
  printUpdateNotice();
743
762
  }
744
763
 
764
+ /**
765
+ * --status reads the setup back; this judges it. Each check is pass ("ok"),
766
+ * worth knowing ("warn" - deliberate or self-healing states) or broken
767
+ * ("fail"), and anything but "ok" carries the exact command that fixes it.
768
+ * Only "fail" makes the exit code non-zero: a mute someone set on purpose is
769
+ * not a reason for `vibe --doctor && ...` to stop a script.
770
+ */
771
+ function doctorChecks() {
772
+ const hooks = require("./hooks");
773
+ const { detectPlayer: detect, muteState, muteRemainingText, playbackDisabled, CACHE_ROOT, CONFIG_FILE } = require("./player");
774
+ const checks = [];
775
+ const add = (level, label, detail = "", fix = "") => checks.push({ level, label, detail, fix });
776
+
777
+ const backend = detect();
778
+ if (backend) {
779
+ add("ok", "Audio player", `${backend.cmd}${backend.volume ? "" : " (no volume flag — gain is baked into the file)"}`);
780
+ } else {
781
+ add("fail", "Audio player", "none found — VibeAudio runs silently",
782
+ process.platform === "linux" ? "install one of: pulseaudio-utils (paplay), ffmpeg (ffplay), alsa-utils (aplay)"
783
+ : process.platform === "win32" ? "PowerShell's SoundPlayer should be present — check that powershell is on PATH"
784
+ : "afplay ships with macOS — check that /usr/bin is on PATH");
785
+ }
786
+
787
+ // loadConfig() swallows a parse error on purpose so playback never stops,
788
+ // which is exactly why a typo here is silent and needs a check of its own.
789
+ let configProblem = null;
790
+ try {
791
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
792
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) configProblem = "not a JSON object";
793
+ } catch (e) {
794
+ if (e.code !== "ENOENT") configProblem = e.message;
795
+ }
796
+ if (configProblem) {
797
+ add("fail", "Saved settings", `${CONFIG_FILE}: ${configProblem} — ignored, defaults are in use`,
798
+ `fix or delete ${CONFIG_FILE}, then: vibe --genre <name> --volume <n>`);
799
+ } else {
800
+ add("ok", "Saved settings", "config.json parses (or none saved)");
801
+ }
802
+
803
+ const detected = hooks.detectTargets();
804
+ for (const id of Object.keys(hooks.TARGETS)) {
805
+ const t = hooks.TARGETS[id];
806
+ const installed = readVibeHooks(t.file(), t);
807
+ const label = `${t.name} hooks`;
808
+ if (!installed.length) {
809
+ if (detected.includes(id)) add("warn", label, "agent found, hooks not installed", "vibe --install-hooks");
810
+ continue;
811
+ }
812
+
813
+ // Hooks hold absolute paths to node and to this CLI; an evicted npx cache
814
+ // or an uninstalled node leaves the agent firing a command that fails.
815
+ const missing = new Set();
816
+ for (const { command } of installed) {
817
+ const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
818
+ for (const p of tokens.slice(0, 2)) if (!fs.existsSync(p)) missing.add(p);
819
+ }
820
+ if (missing.size) {
821
+ add("fail", label, `points at a file that no longer exists: ${[...missing].join(", ")}`, "vibe --install-hooks");
822
+ } else if (installed.some(({ command }) => /--genre /.test(command))) {
823
+ add("warn", label, "pinned to a genre/volume by an older install, which overrides your saved settings", "vibe --install-hooks");
824
+ } else {
825
+ add("ok", label, `${installed.length} entries`);
826
+ }
827
+
828
+ // Codex runs a hook only after the user approves it, and records that in
829
+ // config.toml under "<file>:<event>:<group>:<index>".
830
+ if (id === "codex") {
831
+ let toml = "";
832
+ try { toml = fs.readFileSync(path.join(os.homedir(), ".codex", "config.toml"), "utf8"); } catch (e) { /* none yet */ }
833
+ const pending = [];
834
+ try {
835
+ const file = t.file();
836
+ for (const [event, entries] of Object.entries(JSON.parse(fs.readFileSync(file, "utf8")).hooks || {})) {
837
+ (entries || []).forEach((entry, g) => {
838
+ t.commands(entry).forEach((command, i) => {
839
+ const key = `${file}:${event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()}:${g}:${i}`;
840
+ if (hooks.VIBE_HOOK_FLAG.test(command || "") && !toml.includes(`"${key}"`)) pending.push(event);
841
+ });
842
+ });
843
+ }
844
+ } catch (e) { /* unreadable: the hooks check above already said so */ }
845
+ if (pending.length) {
846
+ add("warn", "Codex hook trust", `${pending.length} hooks not yet approved (${[...new Set(pending)].join(", ")}) — Codex will not run them`,
847
+ "start codex and approve each VibeAudio hook once");
848
+ }
849
+ }
850
+ }
851
+
852
+ const mute = muteState();
853
+ if (mute !== null) add("warn", "Mute", `muted ${muteRemainingText(mute)}`, "vibe --unmute");
854
+ if (playbackDisabled()) add("warn", "VIBE_DISABLE", `set to "${process.env.VIBE_DISABLE}" — automatic playback is off`, "unset VIBE_DISABLE");
855
+ if (mute === null && !playbackDisabled()) add("ok", "Mute", "not muted");
856
+
857
+ const pid = readDaemonPid(hooks.PID_FILE);
858
+ if (pid === null) add("ok", "Background player", "not running");
859
+ else if (!hooks.daemonPlaying()) add("warn", "Background player", `stale pid file (${pid} is not ours) — harmless, cleared on the next prompt`, "vibe --stop");
860
+ else add("ok", "Background player", `playing, pid ${pid}`);
861
+
862
+ try {
863
+ fs.mkdirSync(CACHE_ROOT, { recursive: true });
864
+ fs.accessSync(CACHE_ROOT, fs.constants.W_OK);
865
+ add("ok", "Audio cache", `writable (${CACHE_ROOT})`);
866
+ } catch (e) {
867
+ add("fail", "Audio cache", `${CACHE_ROOT} is not writable: ${e.code || e.message}`, `check permissions on ${path.dirname(CACHE_ROOT)}`);
868
+ }
869
+
870
+ return checks;
871
+ }
872
+
873
+ function printDoctor() {
874
+ const mark = { ok: "\x1b[32m✔\x1b[0m", warn: "\x1b[33m!\x1b[0m", fail: "\x1b[31m✘\x1b[0m" };
875
+ const checks = doctorChecks();
876
+ console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version} doctor\n`);
877
+ for (const c of checks) {
878
+ console.log(` ${mark[c.level]} ${c.label.padEnd(26)}\x1b[90m${c.detail}\x1b[0m`);
879
+ if (c.fix) console.log(` fix: ${c.fix}`);
880
+ }
881
+ const failed = checks.filter((c) => c.level === "fail").length;
882
+ const warned = checks.filter((c) => c.level === "warn").length;
883
+ console.log(failed ? `\n\x1b[31m${failed} problem${failed > 1 ? "s" : ""}\x1b[0m${warned ? `, ${warned} to look at` : ""}.\n`
884
+ : `\n\x1b[32mNo problems\x1b[0m${warned ? `, ${warned} to look at` : ""}.\n`);
885
+ if (failed) process.exitCode = 1;
886
+ }
887
+
745
888
  /**
746
889
  * The effective genre and volume, resolved the same way parseArgs does it, so
747
890
  * --status reports what would actually play rather than a second opinion.
@@ -1210,6 +1353,8 @@ async function run() {
1210
1353
  render,
1211
1354
  clearCache: shouldClear,
1212
1355
  status: showStatus,
1356
+ doctor: showDoctor,
1357
+ notify: notifyChange,
1213
1358
  stop: shouldStop,
1214
1359
  mute: muteChange,
1215
1360
  muteMinutes,
@@ -1270,6 +1415,14 @@ async function run() {
1270
1415
  return;
1271
1416
  }
1272
1417
 
1418
+ if (notifyChange !== null) {
1419
+ saveConfig({ notify: notifyChange });
1420
+ console.log(notifyChange
1421
+ ? "\x1b[32m✔ Notifications on.\x1b[0m A banner names the project when a turn finishes, fails or needs you."
1422
+ : "\x1b[90mNotifications off.\x1b[0m");
1423
+ return;
1424
+ }
1425
+
1273
1426
  if (shouldStop) {
1274
1427
  const hooks = require("./hooks");
1275
1428
  const stopped = hooks.stopDaemon();
@@ -1285,6 +1438,10 @@ async function run() {
1285
1438
  return printStatus();
1286
1439
  }
1287
1440
 
1441
+ if (showDoctor) {
1442
+ return printDoctor();
1443
+ }
1444
+
1288
1445
  if (shouldClear) {
1289
1446
  console.log(`\x1b[32m[vibeaudio] Cleared cache at ${clearCache()}\x1b[0m`);
1290
1447
  return;
package/src/hooks.js CHANGED
@@ -15,7 +15,7 @@ const fs = require("fs");
15
15
  const os = require("os");
16
16
  const path = require("path");
17
17
  const { spawn, execFileSync } = require("child_process");
18
- const { AudioPlayer } = require("./player");
18
+ const { AudioPlayer, loadConfig, playbackDisabled } = require("./player");
19
19
 
20
20
  const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
21
21
  const PID_FILE = path.join(STATE_DIR, "daemon.pid");
@@ -679,6 +679,56 @@ function readPayload(done, timeoutMs = 500) {
679
679
  setTimeout(finish, timeoutMs).unref();
680
680
  }
681
681
 
682
+ /**
683
+ * Opt-in desktop notification, for the moment a chime cannot answer "which
684
+ * one?": with several terminals going, a sound says something finished and
685
+ * leaves you alt-tabbing to find out what. Off by default - a banner is more
686
+ * intrusive than a sound. Env beats the saved setting, like every other one.
687
+ */
688
+ function notifyEnabled(env = process.env, config = loadConfig()) {
689
+ const raw = String(env.VIBE_NOTIFY ?? config.notify ?? "").trim().toLowerCase();
690
+ return ["1", "true", "on", "yes"].includes(raw);
691
+ }
692
+
693
+ /** The project a hook fired in: the payload's cwd (Claude, Codex, Gemini), else ours. */
694
+ function sessionLabel(payload, cwd = process.cwd()) {
695
+ const dir = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : cwd;
696
+ return path.basename(dir.replace(/[\\/]+$/, "")) || "a session";
697
+ }
698
+
699
+ /**
700
+ * The command that shows a notification, or null where there is no built-in
701
+ * way to (Windows, for now). Title and body travel as argv, never spliced into
702
+ * a script string: a project directory is user-controlled text.
703
+ */
704
+ function notifyCommand(platform, title, body) {
705
+ if (platform === "darwin") {
706
+ return {
707
+ cmd: "osascript",
708
+ args: ["-e", "on run argv", "-e", "display notification (item 1 of argv) with title (item 2 of argv)", "-e", "end run", body, title]
709
+ };
710
+ }
711
+ if (platform === "linux") return { cmd: "notify-send", args: ["--", title, body] };
712
+ return null;
713
+ }
714
+
715
+ // Detached and unref'd: the agent waits on this hook, and a missing
716
+ // osascript/notify-send (a Linux box with no notification daemon) must be
717
+ // invisible rather than an error in someone's agent.
718
+ function notify(raw, message) {
719
+ if (!notifyEnabled() || playbackDisabled()) return false; // a mute means quiet, banners included
720
+ const command = notifyCommand(process.platform, "VibeAudio", `${sessionLabel(parsePayload(raw))}: ${message}`);
721
+ if (!command) return false;
722
+ try {
723
+ const child = spawn(command.cmd, command.args, { detached: true, stdio: "ignore" });
724
+ child.on("error", () => {});
725
+ child.unref();
726
+ return true;
727
+ } catch (e) {
728
+ return false;
729
+ }
730
+ }
731
+
682
732
  function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, raw = "" } = {}) {
683
733
  const id = sessionId(parsePayload(raw).session_id);
684
734
  // A turn that ends while paused for the user - a denied tool that nothing
@@ -691,7 +741,9 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
691
741
  const others = working(listSessions()).length > 0;
692
742
  const wasPlaying = others ? false : stopDaemon({ keepSessions: true });
693
743
  if (!others) fs.rmSync(INTENSITY_FILE, { force: true });
694
- if (!(tracked || wasPlaying) || noChime) return false;
744
+ if (!(tracked || wasPlaying)) return false;
745
+ notify(raw, outcome === "failure" ? "failed" : "finished");
746
+ if (noChime) return false;
695
747
 
696
748
  // Chime plays in this short-lived hook process.
697
749
  new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
@@ -742,6 +794,8 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
742
794
 
743
795
  writeSession(id, { ...session, waiting: waitKey(raw) });
744
796
  if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
797
+ const tool = payloadToolName(raw);
798
+ notify(raw, tool ? `needs you (${tool})` : "needs you");
745
799
  if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
746
800
  return true;
747
801
  }
@@ -1018,7 +1072,7 @@ single short sentence. Do nothing else.
1018
1072
  - \`volume <5-100>\`: \`${cli} --volume <n>\`
1019
1073
  Both save the user's default and reach the hooks on the next prompt - there
1020
1074
  is nothing to reinstall, so do not run --install-hooks for these.
1021
- Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random.
1075
+ Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random.
1022
1076
 
1023
1077
  For anything else, show the user the list above instead of running a command.
1024
1078
  `;
@@ -1082,6 +1136,9 @@ module.exports = {
1082
1136
  hookEnd,
1083
1137
  newTurn,
1084
1138
  outcomeFromPayload,
1139
+ notifyEnabled,
1140
+ notifyCommand,
1141
+ sessionLabel,
1085
1142
  readPayload,
1086
1143
  toolTier,
1087
1144
  readIntensity,
@@ -49,6 +49,8 @@ const GENRES = [
49
49
  { name: "🎋 Zen Ambient", desc: "Meditative singing bowls & floating celestial pads", id: "zen" },
50
50
  { name: "🎹 Sparse Piano", desc: "Single struck notes & long silences — Satie-ish", id: "piano" },
51
51
  { name: "🌫️ Deep Drone", desc: "Held tone & filtered noise — no melody at all", id: "drone" },
52
+ { name: "🌧️ Rain", desc: "Soft rainfall & distant droplets — no melody at all", id: "rain" },
53
+ { name: "🌊 Ocean", desc: "Slow surf rising and draining — no melody at all", id: "ocean" },
52
54
  { name: "🎲 Shuffle / Random", desc: "Picks a surprise vibe each time", id: "random" }
53
55
  ];
54
56
 
package/src/mcp.js CHANGED
@@ -27,7 +27,7 @@ const TOOLS = [
27
27
  properties: {
28
28
  genre: {
29
29
  type: "string",
30
- description: "Music genre: lofi, synthwave, 8bit, electronic, jazz, zen, piano (sparse), drone (no melody), or random",
30
+ description: "Music genre: lofi, synthwave, 8bit, electronic, jazz, zen, piano (sparse), drone/rain/ocean (no melody), or random",
31
31
  enum: [...AVAILABLE_GENRES, "random"]
32
32
  },
33
33
  volume: {
package/src/player.js CHANGED
@@ -14,6 +14,8 @@ const { generateChiptuneLoop } = require("./synth/chiptune");
14
14
  const { generateElectronicLoop } = require("./synth/electronic");
15
15
  const { generateZenLoop } = require("./synth/zen");
16
16
  const { generateDroneLoop } = require("./synth/drone");
17
+ const { generateRainLoop } = require("./synth/rain");
18
+ const { generateOceanLoop } = require("./synth/ocean");
17
19
  const { generatePianoLoop } = require("./synth/piano");
18
20
  const { generateJazzLoop } = require("./synth/jazz");
19
21
  const { generateSuccessChime, generateFailureChime, generateAttentionChime } = require("./synth/chime");
@@ -52,15 +54,16 @@ function synthFingerprint(dir = path.join(__dirname, "synth")) {
52
54
  }
53
55
 
54
56
  const CACHE_DIR = path.join(CACHE_ROOT, `v${pkg.version}-${synthFingerprint()}`);
55
- const AVAILABLE_GENRES = ["lofi", "synthwave", "8bit", "electronic", "jazz", "zen", "piano", "drone"];
57
+ const AVAILABLE_GENRES = ["lofi", "synthwave", "8bit", "electronic", "jazz", "zen", "piano", "drone", "rain", "ocean"];
56
58
 
57
59
  /**
58
- * What `random` may land on. Drone is deliberately excluded: it is the "no
59
- * melody at all" option people choose on purpose, not a mood in the same
60
- * series as the others. Rolling it by chance reads as broken audio rather
61
- * than as variety, so it stays opt-in by name (or via the noise/focus alias).
60
+ * What `random` may land on. The non-melodic genres are deliberately excluded:
61
+ * they are the "no melody at all" options people choose on purpose, not moods
62
+ * in the same series as the others. Rolling one by chance reads as broken
63
+ * audio rather than as variety, so each stays opt-in by name (or by alias).
62
64
  */
63
- const SHUFFLE_GENRES = AVAILABLE_GENRES.filter((g) => g !== "drone");
65
+ const NON_MELODIC_GENRES = ["drone", "rain", "ocean"];
66
+ const SHUFFLE_GENRES = AVAILABLE_GENRES.filter((g) => !NON_MELODIC_GENRES.includes(g));
64
67
 
65
68
  // Next loop starts slightly before the current one ends, so the per-loop
66
69
  // boundary fades crossfade instead of leaving a process-spawn gap.
@@ -218,6 +221,11 @@ const GENRE_ALIASES = {
218
221
  hum: "drone",
219
222
  whitenoise: "drone",
220
223
  "white-noise": "drone",
224
+ storm: "rain",
225
+ drizzle: "rain",
226
+ waves: "ocean",
227
+ sea: "ocean",
228
+ surf: "ocean",
221
229
  sparse: "piano",
222
230
  satie: "piano",
223
231
  keys: "piano",
@@ -271,6 +279,10 @@ function generateLoop(genre, tier, seed, bar = 0) {
271
279
  return generateDroneLoop(7.0, tier, seed, bar);
272
280
  case "piano":
273
281
  return generatePianoLoop(7.6, tier, seed, bar);
282
+ case "rain":
283
+ return generateRainLoop(7.0, tier, seed, bar);
284
+ case "ocean":
285
+ return generateOceanLoop(8.0, tier, seed, bar);
274
286
  case "lofi":
275
287
  default:
276
288
  return generateLofiLoop(6.4, tier, seed, bar);
@@ -294,9 +306,21 @@ function seedDir(seed) {
294
306
  return path.join(CACHE_DIR, `s${seed >>> 0}`);
295
307
  }
296
308
 
297
- /** Keep a small working set of projects cached; regenerating costs ~100ms. */
309
+ /**
310
+ * Keep a small working set of projects cached; regenerating costs ~100ms.
311
+ *
312
+ * A directory touched within PRUNE_MIN_AGE_MS is left alone whoever owns it.
313
+ * Another process may be part-way through a render there - it creates the
314
+ * directory, then writes its .tmp into it - and deleting it underneath that
315
+ * write throws ENOENT on the playback path, which is the one thing audio is
316
+ * never allowed to do. Time is the only signal we have across processes;
317
+ * skipping only our own directory would not have helped the other one.
318
+ */
319
+ const PRUNE_MIN_AGE_MS = 10_000;
320
+
298
321
  function pruneSeedDirs(keep = 3) {
299
322
  try {
323
+ const now = Date.now();
300
324
  const dirs = fs
301
325
  .readdirSync(CACHE_DIR, { withFileTypes: true })
302
326
  .filter((e) => e.isDirectory() && /^s\d+$/.test(e.name))
@@ -307,6 +331,7 @@ function pruneSeedDirs(keep = 3) {
307
331
  .sort((a, b) => b.mtime - a.mtime);
308
332
 
309
333
  for (const stale of dirs.slice(keep)) {
334
+ if (now - stale.mtime < PRUNE_MIN_AGE_MS) continue;
310
335
  fs.rmSync(stale.full, { recursive: true, force: true });
311
336
  }
312
337
  } catch (e) {
@@ -549,6 +574,23 @@ function writeCacheFileAtomic(filePath, buffer) {
549
574
  } catch (e) {
550
575
  fs.rmSync(tmp, { force: true });
551
576
 
577
+ // The seed directory can be pruned by another process between our mkdir
578
+ // and this write. The mtime floor in pruneSeedDirs makes that rare rather
579
+ // than impossible, so recreate the directory and try once more - a second
580
+ // ENOENT would mean a prune landed inside a window of microseconds.
581
+ if (e.code === "ENOENT") {
582
+ try {
583
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
584
+ fs.writeFileSync(tmp, buffer);
585
+ fs.renameSync(tmp, filePath);
586
+ return;
587
+ } catch (retryErr) {
588
+ fs.rmSync(tmp, { force: true });
589
+ if (fs.existsSync(filePath)) return;
590
+ throw retryErr;
591
+ }
592
+ }
593
+
552
594
  // POSIX rename replaces the destination silently. Windows does not: it
553
595
  // fails with EPERM/EACCES when another process holds the destination
554
596
  // open - which is precisely the concurrent case this function exists to
@@ -789,6 +831,8 @@ class AudioPlayer {
789
831
  module.exports = {
790
832
  AudioPlayer,
791
833
  getAudioPath,
834
+ pruneSeedDirs,
835
+ writeCacheFileAtomic,
792
836
  getChimePath,
793
837
  clearCache,
794
838
  detectPlayer,
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Ocean Procedural Synth Engine
3
+ * Low-passed noise under a slow swell: the surf rises, breaks and drains once
4
+ * per loop. No notes, no rhythm, no melody.
5
+ *
6
+ * The swell is exactly one cycle per loop (plus an exact second harmonic at
7
+ * tier 2+), so it lines up at the boundary and the loop reads as one
8
+ * continuous tide. Tiers add weight and foam, never a beat.
9
+ */
10
+
11
+ const {
12
+ SAMPLE_RATE,
13
+ makeRng,
14
+ rotate,
15
+ ornamentRng,
16
+ createWavBuffer
17
+ } = require("./generator");
18
+
19
+ // The seed picks the sea, not a tune: how dark the water is, how deep the
20
+ // swell runs, and how sharply it breaks. `level` evens the three out: a darker
21
+ // filter passes less noise, so each needs its own make-up to land at the
22
+ // level of the other genres (jazz: peak 0.64 / RMS 0.108).
23
+ const OCEAN_VARIANTS = [
24
+ { lp: 380, depth: 0.80, shape: 1.6, level: 1.48, label: "slow swell" },
25
+ { lp: 620, depth: 0.65, shape: 1.2, level: 1.03, label: "shore" },
26
+ { lp: 260, depth: 0.90, shape: 2.0, level: 1.97, label: "deep water" }
27
+ ];
28
+
29
+ const TIER_GAIN = { 1: 1.0, 2: 1.1, 3: 1.2 };
30
+ const onePole = (hz) => 1 - Math.exp((-2 * Math.PI * hz) / SAMPLE_RATE);
31
+
32
+ function generateOceanLoop(durationSec = 8.0, tier = 2, seed = 0, bar = 0) {
33
+ // Drawn before any tier gating, so gating a layer can't shift these.
34
+ const orn = ornamentRng(seed);
35
+ const variant = rotate(orn, OCEAN_VARIANTS, bar);
36
+ const chopPhase = orn();
37
+ const widthShift = 0.05 + orn() * 0.06;
38
+
39
+ const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
40
+ const left = new Float64Array(totalSamples);
41
+ const right = new Float64Array(totalSamples);
42
+
43
+ // Noise streams are consumed identically at every tier.
44
+ const noiseRng = makeRng(seed ^ 0x7f4a7c15);
45
+ const foamRng = ornamentRng(seed ^ 0x1b873593);
46
+ const gain = variant.level * (TIER_GAIN[tier] || TIER_GAIN[2]);
47
+ let lpL = 0, lpR = 0, foamL = 0, foamR = 0;
48
+
49
+ // One swell cycle per loop; the right ear trails the left for width.
50
+ const swell = (cycles) => {
51
+ const s = 0.5 - 0.5 * Math.cos(2 * Math.PI * cycles);
52
+ return variant.depth * Math.pow(s, variant.shape) + (1 - variant.depth);
53
+ };
54
+
55
+ for (let i = 0; i < totalSamples; i++) {
56
+ const c = i / totalSamples;
57
+ let envL = swell(c);
58
+ let envR = swell(c - widthShift);
59
+ // Tier 2+: a second, shorter swell (exactly two cycles, so it tiles too)
60
+ // rides on the first - more water, not more rhythm.
61
+ if (tier >= 2) {
62
+ const chop = (x) => 0.18 * (0.5 - 0.5 * Math.cos(4 * Math.PI * x + chopPhase * 2 * Math.PI));
63
+ envL += chop(c);
64
+ envR += chop(c - widthShift);
65
+ }
66
+
67
+ // The surf opens up as the swell builds: louder and brighter together.
68
+ const kL = onePole(variant.lp * (0.45 + 0.55 * envL));
69
+ const kR = onePole(variant.lp * (0.45 + 0.55 * envR));
70
+ lpL += kL * (noiseRng() * 2 - 1 - lpL);
71
+ lpR += kR * (noiseRng() * 2 - 1 - lpR);
72
+ left[i] = lpL * envL * gain;
73
+ right[i] = lpR * envR * gain;
74
+
75
+ // Tier 3 only: foam, a high hiss that only exists while the wave breaks.
76
+ if (tier === 3) {
77
+ foamL += 0.35 * (foamRng() * 2 - 1 - foamL);
78
+ foamR += 0.35 * (foamRng() * 2 - 1 - foamR);
79
+ left[i] += foamL * 0.05 * Math.pow(envL, 3);
80
+ right[i] += foamR * 0.05 * Math.pow(envR, 3);
81
+ }
82
+ }
83
+
84
+ // Both ends fade to silence, or the loop point clicks every repeat.
85
+ const fadeLen = Math.floor(0.12 * SAMPLE_RATE);
86
+ for (let i = 0; i < fadeLen; i++) {
87
+ const s = i / fadeLen;
88
+ left[i] *= s;
89
+ right[i] *= s;
90
+ left[totalSamples - 1 - i] *= s;
91
+ right[totalSamples - 1 - i] *= s;
92
+ }
93
+
94
+ return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
95
+ }
96
+
97
+ module.exports = { generateOceanLoop };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Rain Procedural Synth Engine
3
+ * A band-passed noise bed with sparse droplet transients on top. No notes, no
4
+ * rhythm, no melody - like `drone`, it is a floor of sound rather than music.
5
+ *
6
+ * Tiers add density, not movement: a heavier bed and more drops falling into
7
+ * the same piece, never a pulse.
8
+ */
9
+
10
+ const {
11
+ SAMPLE_RATE,
12
+ sine,
13
+ makeRng,
14
+ rotate,
15
+ ornamentRng,
16
+ createWavBuffer
17
+ } = require("./generator");
18
+
19
+ // The seed picks how the rain sounds, not what it plays: where the band sits,
20
+ // how many drops land, and how high they ring.
21
+ const RAIN_VARIANTS = [
22
+ { hp: 800, lp: 5000, drops: 1.0, pitch: [2200, 4200], label: "steady" },
23
+ { hp: 500, lp: 3600, drops: 0.7, pitch: [1800, 3400], label: "roof" },
24
+ { hp: 1300, lp: 7000, drops: 1.3, pitch: [2800, 5200], label: "window" }
25
+ ];
26
+
27
+ const DROP_POOL = 40;
28
+ const DROPS_BY_TIER = { 1: 0.25, 2: 0.5, 3: 1.0 };
29
+ // Band-passed noise is far hotter than the melodic genres, so this is made
30
+ // down to their level (jazz: peak 0.64 / RMS 0.108) rather than up to it.
31
+ const BED_GAIN = { 1: 0.27, 2: 0.33, 3: 0.44 };
32
+
33
+ const onePole = (hz) => 1 - Math.exp((-2 * Math.PI * hz) / SAMPLE_RATE);
34
+
35
+ function generateRainLoop(durationSec = 7.0, tier = 2, seed = 0, bar = 0) {
36
+ const orn = ornamentRng(seed);
37
+ const variant = rotate(orn, RAIN_VARIANTS, bar);
38
+
39
+ const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
40
+ const left = new Float64Array(totalSamples);
41
+ const right = new Float64Array(totalSamples);
42
+
43
+ // 1. Bed: white noise through a high-pass and a low-pass. Drawn identically
44
+ // at every tier, or the same seed would render a different bed as time
45
+ // passes.
46
+ const noiseRng = makeRng(seed ^ 0x51ed270b);
47
+ const kLp = onePole(variant.lp);
48
+ const kHp = onePole(variant.hp);
49
+ let lpL = 0, lpR = 0, hpL = 0, hpR = 0;
50
+ const bedGain = BED_GAIN[tier] || BED_GAIN[2];
51
+
52
+ for (let i = 0; i < totalSamples; i++) {
53
+ lpL += kLp * (noiseRng() * 2 - 1 - lpL);
54
+ lpR += kLp * (noiseRng() * 2 - 1 - lpR);
55
+ hpL += kHp * (lpL - hpL);
56
+ hpR += kHp * (lpR - hpR);
57
+ left[i] += (lpL - hpL) * bedGain;
58
+ right[i] += (lpR - hpR) * bedGain;
59
+ }
60
+
61
+ // 2. Droplets. The whole pool is drawn up front, from a stream of its own,
62
+ // and a tier only chooses how much of it to play - so tier 1 is tier 3's
63
+ // drops with the rest left out, not a different shower.
64
+ const dropRng = ornamentRng(seed ^ 0x2545f491);
65
+ const pool = Array.from({ length: DROP_POOL }, () => ({
66
+ at: Math.floor(dropRng() * totalSamples),
67
+ hz: variant.pitch[0] + dropRng() * (variant.pitch[1] - variant.pitch[0]),
68
+ pan: dropRng(),
69
+ amp: 0.35 + 0.65 * dropRng() * dropRng()
70
+ }));
71
+ const count = Math.min(DROP_POOL, Math.round(DROP_POOL * variant.drops * (DROPS_BY_TIER[tier] || 0.5)));
72
+ const dropLen = Math.floor(0.045 * SAMPLE_RATE);
73
+ for (const d of pool.slice(0, count)) {
74
+ for (let j = 0; j < dropLen && d.at + j < totalSamples; j++) {
75
+ const t = j / SAMPLE_RATE;
76
+ const v = sine(d.hz * t) * Math.exp(-t / 0.009) * d.amp * 0.11;
77
+ left[d.at + j] += v * (1 - d.pan);
78
+ right[d.at + j] += v * d.pan;
79
+ }
80
+ }
81
+
82
+ // Both ends fade to silence, or the loop point clicks every repeat.
83
+ const fadeLen = Math.floor(0.12 * SAMPLE_RATE);
84
+ for (let i = 0; i < fadeLen; i++) {
85
+ const s = i / fadeLen;
86
+ left[i] *= s;
87
+ right[i] *= s;
88
+ left[totalSamples - 1 - i] *= s;
89
+ right[totalSamples - 1 - i] *= s;
90
+ }
91
+
92
+ return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
93
+ }
94
+
95
+ module.exports = { generateRainLoop };