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