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 +40 -9
- package/package.json +1 -1
- package/src/cli.js +284 -2
- package/src/history.js +57 -0
- package/src/hooks.js +75 -9
- package/src/interactive.js +2 -0
- package/src/mcp.js +1 -1
- package/src/output.js +57 -0
- package/src/player.js +58 -12
- package/src/synth/chime.js +21 -5
- package/src/synth/ocean.js +97 -0
- package/src/synth/rain.js +95 -0
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
|
-
| `
|
|
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.
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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,
|
package/src/interactive.js
CHANGED
|
@@ -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.
|
|
59
|
-
* melody at all"
|
|
60
|
-
* series as the others. Rolling
|
|
61
|
-
* than as variety, so
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/synth/chime.js
CHANGED
|
@@ -10,16 +10,30 @@ const {
|
|
|
10
10
|
createWavBuffer
|
|
11
11
|
} = require("./generator");
|
|
12
12
|
|
|
13
|
-
|
|
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:
|
|
20
|
-
{ note:
|
|
21
|
-
{ note:
|
|
22
|
-
{ note:
|
|
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 };
|