vibeaudio 0.8.2 → 0.10.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
 
@@ -355,6 +357,23 @@ Removing them is one command:
355
357
  vibe --uninstall-hooks
356
358
  ```
357
359
 
360
+ ### Know where your time goes: `vibe --report`
361
+
362
+ Hooks log each finished turn — which project, how long, how it ended, and how much of it the agent spent blocked on a dialog of yours — to `~/.vibeaudio/history.jsonl`. `vibe --report` (or `--report 30`) totals it:
363
+
364
+ ```
365
+ Turns 30 3 failed
366
+ You waited 3h 55m on agents, 7m 51s per turn
367
+ Agents waited 5m 35s on you — permission dialogs and questions
368
+ Typical turn 2m 57s median
369
+ ```
370
+
371
+ 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.
372
+
373
+ ### The chime is in the music's key
374
+
375
+ 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.
376
+
358
377
  ### Reactive mode (opt-in)
359
378
 
360
379
  ```bash
@@ -458,7 +477,7 @@ VIBE_VOLUME = "25"
458
477
  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
478
 
460
479
  #### Exposed MCP Tools:
461
- * `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `random`; `volume`: `5-100`).
480
+ * `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`).
462
481
  * `vibe_stop`: Stop music and play the completion chime (`outcome`: `success` or `failure`).
463
482
  * `vibe_status`: Return current playback state and active tier.
464
483
 
@@ -480,7 +499,9 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
480
499
  | `zen` | 🎋 **Zen Ambient** | Meditative Tibetan singing bowls & celestial drone (zero rhythm) |
481
500
  | `piano` | 🎹 **Sparse Piano** | Single struck notes and long silences — Satie-ish |
482
501
  | `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`** |
502
+ | `rain` | 🌧️ **Rain** | Band-passed rainfall with sparse droplets — **no melody at all** |
503
+ | `ocean` | 🌊 **Ocean** | Low surf on a slow swell that rises and drains — **no melody at all** |
504
+ | `random` | 🎲 **Shuffle Mode** | Picks a surprise genre for the run — **never `drone`, `rain` or `ocean`** |
484
505
 
485
506
  **Aliases also work**, so you can ask for a genre the way you'd say it — `vibe --preview chill` is `lofi`:
486
507
 
@@ -494,14 +515,16 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
494
515
  | `zen` | `ambient`, `calm`, `meditation` |
495
516
  | `piano` | `sparse`, `satie`, `keys`, `minimal` |
496
517
  | `drone` | `noise`, `focus`, `hum`, `whitenoise`, `white-noise` |
518
+ | `rain` | `storm`, `drizzle` |
519
+ | `ocean` | `waves`, `sea`, `surf` |
497
520
 
498
521
  Matching is case-insensitive, so `BOSSA` works too.
499
522
 
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.
523
+ > **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
524
  >
502
525
  > **`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
526
  >
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.
527
+ > 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
528
 
506
529
  ### Usage Examples:
507
530
  ```bash
@@ -520,7 +543,7 @@ vibe --genre jazz --volume 25
520
543
  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
544
 
522
545
  ```bash
523
- vibe --genre jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random
546
+ vibe --genre jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random
524
547
  vibe --volume 25 # or --whisper / --quiet / --loud
525
548
  vibe --chime-volume 70
526
549
  vibe --status # what's saved, and what's overriding it
@@ -545,7 +568,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
545
568
 
546
569
  | Flag | Description | Default |
547
570
  | :--- | :--- | :--- |
548
- | `-g, --genre <name>` | Music style: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `random`. With no command after it, saves your default | `lofi` |
571
+ | `-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
572
  | `-v, --volume <5-100>` | Set playback volume. With no command after it, saves your default | `40` |
550
573
  | `-cv, --chime-volume <5-100>` | Set independent completion chime volume | `volume × 1.1`, kept within 35–65 |
551
574
  | `--grace <ms>` | Silence window before music starts | `1500` |
@@ -557,8 +580,11 @@ The full order, highest first: **a flag** → **an environment variable** → **
557
580
  | `--no-chime` | Disable the resolution completion chime | `false` |
558
581
  | `--no-hud` | Disable terminal window/tab title animation | `false` |
559
582
  | `--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` |
583
+ | `--render [file]` | Write this project's music to a `.wav` and exit (full scale, ignores `--volume`) | `vibeaudio-<genre>.wav` |
561
584
  | `--status` | Show what's installed, running and detected, then exit | — |
585
+ | `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
586
+ | `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
587
+ | `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
562
588
  | `--stop` | Stop the background player, then exit | — |
563
589
  | `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
564
590
  | `--unmute` | Resume normal playback, then exit | — |
@@ -577,12 +603,14 @@ The full order, highest first: **a flag** → **an environment variable** → **
577
603
  **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
604
 
579
605
  ```bash
580
- export VIBE_GENRE=jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random
606
+ export VIBE_GENRE=jazz # lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random
581
607
  export VIBE_VOLUME=25 # Background music at 25%
582
608
  export VIBE_CHIME_VOLUME=70 # Crisp completion chime at 70%
583
609
  export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
584
610
  export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
585
611
  export VIBE_DISABLE=1 # Mute, without uninstalling anything
612
+ export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
613
+ export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
586
614
  export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
587
615
  ```
588
616
 
@@ -615,6 +643,9 @@ The expiry is deliberate too. A call is a bounded thing; a mute you forget about
615
643
  **No sound at all**
616
644
  Check that a player exists for your platform (see [Requirements](#-requirements)). VibeAudio prints a notice to stderr when it can't find one. On Linux: `sudo apt install pulseaudio-utils` (or `ffmpeg` / `alsa-utils`).
617
645
 
646
+ **Silent after connecting a monitor or headphones (macOS)**
647
+ `afplay` plays on macOS's *default output device*, which moves when a display or headphones connect. A monitor often has no system volume control at all and speakers much quieter than the laptop's, so a quiet setting — a sparse genre like `piano` at 25% — can fall below what you can hear. `vibe --doctor` shows where sound is going and warns if that output is muted or has no volume control. Then try `vibe --genre lofi --volume 60` for one prompt, and check the monitor's own speaker volume.
648
+
618
649
  **Music starts immediately instead of after the grace window**
619
650
  It usually is waiting — your AI tool's own startup (auth, session load) just takes longer than 1.5s, so music and the tool's first output appear together. Raise the window: `vibe --grace 3000 claude`.
620
651
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.8.2",
3
+ "version": "0.10.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,11 @@ 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
+ --report [days] How long you waited on agents, and where (default: 7 days)
81
+ --doctor Check the setup; each problem comes with its fix (exit 1 if any)
79
82
  --stop Stop the background player, then exit
80
83
  --mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
81
84
  --unmute Resume normal playback, then exit
@@ -171,6 +174,9 @@ function parseArgs(argv) {
171
174
  let render = null;
172
175
  let clearCacheFlag = false;
173
176
  let statusFlag = false;
177
+ let doctorFlag = false;
178
+ let notifyFlag = null;
179
+ let reportDays = null;
174
180
  let stopFlag = false;
175
181
  let muteFlag = null;
176
182
  let muteMinutes = null;
@@ -281,6 +287,29 @@ function parseArgs(argv) {
281
287
  continue;
282
288
  }
283
289
 
290
+ if (arg === "--report") {
291
+ // Optional window in days, like --mute's minutes: `vibe --report 30`.
292
+ i += 1;
293
+ reportDays = 7;
294
+ if (args[i] !== undefined && /^\d+$/.test(args[i])) {
295
+ reportDays = Math.min(Math.max(parseInt(args[i], 10), 1), 365);
296
+ i += 1;
297
+ }
298
+ continue;
299
+ }
300
+
301
+ if (arg === "--notify" || arg === "--no-notify") {
302
+ notifyFlag = arg === "--notify";
303
+ i += 1;
304
+ continue;
305
+ }
306
+
307
+ if (arg === "--doctor") {
308
+ doctorFlag = true;
309
+ i += 1;
310
+ continue;
311
+ }
312
+
284
313
  if (arg === "--stop") {
285
314
  stopFlag = true;
286
315
  i += 1;
@@ -423,6 +452,9 @@ function parseArgs(argv) {
423
452
  render,
424
453
  clearCache: clearCacheFlag,
425
454
  status: statusFlag,
455
+ doctor: doctorFlag,
456
+ notify: notifyFlag,
457
+ report: reportDays,
426
458
  stop: stopFlag,
427
459
  mute: muteFlag,
428
460
  muteMinutes,
@@ -660,6 +692,7 @@ function printStatus() {
660
692
  console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
661
693
  console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
662
694
  console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
695
+ console.log(` notify ${hooks.notifyEnabled() ? on("on") : off("off")}${off(" desktop banner naming the project — vibe --notify / --no-notify")}`);
663
696
 
664
697
  // Audio backend
665
698
  const backend = detect();
@@ -742,6 +775,235 @@ function printStatus() {
742
775
  printUpdateNotice();
743
776
  }
744
777
 
778
+ /**
779
+ * --status reads the setup back; this judges it. Each check is pass ("ok"),
780
+ * worth knowing ("warn" - deliberate or self-healing states) or broken
781
+ * ("fail"), and anything but "ok" carries the exact command that fixes it.
782
+ * Only "fail" makes the exit code non-zero: a mute someone set on purpose is
783
+ * not a reason for `vibe --doctor && ...` to stop a script.
784
+ */
785
+ function doctorChecks() {
786
+ const hooks = require("./hooks");
787
+ const { detectPlayer: detect, muteState, muteRemainingText, playbackDisabled, CACHE_ROOT, CONFIG_FILE } = require("./player");
788
+ const checks = [];
789
+ const add = (level, label, detail = "", fix = "") => checks.push({ level, label, detail, fix });
790
+
791
+ const backend = detect();
792
+ if (backend) {
793
+ add("ok", "Audio player", `${backend.cmd}${backend.volume ? "" : " (no volume flag — gain is baked into the file)"}`);
794
+ } else {
795
+ add("fail", "Audio player", "none found — VibeAudio runs silently",
796
+ process.platform === "linux" ? "install one of: pulseaudio-utils (paplay), ffmpeg (ffplay), alsa-utils (aplay)"
797
+ : process.platform === "win32" ? "PowerShell's SoundPlayer should be present — check that powershell is on PATH"
798
+ : "afplay ships with macOS — check that /usr/bin is on PATH");
799
+ }
800
+
801
+ // loadConfig() swallows a parse error on purpose so playback never stops,
802
+ // which is exactly why a typo here is silent and needs a check of its own.
803
+ let configProblem = null;
804
+ try {
805
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
806
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) configProblem = "not a JSON object";
807
+ } catch (e) {
808
+ if (e.code !== "ENOENT") configProblem = e.message;
809
+ }
810
+ if (configProblem) {
811
+ add("fail", "Saved settings", `${CONFIG_FILE}: ${configProblem} — ignored, defaults are in use`,
812
+ `fix or delete ${CONFIG_FILE}, then: vibe --genre <name> --volume <n>`);
813
+ } else {
814
+ add("ok", "Saved settings", "config.json parses (or none saved)");
815
+ }
816
+
817
+ // afplay plays on the default output device, which moves when a monitor or
818
+ // headphones connect - and an external display often has no system volume at
819
+ // all, or speakers far quieter than the laptop's. Say where sound is going.
820
+ const out = require("./output").macOutput();
821
+ if (out) {
822
+ const where = out.device ? `${out.device.name}${out.device.transport ? ` (${out.device.transport})` : ""}` : "the default output";
823
+ if (out.muted) {
824
+ add("warn", "Sound output", `${where} is muted in macOS — nothing will be heard`, "unmute it in the menu bar, or: osascript -e 'set volume output muted false'");
825
+ } else if (out.volume === null) {
826
+ add("warn", "Sound output", `${where} has no system volume control, so VibeAudio's own volume is the only one — and its speakers may be quiet`,
827
+ "raise it with: vibe --volume 60 (and check the monitor's own speaker volume)");
828
+ } else if (out.volume < 10) {
829
+ add("warn", "Sound output", `${where}, system volume ${out.volume}%`, "raise the macOS volume");
830
+ } else {
831
+ add("ok", "Sound output", `${where}, system volume ${out.volume}%`);
832
+ }
833
+ }
834
+
835
+ const detected = hooks.detectTargets();
836
+ for (const id of Object.keys(hooks.TARGETS)) {
837
+ const t = hooks.TARGETS[id];
838
+ const installed = readVibeHooks(t.file(), t);
839
+ const label = `${t.name} hooks`;
840
+ if (!installed.length) {
841
+ if (detected.includes(id)) add("warn", label, "agent found, hooks not installed", "vibe --install-hooks");
842
+ continue;
843
+ }
844
+
845
+ // Hooks hold absolute paths to node and to this CLI; an evicted npx cache
846
+ // or an uninstalled node leaves the agent firing a command that fails.
847
+ const missing = new Set();
848
+ for (const { command } of installed) {
849
+ const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
850
+ for (const p of tokens.slice(0, 2)) if (!fs.existsSync(p)) missing.add(p);
851
+ }
852
+ if (missing.size) {
853
+ add("fail", label, `points at a file that no longer exists: ${[...missing].join(", ")}`, "vibe --install-hooks");
854
+ } else if (installed.some(({ command }) => /--genre /.test(command))) {
855
+ add("warn", label, "pinned to a genre/volume by an older install, which overrides your saved settings", "vibe --install-hooks");
856
+ } else {
857
+ add("ok", label, `${installed.length} entries`);
858
+ }
859
+
860
+ // Codex runs a hook only after the user approves it, and records that in
861
+ // config.toml under "<file>:<event>:<group>:<index>".
862
+ if (id === "codex") {
863
+ let toml = "";
864
+ try { toml = fs.readFileSync(path.join(os.homedir(), ".codex", "config.toml"), "utf8"); } catch (e) { /* none yet */ }
865
+ const pending = [];
866
+ try {
867
+ const file = t.file();
868
+ for (const [event, entries] of Object.entries(JSON.parse(fs.readFileSync(file, "utf8")).hooks || {})) {
869
+ (entries || []).forEach((entry, g) => {
870
+ t.commands(entry).forEach((command, i) => {
871
+ const key = `${file}:${event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()}:${g}:${i}`;
872
+ if (hooks.VIBE_HOOK_FLAG.test(command || "") && !toml.includes(`"${key}"`)) pending.push(event);
873
+ });
874
+ });
875
+ }
876
+ } catch (e) { /* unreadable: the hooks check above already said so */ }
877
+ if (pending.length) {
878
+ add("warn", "Codex hook trust", `${pending.length} hooks not yet approved (${[...new Set(pending)].join(", ")}) — Codex will not run them`,
879
+ "start codex and approve each VibeAudio hook once");
880
+ }
881
+ }
882
+ }
883
+
884
+ const mute = muteState();
885
+ if (mute !== null) add("warn", "Mute", `muted ${muteRemainingText(mute)}`, "vibe --unmute");
886
+ if (playbackDisabled()) add("warn", "VIBE_DISABLE", `set to "${process.env.VIBE_DISABLE}" — automatic playback is off`, "unset VIBE_DISABLE");
887
+ if (mute === null && !playbackDisabled()) add("ok", "Mute", "not muted");
888
+
889
+ const pid = readDaemonPid(hooks.PID_FILE);
890
+ if (pid === null) add("ok", "Background player", "not running");
891
+ else if (!hooks.daemonPlaying()) add("warn", "Background player", `stale pid file (${pid} is not ours) — harmless, cleared on the next prompt`, "vibe --stop");
892
+ else add("ok", "Background player", `playing, pid ${pid}`);
893
+
894
+ try {
895
+ fs.mkdirSync(CACHE_ROOT, { recursive: true });
896
+ fs.accessSync(CACHE_ROOT, fs.constants.W_OK);
897
+ add("ok", "Audio cache", `writable (${CACHE_ROOT})`);
898
+ } catch (e) {
899
+ add("fail", "Audio cache", `${CACHE_ROOT} is not writable: ${e.code || e.message}`, `check permissions on ${path.dirname(CACHE_ROOT)}`);
900
+ }
901
+
902
+ return checks;
903
+ }
904
+
905
+ /** 95 -> "1m 35s", 4_000_000 -> "1h 06m". */
906
+ function formatDuration(ms) {
907
+ const s = Math.round(ms / 1000);
908
+ if (s < 60) return `${s}s`;
909
+ const m = Math.floor(s / 60);
910
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
911
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
912
+ }
913
+
914
+ /**
915
+ * The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
916
+ * working time - a turn's length minus the stretches it spent blocked on a
917
+ * dialog of yours - because the two are different complaints: one is the
918
+ * agent being slow, the other is you being away.
919
+ */
920
+ function printReport(days, now = Date.now()) {
921
+ const { readTurns } = require("./history");
922
+ const turns = readTurns(days, now);
923
+ const dim = (s) => `\x1b[90m${s}\x1b[0m`;
924
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
925
+ console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m report — last ${days === 1 ? "day" : `${days} days`}\n`);
926
+
927
+ if (!turns.length) {
928
+ console.log(` No turns recorded yet.`);
929
+ console.log(dim(` Turns are logged when your agent's hooks fire (vibe --install-hooks). A command wrapped as \`vibe <command>\` is not.`));
930
+ console.log(dim(` The log stays on this machine; VIBE_NO_HISTORY=1 turns it off.`));
931
+ console.log();
932
+ return;
933
+ }
934
+
935
+ const working = (t) => t.ms - (t.blockedMs || 0);
936
+ const sum = (xs) => xs.reduce((a, b) => a + b, 0);
937
+ const sorted = turns.map((t) => t.ms).sort((a, b) => a - b);
938
+ const median = sorted[Math.floor(sorted.length / 2)];
939
+ const longest = turns.reduce((a, b) => (b.ms > a.ms ? b : a));
940
+ const failed = turns.filter((t) => t.outcome === "failure").length;
941
+ const waitedOnAgents = sum(turns.map(working));
942
+ const blocked = sum(turns.map((t) => t.blockedMs || 0));
943
+
944
+ // Two projects called `api` under different parents stay apart.
945
+ const names = new Map();
946
+ for (const t of turns) {
947
+ const parts = String(t.project || "?").split(/[\\/]/).filter(Boolean);
948
+ const clash = [...names.entries()].some(([p, n]) => p !== t.project && n === parts.slice(-1)[0]);
949
+ names.set(t.project, clash ? parts.slice(-2).join("/") : parts.slice(-1)[0] || "?");
950
+ }
951
+ const label = (t) => names.get(t.project);
952
+
953
+ const row = (k, v, extra = "") => console.log(` ${k.padEnd(18)}${bold(v)}${extra ? dim(` ${extra}`) : ""}`);
954
+ row("Turns", String(turns.length), failed ? `${failed} failed` : "");
955
+ row("You waited", formatDuration(waitedOnAgents), `on agents, ${formatDuration(waitedOnAgents / turns.length)} per turn`);
956
+ if (blocked >= 1000) row("Agents waited", formatDuration(blocked), "on you — permission dialogs and questions");
957
+ row("Typical turn", formatDuration(median), "median");
958
+ row("Longest turn", formatDuration(longest.ms), label(longest));
959
+
960
+ const byProject = new Map();
961
+ for (const t of turns) {
962
+ const p = byProject.get(label(t)) || { ms: 0, n: 0 };
963
+ p.ms += working(t);
964
+ p.n += 1;
965
+ byProject.set(label(t), p);
966
+ }
967
+ const top = [...byProject.entries()].sort((a, b) => b[1].ms - a[1].ms).slice(0, 5);
968
+ const widest = top[0][1].ms || 1;
969
+ console.log(`\n${bold("Where the time went")}`);
970
+ for (const [name, p] of top) {
971
+ console.log(` ${name.slice(0, 18).padEnd(19)}${formatDuration(p.ms).padStart(8)} ${"█".repeat(Math.max(1, Math.round((p.ms / widest) * 14))).padEnd(14)} ${dim(`${p.n} ${p.n === 1 ? "turn" : "turns"}`)}`);
972
+ }
973
+
974
+ if (days <= 14) {
975
+ console.log(`\n${bold("By day")}`);
976
+ const byDay = new Map();
977
+ for (const t of turns) {
978
+ const key = new Date(t.at).toLocaleDateString("en-CA");
979
+ byDay.set(key, (byDay.get(key) || 0) + working(t));
980
+ }
981
+ const maxDay = Math.max(...byDay.values()) || 1;
982
+ for (let d = days - 1; d >= 0; d--) {
983
+ const date = new Date(now - d * 86400000);
984
+ const ms = byDay.get(date.toLocaleDateString("en-CA")) || 0;
985
+ const name = `${date.toLocaleDateString("en-US", { weekday: "short" })} ${date.getDate()}`;
986
+ console.log(` ${name.padEnd(8)}${(ms ? formatDuration(ms) : "—").padStart(8)} ${"█".repeat(ms ? Math.max(1, Math.round((ms / maxDay) * 14)) : 0)}`);
987
+ }
988
+ }
989
+ console.log();
990
+ }
991
+
992
+ function printDoctor() {
993
+ const mark = { ok: "\x1b[32m✔\x1b[0m", warn: "\x1b[33m!\x1b[0m", fail: "\x1b[31m✘\x1b[0m" };
994
+ const checks = doctorChecks();
995
+ console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version} doctor\n`);
996
+ for (const c of checks) {
997
+ console.log(` ${mark[c.level]} ${c.label.padEnd(26)}\x1b[90m${c.detail}\x1b[0m`);
998
+ if (c.fix) console.log(` fix: ${c.fix}`);
999
+ }
1000
+ const failed = checks.filter((c) => c.level === "fail").length;
1001
+ const warned = checks.filter((c) => c.level === "warn").length;
1002
+ console.log(failed ? `\n\x1b[31m${failed} problem${failed > 1 ? "s" : ""}\x1b[0m${warned ? `, ${warned} to look at` : ""}.\n`
1003
+ : `\n\x1b[32mNo problems\x1b[0m${warned ? `, ${warned} to look at` : ""}.\n`);
1004
+ if (failed) process.exitCode = 1;
1005
+ }
1006
+
745
1007
  /**
746
1008
  * The effective genre and volume, resolved the same way parseArgs does it, so
747
1009
  * --status reports what would actually play rather than a second opinion.
@@ -1020,6 +1282,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
1020
1282
  volume,
1021
1283
  chimeVolume,
1022
1284
  noChime,
1285
+ genre,
1023
1286
  raw
1024
1287
  });
1025
1288
  });
@@ -1210,6 +1473,9 @@ async function run() {
1210
1473
  render,
1211
1474
  clearCache: shouldClear,
1212
1475
  status: showStatus,
1476
+ doctor: showDoctor,
1477
+ notify: notifyChange,
1478
+ report: reportDays,
1213
1479
  stop: shouldStop,
1214
1480
  mute: muteChange,
1215
1481
  muteMinutes,
@@ -1270,6 +1536,14 @@ async function run() {
1270
1536
  return;
1271
1537
  }
1272
1538
 
1539
+ if (notifyChange !== null) {
1540
+ saveConfig({ notify: notifyChange });
1541
+ console.log(notifyChange
1542
+ ? "\x1b[32m✔ Notifications on.\x1b[0m A banner names the project when a turn finishes, fails or needs you."
1543
+ : "\x1b[90mNotifications off.\x1b[0m");
1544
+ return;
1545
+ }
1546
+
1273
1547
  if (shouldStop) {
1274
1548
  const hooks = require("./hooks");
1275
1549
  const stopped = hooks.stopDaemon();
@@ -1285,6 +1559,14 @@ async function run() {
1285
1559
  return printStatus();
1286
1560
  }
1287
1561
 
1562
+ if (showDoctor) {
1563
+ return printDoctor();
1564
+ }
1565
+
1566
+ if (reportDays !== null) {
1567
+ return printReport(reportDays);
1568
+ }
1569
+
1288
1570
  if (shouldClear) {
1289
1571
  console.log(`\x1b[32m[vibeaudio] Cleared cache at ${clearCache()}\x1b[0m`);
1290
1572
  return;
package/src/history.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A local log of finished turns - what `vibe --report` reads. One JSON object
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, and how
5
+ * it ended. It never leaves the machine; VIBE_NO_HISTORY=1 turns it off.
6
+ */
7
+
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const HISTORY_FILE = path.join(os.homedir(), ".vibeaudio", "history.jsonl");
13
+ const MAX_BYTES = 1024 * 1024;
14
+ const KEEP_LINES = 4000;
15
+
16
+ /** 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
+ if (process.env.VIBE_NO_HISTORY) return false;
19
+ if (!Number.isFinite(ms) || ms < 0) return false;
20
+ try {
21
+ 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
+ // Bounded, so a year of use is still a small file. Rewritten rarely, from
24
+ // the tail, so the newest turns are always the ones kept.
25
+ if (fs.statSync(file).size > MAX_BYTES) {
26
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
27
+ const tmp = `${file}.${process.pid}.tmp`;
28
+ fs.writeFileSync(tmp, `${lines.slice(-KEEP_LINES).join("\n")}\n`);
29
+ fs.renameSync(tmp, file);
30
+ }
31
+ return true;
32
+ } catch (e) {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Turns that ended within the last `days` days. A line that does not parse is skipped, not fatal. */
38
+ function readTurns(days, now = Date.now(), file = HISTORY_FILE) {
39
+ let raw;
40
+ try {
41
+ raw = fs.readFileSync(file, "utf8");
42
+ } catch (e) {
43
+ return [];
44
+ }
45
+ const since = now - days * 86400000;
46
+ const turns = [];
47
+ for (const line of raw.split("\n")) {
48
+ if (!line) continue;
49
+ try {
50
+ const t = JSON.parse(line);
51
+ if (t && Number.isFinite(t.at) && Number.isFinite(t.ms) && t.at >= since) turns.push(t);
52
+ } catch (e) { /* a torn or hand-edited line */ }
53
+ }
54
+ return turns;
55
+ }
56
+
57
+ module.exports = { HISTORY_FILE, recordTurn, readTurns };
package/src/hooks.js CHANGED
@@ -15,7 +15,8 @@ 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
+ const { recordTurn } = require("./history");
19
20
 
20
21
  const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
21
22
  const PID_FILE = path.join(STATE_DIR, "daemon.pid");
@@ -624,7 +625,7 @@ function spawnDaemon(genre, volume, reactive) {
624
625
  function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
625
626
  const id = sessionId(turn && turn.session);
626
627
  // Before the spawn: the daemon reads it on startup.
627
- writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null });
628
+ writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
628
629
  if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
629
630
 
630
631
  stopDaemon({ keepSessions: true });
@@ -679,22 +680,81 @@ function readPayload(done, timeoutMs = 500) {
679
680
  setTimeout(finish, timeoutMs).unref();
680
681
  }
681
682
 
682
- function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, raw = "" } = {}) {
683
+ /**
684
+ * Opt-in desktop notification, for the moment a chime cannot answer "which
685
+ * one?": with several terminals going, a sound says something finished and
686
+ * leaves you alt-tabbing to find out what. Off by default - a banner is more
687
+ * intrusive than a sound. Env beats the saved setting, like every other one.
688
+ */
689
+ function notifyEnabled(env = process.env, config = loadConfig()) {
690
+ const raw = String(env.VIBE_NOTIFY ?? config.notify ?? "").trim().toLowerCase();
691
+ return ["1", "true", "on", "yes"].includes(raw);
692
+ }
693
+
694
+ /** The project a hook fired in: the payload's cwd (Claude, Codex, Gemini), else ours. */
695
+ function sessionLabel(payload, cwd = process.cwd()) {
696
+ const dir = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : cwd;
697
+ return path.basename(dir.replace(/[\\/]+$/, "")) || "a session";
698
+ }
699
+
700
+ /**
701
+ * The command that shows a notification, or null where there is no built-in
702
+ * way to (Windows, for now). Title and body travel as argv, never spliced into
703
+ * a script string: a project directory is user-controlled text.
704
+ */
705
+ function notifyCommand(platform, title, body) {
706
+ if (platform === "darwin") {
707
+ return {
708
+ cmd: "osascript",
709
+ args: ["-e", "on run argv", "-e", "display notification (item 1 of argv) with title (item 2 of argv)", "-e", "end run", body, title]
710
+ };
711
+ }
712
+ if (platform === "linux") return { cmd: "notify-send", args: ["--", title, body] };
713
+ return null;
714
+ }
715
+
716
+ // Detached and unref'd: the agent waits on this hook, and a missing
717
+ // osascript/notify-send (a Linux box with no notification daemon) must be
718
+ // invisible rather than an error in someone's agent.
719
+ function notify(raw, message) {
720
+ if (!notifyEnabled() || playbackDisabled()) return false; // a mute means quiet, banners included
721
+ const command = notifyCommand(process.platform, "VibeAudio", `${sessionLabel(parsePayload(raw))}: ${message}`);
722
+ if (!command) return false;
723
+ try {
724
+ const child = spawn(command.cmd, command.args, { detached: true, stdio: "ignore" });
725
+ child.on("error", () => {});
726
+ child.unref();
727
+ return true;
728
+ } catch (e) {
729
+ return false;
730
+ }
731
+ }
732
+
733
+ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre, raw = "" } = {}) {
683
734
  const id = sessionId(parsePayload(raw).session_id);
684
735
  // A turn that ends while paused for the user - a denied tool that nothing
685
736
  // resumed after - still finished, so its session counts either way.
686
- const tracked = readSession(id) !== null;
737
+ const session = readSession(id);
738
+ const tracked = session !== null;
687
739
  fs.rmSync(sessionFile(id), { force: true });
740
+ if (session && Number.isFinite(session.started)) {
741
+ const now = Date.now();
742
+ // A turn that ends while still paused for you has been blocked since then.
743
+ const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
744
+ recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, at: now });
745
+ }
688
746
 
689
747
  // The music is every working session's, so it ends with the last of them.
690
748
  // Everyone else's carries on, and this session still gets its own chime.
691
749
  const others = working(listSessions()).length > 0;
692
750
  const wasPlaying = others ? false : stopDaemon({ keepSessions: true });
693
751
  if (!others) fs.rmSync(INTENSITY_FILE, { force: true });
694
- if (!(tracked || wasPlaying) || noChime) return false;
752
+ if (!(tracked || wasPlaying)) return false;
753
+ notify(raw, outcome === "failure" ? "failed" : "finished");
754
+ if (noChime) return false;
695
755
 
696
756
  // Chime plays in this short-lived hook process.
697
- new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
757
+ new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre });
698
758
  return true;
699
759
  }
700
760
 
@@ -740,8 +800,10 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
740
800
  const session = readSession(id);
741
801
  if (!session || session.waiting != null) return false;
742
802
 
743
- writeSession(id, { ...session, waiting: waitKey(raw) });
803
+ writeSession(id, { ...session, waiting: waitKey(raw), waitStart: Date.now() });
744
804
  if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
805
+ const tool = payloadToolName(raw);
806
+ notify(raw, tool ? `needs you (${tool})` : "needs you");
745
807
  if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
746
808
  return true;
747
809
  }
@@ -767,7 +829,8 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
767
829
  // to finish is the first sign of work carrying on.
768
830
  if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
769
831
 
770
- writeSession(id, { ...session, waiting: null });
832
+ const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
833
+ writeSession(id, { ...session, waiting: null, waitStart: null, blockedMs });
771
834
  if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
772
835
  return true;
773
836
  }
@@ -1018,7 +1081,7 @@ single short sentence. Do nothing else.
1018
1081
  - \`volume <5-100>\`: \`${cli} --volume <n>\`
1019
1082
  Both save the user's default and reach the hooks on the next prompt - there
1020
1083
  is nothing to reinstall, so do not run --install-hooks for these.
1021
- Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random.
1084
+ Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random.
1022
1085
 
1023
1086
  For anything else, show the user the list above instead of running a command.
1024
1087
  `;
@@ -1082,6 +1145,9 @@ module.exports = {
1082
1145
  hookEnd,
1083
1146
  newTurn,
1084
1147
  outcomeFromPayload,
1148
+ notifyEnabled,
1149
+ notifyCommand,
1150
+ sessionLabel,
1085
1151
  readPayload,
1086
1152
  toolTier,
1087
1153
  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/output.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * What macOS is actually sending sound to. `afplay` plays on the default
3
+ * output device, which is not always the one you are listening on: connect a
4
+ * monitor and the default can move to it, where the system volume may be
5
+ * missing or the speakers quiet. Read only by `--doctor`; nothing else asks.
6
+ */
7
+
8
+ const { spawnSync } = require("child_process");
9
+
10
+ /** "output volume:39, input volume:88, alert volume:63, output muted:false" */
11
+ function parseVolumeSettings(text) {
12
+ const volume = /output volume:\s*(\d+)/.exec(text);
13
+ const muted = /output muted:\s*(true|false)/.exec(text);
14
+ return {
15
+ // "missing value" is what macOS reports for a device with no software
16
+ // volume control - common for monitors - so absent means no knob, not zero.
17
+ volume: volume ? Number(volume[1]) : null,
18
+ muted: muted ? muted[1] === "true" : null
19
+ };
20
+ }
21
+
22
+ const TRANSPORTS = {
23
+ coreaudio_device_type_usb: "USB",
24
+ coreaudio_device_type_hdmi: "HDMI",
25
+ coreaudio_device_type_displayport: "DisplayPort",
26
+ coreaudio_device_type_builtin: "built-in",
27
+ coreaudio_device_type_bluetooth: "Bluetooth",
28
+ coreaudio_device_type_airplay: "AirPlay",
29
+ coreaudio_device_type_virtual: "virtual"
30
+ };
31
+
32
+ /** The device `afplay` will use, from `system_profiler SPAudioDataType -json`. */
33
+ function parseDefaultOutput(json) {
34
+ try {
35
+ const items = (JSON.parse(json).SPAudioDataType || []).flatMap((group) => group._items || []);
36
+ const item = items.find((i) => i.coreaudio_default_audio_output_device === "spaudio_yes");
37
+ if (!item) return null;
38
+ return { name: item._name, transport: TRANSPORTS[item.coreaudio_device_transport] || null };
39
+ } catch (e) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** null off macOS, or when either lookup fails: absence of an answer is not a finding. */
45
+ function macOutput() {
46
+ if (process.platform !== "darwin") return null;
47
+ const run = (cmd, args) => {
48
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 });
49
+ return r.status === 0 ? r.stdout : null;
50
+ };
51
+ const settings = run("osascript", ["-e", "get volume settings"]);
52
+ if (settings === null) return null;
53
+ const profile = run("system_profiler", ["SPAudioDataType", "-json"]);
54
+ return { ...parseVolumeSettings(settings), device: profile ? parseDefaultOutput(profile) : null };
55
+ }
56
+
57
+ module.exports = { parseVolumeSettings, parseDefaultOutput, macOutput };
package/src/player.js CHANGED
@@ -14,9 +14,11 @@ 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
- const { generateSuccessChime, generateFailureChime, generateAttentionChime } = require("./synth/chime");
21
+ const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
20
22
  const { hashString } = require("./synth/generator");
21
23
  const pkg = require("../package.json");
22
24
 
@@ -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);
@@ -621,13 +633,45 @@ const CHIMES = {
621
633
  attention: generateAttentionChime
622
634
  };
623
635
 
624
- function getChimePath(outcome = "success", gain = 1) {
636
+ /**
637
+ * The key each genre sits in, so the success chime can resolve onto it. Read
638
+ * off the generators: lofi, 8bit, jazz and piano are C major (or its relative
639
+ * A minor, which shares the chord); synthwave and electronic are D minor; zen
640
+ * is D major; drone's roots (D, A, E) all take an A minor chord. Rain and
641
+ * ocean are noise with no pitch, and `random` is resolved by whoever started
642
+ * the music, so those keep the original chime.
643
+ */
644
+ const GENRE_KEYS = {
645
+ lofi: "C major",
646
+ "8bit": "C major",
647
+ jazz: "C major",
648
+ piano: "C major",
649
+ synthwave: "D minor",
650
+ electronic: "D minor",
651
+ zen: "D major",
652
+ drone: "A minor"
653
+ };
654
+
655
+ function chimeKeyFor(genre) {
656
+ // resolveGenre would roll the dice here, and a chime in a key picked at
657
+ // random is no more matched than the default.
658
+ if (String(genre || "").trim().toLowerCase() === "random") return DEFAULT_CHIME_KEY;
659
+ const key = GENRE_KEYS[resolveGenre(genre || "")];
660
+ return key && SUCCESS_CHIME_NOTES[key] ? key : DEFAULT_CHIME_KEY;
661
+ }
662
+
663
+ function getChimePath(outcome = "success", gain = 1, key = DEFAULT_CHIME_KEY) {
625
664
  ensureCacheDir();
626
665
  const kind = outcome === "error" ? "failure" : CHIMES[outcome] ? outcome : "success";
627
- const filePath = path.join(CACHE_DIR, `chime_${kind}${gainSuffix(gain)}.wav`);
666
+ // Only the success chime has keys, and the default keeps its original file
667
+ // name so an upgrade does not leave a cached copy behind.
668
+ const keyed = kind === "success" && key !== DEFAULT_CHIME_KEY && SUCCESS_CHIME_NOTES[key];
669
+ const suffix = keyed ? `_${key.replace(" ", "")}` : "";
670
+ const filePath = path.join(CACHE_DIR, `chime_${kind}${suffix}${gainSuffix(gain)}.wav`);
628
671
 
629
672
  if (!fs.existsSync(filePath)) {
630
- writeCacheFileAtomic(filePath, applyGain(CHIMES[kind](), gain));
673
+ const wav = keyed ? generateSuccessChime(1.6, key) : CHIMES[kind]();
674
+ writeCacheFileAtomic(filePath, applyGain(wav, gain));
631
675
  }
632
676
  return filePath;
633
677
  }
@@ -775,7 +819,7 @@ class AudioPlayer {
775
819
  * that must hand control back at once - an agent waits on its hooks, and a
776
820
  * blocking chime held the permission dialog back for its whole length.
777
821
  */
778
- stop({ playChime = true, outcome = "success", volume = 0.35, chimeVolume = null, detach = false } = {}) {
822
+ stop({ playChime = true, outcome = "success", volume = 0.35, chimeVolume = null, detach = false, genre = this.genre } = {}) {
779
823
  const wasPlaying = this.isPlaying;
780
824
  this.isPlaying = false;
781
825
 
@@ -797,7 +841,7 @@ class AudioPlayer {
797
841
 
798
842
  const targetVol = chimeVolume !== null ? chimeVolume : Math.min(0.65, Math.max(0.35, volume * 1.1));
799
843
  const clamped = Math.max(0.05, Math.min(1.0, targetVol));
800
- const chimeFile = getChimePath(outcome, bakedGain(backend, clamped));
844
+ const chimeFile = getChimePath(outcome, bakedGain(backend, clamped), chimeKeyFor(genre));
801
845
 
802
846
  try {
803
847
  if (detach) {
@@ -822,6 +866,8 @@ module.exports = {
822
866
  pruneSeedDirs,
823
867
  writeCacheFileAtomic,
824
868
  getChimePath,
869
+ chimeKeyFor,
870
+ GENRE_KEYS,
825
871
  clearCache,
826
872
  detectPlayer,
827
873
  bakedGain,
@@ -10,16 +10,30 @@ const {
10
10
  createWavBuffer
11
11
  } = require("./generator");
12
12
 
13
- function generateSuccessChime(durationSec = 1.6) {
13
+ // The success chime is the tonic chord of whatever key the music is in - root,
14
+ // fifth, octave, third - so it lands as the resolution of the piece instead of
15
+ // a stranger's bell over it. The default is the original C major chime.
16
+ const DEFAULT_CHIME_KEY = "C major";
17
+ const SUCCESS_CHIME_NOTES = {
18
+ "C major": ["C5", "G5", "C6", "E6"],
19
+ "D major": ["D5", "A5", "D6", "F#6"],
20
+ "D minor": ["D5", "A5", "D6", "F6"],
21
+ // Sits low on purpose: A, E and C are consonant over each of the drone's
22
+ // three roots (D, A, E), so one chime serves all of them.
23
+ "A minor": ["A4", "E5", "A5", "C6"]
24
+ };
25
+
26
+ function generateSuccessChime(durationSec = 1.6, key = DEFAULT_CHIME_KEY) {
27
+ const names = SUCCESS_CHIME_NOTES[key] || SUCCESS_CHIME_NOTES[DEFAULT_CHIME_KEY];
14
28
  const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
15
29
  const left = new Float64Array(totalSamples);
16
30
  const right = new Float64Array(totalSamples);
17
31
 
18
32
  const chimeNotes = [
19
- { note: "C5", delay: 0.00, pan: 0.4 },
20
- { note: "G5", delay: 0.09, pan: 0.6 },
21
- { note: "C6", delay: 0.18, pan: 0.45 },
22
- { note: "E6", delay: 0.27, pan: 0.55 }
33
+ { note: names[0], delay: 0.00, pan: 0.4 },
34
+ { note: names[1], delay: 0.09, pan: 0.6 },
35
+ { note: names[2], delay: 0.18, pan: 0.45 },
36
+ { note: names[3], delay: 0.27, pan: 0.55 }
23
37
  ];
24
38
 
25
39
  for (const c of chimeNotes) {
@@ -130,6 +144,8 @@ function generateAttentionChime(durationSec = 1.3) {
130
144
  }
131
145
 
132
146
  module.exports = {
147
+ DEFAULT_CHIME_KEY,
148
+ SUCCESS_CHIME_NOTES,
133
149
  generateChime: generateSuccessChime,
134
150
  generateSuccessChime,
135
151
  generateFailureChime,
@@ -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 };