vibeaudio 0.6.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/package.json +15 -6
- package/src/cli.js +102 -3
- package/src/player.js +68 -14
- package/src/render.js +123 -0
- package/src/synth/chiptune.js +3 -3
- package/src/synth/drone.js +3 -3
- package/src/synth/electronic.js +3 -3
- package/src/synth/generator.js +23 -0
- package/src/synth/jazz.js +3 -2
- package/src/synth/lofi.js +3 -2
- package/src/synth/piano.js +3 -2
- package/src/synth/synthwave.js +3 -3
- package/src/synth/zen.js +3 -2
- package/src/update.js +106 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🎧 VibeAudio
|
|
2
2
|
|
|
3
|
-
[](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml)
|
|
3
|
+
[](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [](https://www.npmjs.com/package/vibeaudio) [](https://npm-stat.com/charts.html?package=vibeaudio) [](https://npm-stat.com/charts.html?package=vibeaudio)
|
|
4
4
|
|
|
5
5
|
> **Procedural focus music while your AI coding tools think.**
|
|
6
6
|
> Every project gets its own arrangement. Zero dependencies, zero audio files.
|
|
@@ -23,6 +23,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
|
|
|
23
23
|
* 🧮 **Pure synthesis, zero MP3s.** Every note, chord and pad is generated in code — no audio assets, no npm dependencies, no `node-gyp`.
|
|
24
24
|
* 🎼 **A different arrangement per project.** Your working directory seeds the progression, bass line and melody, so each repo has its own sound and keeps it.
|
|
25
25
|
* 📈 **Escalating layers.** Tier 1 (0–15s) gentle intro → Tier 2 (15–45s) main groove → Tier 3 (45s+) deep focus. You can hear how deep into the task the agent is.
|
|
26
|
+
* 🔁 **A phrase, not a loop.** Each piece is three bars that rotate through your project's own progressions, so a long turn moves through a ~21-second phrase instead of replaying one 7-second bar for half an hour.
|
|
26
27
|
* 🎛️ **Music that follows the work** ([reactive mode](#reactive-mode-opt-in), opt-in). Calm while the agent reads, fuller while it edits, busiest when it runs commands or hands work to sub-agents — you can hear *what* it's doing, not just for how long.
|
|
27
28
|
* 🔔 **Outcome-aware chimes.** Ascending on success, a soft descending minor chord on failure, and **silence on `Ctrl+C`** — an abort is never reported as done.
|
|
28
29
|
* ✋ **A "your turn" chime.** When Claude Code stops to ask permission (or an MCP server asks for input), the music pauses and a rising two-note chime asks for you; it picks back up once you've answered.
|
|
@@ -60,6 +61,15 @@ That's a deliberate choice. Focus music has one job: **be ignorable.** Music tha
|
|
|
60
61
|
|
|
61
62
|
---
|
|
62
63
|
|
|
64
|
+
### 🎧 Send someone your repo's sound
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
vibe --render # -> vibeaudio-lofi.wav, ~53s
|
|
68
|
+
vibe --genre jazz --render our-api.wav
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A whole session in one file: every bar of the phrase at tier 1, then tier 2, then tier 3, and the success chime at the end — rendered from your project's own seed, at full scale so your player's volume control is the only one in the way. Plain WAV, so nothing has to be installed to play it.
|
|
72
|
+
|
|
63
73
|
## 📋 Requirements
|
|
64
74
|
|
|
65
75
|
* **Node.js ≥ 18**
|
|
@@ -87,6 +97,23 @@ npm i -g vibeaudio
|
|
|
87
97
|
|
|
88
98
|
That puts `vibe` and `vibeaudio` on your `PATH`. Re-run the same command to update, or see [Uninstall](#-uninstall) to remove it cleanly.
|
|
89
99
|
|
|
100
|
+
**Want what's on `main`?** Fixes land here before they reach the registry, and installing from GitHub gets them straight away — no clone, no build:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npm i -g github:kiril6/vibeaudio
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Same command to update it again. Switch back to the released version any time with `npm i -g vibeaudio`.
|
|
107
|
+
|
|
108
|
+
**Staying up to date.** When a newer version is out, `vibe --status`, `vibe --help` and the menu say so in one line. They're the only places it's shown — never in your agent's hooks, and never over a command you wrapped. What changed is on the [releases page](https://github.com/kiril6/vibeaudio/releases).
|
|
109
|
+
|
|
110
|
+
<details>
|
|
111
|
+
<summary><b>What the update check sends, and how to turn it off</b></summary>
|
|
112
|
+
|
|
113
|
+
Once a day at most, a background process asks the public npm registry for the latest version number — the same request `npm outdated` makes. It sends nothing about you: no ID, no settings, no usage. It's the only network request VibeAudio ever makes, and `vibe` never waits for it; the answer is shown the next time. It's skipped in CI, under `npx`, and whenever `VIBE_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER=1` is set.
|
|
114
|
+
|
|
115
|
+
</details>
|
|
116
|
+
|
|
90
117
|
### Then pick how it runs
|
|
91
118
|
|
|
92
119
|
**Using Claude Code, Codex, Cursor or Grok interactively?** Install the hooks — this is the mode that actually tracks thinking:
|
|
@@ -455,7 +482,20 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
|
|
|
455
482
|
| `drone` | 🌫️ **Deep Drone** | A held tone and filtered noise — **no melody at all** |
|
|
456
483
|
| `random` | 🎲 **Shuffle Mode** | Picks a surprise genre for the run — **never `drone`** |
|
|
457
484
|
|
|
458
|
-
Aliases also work
|
|
485
|
+
**Aliases also work**, so you can ask for a genre the way you'd say it — `vibe --preview chill` is `lofi`:
|
|
486
|
+
|
|
487
|
+
| Canonical | Also accepted |
|
|
488
|
+
| :--- | :--- |
|
|
489
|
+
| `lofi` | `lo-fi`, `lofi-hiphop`, `chill`, `chillhop`, `study` |
|
|
490
|
+
| `synthwave` | `retrowave`, `outrun`, `80s` |
|
|
491
|
+
| `8bit` | `chiptune`, `chip`, `nes`, `gameboy`, `retro` |
|
|
492
|
+
| `electronic` | `downtempo`, `techno`, `edm` |
|
|
493
|
+
| `jazz` | `bossa`, `swing`, `lounge` |
|
|
494
|
+
| `zen` | `ambient`, `calm`, `meditation` |
|
|
495
|
+
| `piano` | `sparse`, `satie`, `keys`, `minimal` |
|
|
496
|
+
| `drone` | `noise`, `focus`, `hum`, `whitenoise`, `white-noise` |
|
|
497
|
+
|
|
498
|
+
Matching is case-insensitive, so `BOSSA` works too.
|
|
459
499
|
|
|
460
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.
|
|
461
501
|
>
|
|
@@ -517,6 +557,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
517
557
|
| `--no-chime` | Disable the resolution completion chime | `false` |
|
|
518
558
|
| `--no-hud` | Disable terminal window/tab title animation | `false` |
|
|
519
559
|
| `--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` |
|
|
520
561
|
| `--status` | Show what's installed, running and detected, then exit | — |
|
|
521
562
|
| `--stop` | Stop the background player, then exit | — |
|
|
522
563
|
| `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
|
|
@@ -542,6 +583,7 @@ export VIBE_CHIME_VOLUME=70 # Crisp completion chime at 70%
|
|
|
542
583
|
export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
|
|
543
584
|
export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
|
|
544
585
|
export VIBE_DISABLE=1 # Mute, without uninstalling anything
|
|
586
|
+
export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
|
|
545
587
|
```
|
|
546
588
|
|
|
547
589
|
**`VIBE_DISABLE=1` is for a shell you always want quiet** — a CI job, a shared machine, a terminal profile you keep silent. It's read at playback time and covers hooks, wrapper and MCP alike.
|
|
@@ -682,6 +724,10 @@ Apart from those two, the three commands above remove everything VibeAudio write
|
|
|
682
724
|
|
|
683
725
|
---
|
|
684
726
|
|
|
727
|
+
If VibeAudio is useful to you, consider giving it a ⭐ on [GitHub](https://github.com/kiril6/vibeaudio) — it helps others find it.
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
685
731
|
## 🤝 Contributing
|
|
686
732
|
|
|
687
733
|
Contributions welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the ground rules (zero runtime dependencies, no build step, pure synth modules) and the dev loop. Found a bug? [Open an issue](https://github.com/kiril6/vibeaudio/issues/new) with your **OS**, **Node version**, and which audio player you have installed.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|
|
7
7
|
"vibe": "bin/vibeaudio.js"
|
|
@@ -28,16 +28,25 @@
|
|
|
28
28
|
],
|
|
29
29
|
"keywords": [
|
|
30
30
|
"claude-code",
|
|
31
|
+
"claude",
|
|
31
32
|
"codex",
|
|
32
33
|
"cursor",
|
|
34
|
+
"gemini-cli",
|
|
35
|
+
"copilot",
|
|
33
36
|
"grok",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
+
"qwen",
|
|
38
|
+
"ai",
|
|
39
|
+
"ai-agent",
|
|
40
|
+
"hooks",
|
|
41
|
+
"mcp",
|
|
42
|
+
"music",
|
|
43
|
+
"focus-music",
|
|
37
44
|
"lofi",
|
|
38
45
|
"synthwave",
|
|
39
46
|
"chiptune",
|
|
40
|
-
"
|
|
47
|
+
"productivity",
|
|
48
|
+
"notification",
|
|
49
|
+
"cli",
|
|
41
50
|
"developer-tools"
|
|
42
51
|
],
|
|
43
52
|
"author": "Kiril Delovski <delovski.office@gmail.com>",
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
getAudioPath,
|
|
12
12
|
clearCache,
|
|
13
13
|
detectPlayer,
|
|
14
|
+
bakedGain,
|
|
14
15
|
resolveGenre,
|
|
15
16
|
isKnownGenre,
|
|
16
17
|
normalizeVolume,
|
|
@@ -19,6 +20,7 @@ const {
|
|
|
19
20
|
projectSettings,
|
|
20
21
|
saveProjectConfig,
|
|
21
22
|
wavDurationMs,
|
|
23
|
+
projectSeed,
|
|
22
24
|
AVAILABLE_GENRES
|
|
23
25
|
} = require("./player");
|
|
24
26
|
const pkg = require("../package.json");
|
|
@@ -56,6 +58,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
56
58
|
vibe --genre 8bit sleep 5
|
|
57
59
|
vibe --volume 30 npm test
|
|
58
60
|
vibe --preview jazz
|
|
61
|
+
vibe --render \x1b[90m# save this repo's sound as a .wav to share\x1b[0m
|
|
59
62
|
vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
|
|
60
63
|
|
|
61
64
|
\x1b[1mOPTIONS:\x1b[0m
|
|
@@ -71,6 +74,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
71
74
|
--no-chime Disable the resolution completion chime
|
|
72
75
|
--no-hud Disable terminal window/tab title animation
|
|
73
76
|
--preview <genre> Play one loop of a genre and exit
|
|
77
|
+
--render [file] Write this project's music to a .wav and exit
|
|
74
78
|
--status Show what is installed, running and detected, then exit
|
|
75
79
|
--stop Stop the background player, then exit
|
|
76
80
|
--mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
|
|
@@ -103,7 +107,17 @@ Procedural focus music while your AI coding tools think.
|
|
|
103
107
|
VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
|
|
104
108
|
VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
|
|
105
109
|
VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
|
|
110
|
+
VIBE_NO_UPDATE_CHECK=1 Never check npm for a newer version
|
|
106
111
|
`);
|
|
112
|
+
printUpdateNotice();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Only where a person is reading: help, status and the menu. See update.js.
|
|
116
|
+
function printUpdateNotice() {
|
|
117
|
+
if (!process.stdout.isTTY) return;
|
|
118
|
+
const { ephemeralInstallReason } = require("./hooks");
|
|
119
|
+
const line = require("./update").updateNotice({ ephemeral: Boolean(ephemeralInstallReason()) });
|
|
120
|
+
if (line) console.log(`${line}\n`);
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
/**
|
|
@@ -154,6 +168,7 @@ function parseArgs(argv) {
|
|
|
154
168
|
let noChime = false;
|
|
155
169
|
let noHud = false;
|
|
156
170
|
let preview = null;
|
|
171
|
+
let render = null;
|
|
157
172
|
let clearCacheFlag = false;
|
|
158
173
|
let statusFlag = false;
|
|
159
174
|
let stopFlag = false;
|
|
@@ -240,6 +255,20 @@ function parseArgs(argv) {
|
|
|
240
255
|
continue;
|
|
241
256
|
}
|
|
242
257
|
|
|
258
|
+
// Optional filename: `--render` alone names the file after the genre.
|
|
259
|
+
// Only a non-flag counts, so `vibe --render --genre zen` still parses as
|
|
260
|
+
// both, the way `--mute claude` does.
|
|
261
|
+
if (arg === "--render") {
|
|
262
|
+
i += 1;
|
|
263
|
+
if (args[i] !== undefined && !args[i].startsWith("-")) {
|
|
264
|
+
render = args[i];
|
|
265
|
+
i += 1;
|
|
266
|
+
} else {
|
|
267
|
+
render = "";
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
243
272
|
if (arg === "--clear-cache") {
|
|
244
273
|
clearCacheFlag = true;
|
|
245
274
|
i += 1;
|
|
@@ -391,6 +420,7 @@ function parseArgs(argv) {
|
|
|
391
420
|
noChime,
|
|
392
421
|
noHud,
|
|
393
422
|
preview,
|
|
423
|
+
render,
|
|
394
424
|
clearCache: clearCacheFlag,
|
|
395
425
|
status: statusFlag,
|
|
396
426
|
stop: stopFlag,
|
|
@@ -709,6 +739,7 @@ function printStatus() {
|
|
|
709
739
|
}
|
|
710
740
|
}
|
|
711
741
|
console.log();
|
|
742
|
+
printUpdateNotice();
|
|
712
743
|
}
|
|
713
744
|
|
|
714
745
|
/**
|
|
@@ -904,6 +935,15 @@ function signalExitCode(signal) {
|
|
|
904
935
|
return 128 + (os.constants.signals[signal] || 0);
|
|
905
936
|
}
|
|
906
937
|
|
|
938
|
+
function previewAudioFile(genre, volume, backend) {
|
|
939
|
+
return getAudioPath(
|
|
940
|
+
resolveGenre(genre),
|
|
941
|
+
2,
|
|
942
|
+
projectSeed(),
|
|
943
|
+
bakedGain(backend, volume)
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
907
947
|
function previewGenre(genre, volume) {
|
|
908
948
|
if (!isKnownGenre(genre)) {
|
|
909
949
|
console.error(`\x1b[31m[vibeaudio] Unknown genre '${genre}'.\x1b[0m Available: ${AVAILABLE_GENRES.join(", ")}, random`);
|
|
@@ -917,13 +957,45 @@ function previewGenre(genre, volume) {
|
|
|
917
957
|
}
|
|
918
958
|
|
|
919
959
|
const resolved = resolveGenre(genre);
|
|
920
|
-
const audioFile =
|
|
960
|
+
const audioFile = previewAudioFile(resolved, volume, backend);
|
|
921
961
|
const seconds = ((wavDurationMs(audioFile) || 6500) / 1000).toFixed(1);
|
|
922
962
|
|
|
923
963
|
console.log(`\x1b[36m♫ Previewing \x1b[1m${resolved}\x1b[0m\x1b[36m (tier 2, ${seconds}s) — Ctrl+C to stop\x1b[0m`);
|
|
924
964
|
spawnSync(backend.cmd, backend.args(audioFile, volume), { stdio: "ignore" });
|
|
925
965
|
}
|
|
926
966
|
|
|
967
|
+
/**
|
|
968
|
+
* Writes this project's music to a file, so the thing that is actually
|
|
969
|
+
* distinctive about VibeAudio - that a repo has its own arrangement - is
|
|
970
|
+
* something a user can hear outside their own terminal, and send to someone.
|
|
971
|
+
*
|
|
972
|
+
* Rendered at full scale rather than at the configured volume: a file is
|
|
973
|
+
* played by something with its own volume control, and a quiet render is not
|
|
974
|
+
* recoverable. WAV because it needs no encoder on any platform; it is large,
|
|
975
|
+
* and the caller can compress it with whatever they already have.
|
|
976
|
+
*/
|
|
977
|
+
function renderToFile(target, genre) {
|
|
978
|
+
if (!isKnownGenre(genre)) {
|
|
979
|
+
console.error(`\x1b[31m[vibeaudio] Unknown genre '${genre}'.\x1b[0m Available: ${AVAILABLE_GENRES.join(", ")}, random`);
|
|
980
|
+
process.exit(1);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const { renderPiece } = require("./render");
|
|
984
|
+
const resolved = resolveGenre(genre);
|
|
985
|
+
const seed = projectSeed();
|
|
986
|
+
const file = path.resolve(target || `vibeaudio-${resolved}.wav`);
|
|
987
|
+
|
|
988
|
+
process.stdout.write(`\x1b[36m♫ Rendering \x1b[1m${resolved}\x1b[0m\x1b[36m for ${path.basename(process.cwd())}…\x1b[0m`);
|
|
989
|
+
const piece = renderPiece({ genre: resolved, seed });
|
|
990
|
+
fs.writeFileSync(file, piece.wav);
|
|
991
|
+
|
|
992
|
+
const seconds = (piece.durationMs / 1000).toFixed(0);
|
|
993
|
+
const mb = (piece.wav.length / 1024 / 1024).toFixed(1);
|
|
994
|
+
console.log(`\r\x1b[32m✔ Wrote ${file}\x1b[0m\x1b[K`);
|
|
995
|
+
console.log(` \x1b[90m${seconds}s, ${mb} MB — all three tiers and the success chime, seed ${seed >>> 0}.\x1b[0m`);
|
|
996
|
+
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
997
|
+
}
|
|
998
|
+
|
|
927
999
|
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
|
|
928
1000
|
const hooks = require("./hooks");
|
|
929
1001
|
|
|
@@ -1014,6 +1086,20 @@ function hooksAlreadyCover(cmdArgs, settingsFile = null) {
|
|
|
1014
1086
|
}
|
|
1015
1087
|
}
|
|
1016
1088
|
|
|
1089
|
+
/**
|
|
1090
|
+
* Windows needs a shell for .cmd/.bat wrappers and for bare commands that may
|
|
1091
|
+
* resolve to one (npm, npx, etc). Real executables and explicit paths should
|
|
1092
|
+
* be spawned directly: cmd.exe otherwise breaks paths containing spaces.
|
|
1093
|
+
*/
|
|
1094
|
+
function windowsCommandNeedsShell(command) {
|
|
1095
|
+
const ext = path.extname(command).toLowerCase();
|
|
1096
|
+
|
|
1097
|
+
if (ext === ".cmd" || ext === ".bat") return true;
|
|
1098
|
+
if (ext) return false;
|
|
1099
|
+
|
|
1100
|
+
return !/[\\/]/.test(command);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1017
1103
|
function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false) {
|
|
1018
1104
|
const player = new AudioPlayer();
|
|
1019
1105
|
const hookDriven = hooksAlreadyCover(cmdArgs);
|
|
@@ -1055,7 +1141,7 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
|
|
|
1055
1141
|
|
|
1056
1142
|
const child = spawn(command, commandArgs, {
|
|
1057
1143
|
stdio: "inherit",
|
|
1058
|
-
shell: process.platform === "win32"
|
|
1144
|
+
shell: process.platform === "win32" && windowsCommandNeedsShell(command)
|
|
1059
1145
|
});
|
|
1060
1146
|
|
|
1061
1147
|
// Runs exactly once: both the signal path and the close path lead here.
|
|
@@ -1121,6 +1207,7 @@ async function run() {
|
|
|
1121
1207
|
noChime,
|
|
1122
1208
|
noHud,
|
|
1123
1209
|
preview,
|
|
1210
|
+
render,
|
|
1124
1211
|
clearCache: shouldClear,
|
|
1125
1212
|
status: showStatus,
|
|
1126
1213
|
stop: shouldStop,
|
|
@@ -1207,6 +1294,10 @@ async function run() {
|
|
|
1207
1294
|
return previewGenre(preview, volume);
|
|
1208
1295
|
}
|
|
1209
1296
|
|
|
1297
|
+
if (render !== null) {
|
|
1298
|
+
return renderToFile(render, genre);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1210
1301
|
// Settings typed with no command to run: save them, don't open the launcher.
|
|
1211
1302
|
if (cmdArgs.length === 0 && Object.keys(typed).length > 0) {
|
|
1212
1303
|
return saveDefaults(typed, hereOnly);
|
|
@@ -1226,6 +1317,8 @@ async function run() {
|
|
|
1226
1317
|
// leave. Wrapping the launch too turned every real failure below into a
|
|
1227
1318
|
// silent exit 0, which is the worst possible thing for a wrapper to do:
|
|
1228
1319
|
// `vibe && deploy` would chain on a run that never happened.
|
|
1320
|
+
printUpdateNotice();
|
|
1321
|
+
|
|
1229
1322
|
let selection;
|
|
1230
1323
|
try {
|
|
1231
1324
|
selection = await promptInteractive({
|
|
@@ -1257,4 +1350,10 @@ async function run() {
|
|
|
1257
1350
|
executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud);
|
|
1258
1351
|
}
|
|
1259
1352
|
|
|
1260
|
-
module.exports = {
|
|
1353
|
+
module.exports = {
|
|
1354
|
+
run,
|
|
1355
|
+
parseArgs,
|
|
1356
|
+
hooksAlreadyCover,
|
|
1357
|
+
previewAudioFile,
|
|
1358
|
+
windowsCommandNeedsShell
|
|
1359
|
+
};
|
package/src/player.js
CHANGED
|
@@ -69,6 +69,26 @@ const LOOP_OVERLAP_MS = 120;
|
|
|
69
69
|
const TIER_2_AFTER_MS = 15000;
|
|
70
70
|
const TIER_3_AFTER_MS = 45000;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* How many bars one piece is played as.
|
|
74
|
+
*
|
|
75
|
+
* A tier renders one ~7 second loop and the last tier arrives at 45 seconds,
|
|
76
|
+
* so everything past a turn's first minute used to be a single file on
|
|
77
|
+
* repeat: ~85 identical plays across a ten-minute turn, ~250 across half an
|
|
78
|
+
* hour. Music stays in the background only until the ear starts tracking it,
|
|
79
|
+
* and what it does then is send someone to --mute for good.
|
|
80
|
+
*
|
|
81
|
+
* Each bar rotates to the next of the genre's own curated variants (see
|
|
82
|
+
* `rotate` in synth/generator.js), so the phrase is ~21 seconds instead of
|
|
83
|
+
* ~7 and every bar is still hand-written harmony. Three is not a taste
|
|
84
|
+
* setting: every genre carries exactly three variants, and rotation can only
|
|
85
|
+
* guarantee distinct bars while it has one to move to.
|
|
86
|
+
*
|
|
87
|
+
* Bar 0 is the unrotated variant, so a project still opens on the sound it
|
|
88
|
+
* has always had.
|
|
89
|
+
*/
|
|
90
|
+
const LOOP_BARS = 3;
|
|
91
|
+
|
|
72
92
|
/**
|
|
73
93
|
* Playback backends, in preference order. `volume` marks whether the backend
|
|
74
94
|
* can attenuate; the others play at system volume.
|
|
@@ -171,14 +191,37 @@ function clearCache() {
|
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
const GENRE_ALIASES = {
|
|
194
|
+
"lo-fi": "lofi",
|
|
195
|
+
"lofi-hiphop": "lofi",
|
|
196
|
+
chill: "lofi",
|
|
197
|
+
chillhop: "lofi",
|
|
198
|
+
study: "lofi",
|
|
174
199
|
chiptune: "8bit",
|
|
200
|
+
chip: "8bit",
|
|
201
|
+
nes: "8bit",
|
|
202
|
+
gameboy: "8bit",
|
|
203
|
+
retro: "8bit",
|
|
204
|
+
retrowave: "synthwave",
|
|
205
|
+
outrun: "synthwave",
|
|
206
|
+
"80s": "synthwave",
|
|
175
207
|
downtempo: "electronic",
|
|
208
|
+
techno: "electronic",
|
|
209
|
+
edm: "electronic",
|
|
176
210
|
bossa: "jazz",
|
|
211
|
+
swing: "jazz",
|
|
212
|
+
lounge: "jazz",
|
|
177
213
|
ambient: "zen",
|
|
214
|
+
calm: "zen",
|
|
215
|
+
meditation: "zen",
|
|
178
216
|
noise: "drone",
|
|
179
217
|
focus: "drone",
|
|
218
|
+
hum: "drone",
|
|
219
|
+
whitenoise: "drone",
|
|
220
|
+
"white-noise": "drone",
|
|
180
221
|
sparse: "piano",
|
|
181
|
-
satie: "piano"
|
|
222
|
+
satie: "piano",
|
|
223
|
+
keys: "piano",
|
|
224
|
+
minimal: "piano"
|
|
182
225
|
};
|
|
183
226
|
|
|
184
227
|
function isKnownGenre(genre) {
|
|
@@ -212,25 +255,25 @@ function resolveGenre(genre) {
|
|
|
212
255
|
return GENRE_ALIASES[normalized] || normalized;
|
|
213
256
|
}
|
|
214
257
|
|
|
215
|
-
function generateLoop(genre, tier, seed) {
|
|
258
|
+
function generateLoop(genre, tier, seed, bar = 0) {
|
|
216
259
|
switch (genre) {
|
|
217
260
|
case "synthwave":
|
|
218
|
-
return generateSynthwaveLoop(6.8, tier, seed);
|
|
261
|
+
return generateSynthwaveLoop(6.8, tier, seed, bar);
|
|
219
262
|
case "8bit":
|
|
220
|
-
return generateChiptuneLoop(7.5, tier, seed);
|
|
263
|
+
return generateChiptuneLoop(7.5, tier, seed, bar);
|
|
221
264
|
case "electronic":
|
|
222
|
-
return generateElectronicLoop(6.4, tier, seed);
|
|
265
|
+
return generateElectronicLoop(6.4, tier, seed, bar);
|
|
223
266
|
case "jazz":
|
|
224
|
-
return generateJazzLoop(6.26, tier, seed);
|
|
267
|
+
return generateJazzLoop(6.26, tier, seed, bar);
|
|
225
268
|
case "zen":
|
|
226
|
-
return generateZenLoop(7.2, tier, seed);
|
|
269
|
+
return generateZenLoop(7.2, tier, seed, bar);
|
|
227
270
|
case "drone":
|
|
228
|
-
return generateDroneLoop(7.0, tier, seed);
|
|
271
|
+
return generateDroneLoop(7.0, tier, seed, bar);
|
|
229
272
|
case "piano":
|
|
230
|
-
return generatePianoLoop(7.6, tier, seed);
|
|
273
|
+
return generatePianoLoop(7.6, tier, seed, bar);
|
|
231
274
|
case "lofi":
|
|
232
275
|
default:
|
|
233
|
-
return generateLofiLoop(6.4, tier, seed);
|
|
276
|
+
return generateLofiLoop(6.4, tier, seed, bar);
|
|
234
277
|
}
|
|
235
278
|
}
|
|
236
279
|
|
|
@@ -519,17 +562,23 @@ function writeCacheFileAtomic(filePath, buffer) {
|
|
|
519
562
|
}
|
|
520
563
|
}
|
|
521
564
|
|
|
522
|
-
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1) {
|
|
565
|
+
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0) {
|
|
523
566
|
ensureCacheDir();
|
|
524
567
|
const normalizedGenre = resolveGenre(genre);
|
|
525
568
|
const safeTier = Math.max(1, Math.min(3, tier));
|
|
569
|
+
const safeBar = ((bar % LOOP_BARS) + LOOP_BARS) % LOOP_BARS;
|
|
526
570
|
|
|
571
|
+
// Bars of one piece share the project's seed directory, so a project's
|
|
572
|
+
// music is still pruned as a unit. Bar 0 keeps the unsuffixed name and
|
|
573
|
+
// renders byte-identically to the pre-bars loop, so a project upgrading
|
|
574
|
+
// into this opens on exactly the bar it has always opened on.
|
|
575
|
+
const barSuffix = safeBar === 0 ? "" : `_b${safeBar}`;
|
|
527
576
|
const dir = seedDir(seed);
|
|
528
|
-
const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${gainSuffix(gain)}.wav`);
|
|
577
|
+
const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${barSuffix}${gainSuffix(gain)}.wav`);
|
|
529
578
|
|
|
530
579
|
if (!fs.existsSync(filePath)) {
|
|
531
580
|
fs.mkdirSync(dir, { recursive: true });
|
|
532
|
-
writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0), gain));
|
|
581
|
+
writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0, safeBar), gain));
|
|
533
582
|
pruneSeedDirs();
|
|
534
583
|
}
|
|
535
584
|
|
|
@@ -592,6 +641,7 @@ class AudioPlayer {
|
|
|
592
641
|
this.intensity = null;
|
|
593
642
|
this.minTier = null;
|
|
594
643
|
this.currentTier = 1;
|
|
644
|
+
this.bar = 0;
|
|
595
645
|
this.nextTimer = null;
|
|
596
646
|
this.watchdog = null;
|
|
597
647
|
}
|
|
@@ -621,6 +671,7 @@ class AudioPlayer {
|
|
|
621
671
|
this.genre = resolved;
|
|
622
672
|
this.volume = targetVolume;
|
|
623
673
|
this.seed = projectSeed();
|
|
674
|
+
this.bar = 0; // Every run opens on the project's own bar.
|
|
624
675
|
this.intensity = intensity;
|
|
625
676
|
this.minTier = minTier;
|
|
626
677
|
|
|
@@ -661,7 +712,9 @@ class AudioPlayer {
|
|
|
661
712
|
// never both, or the volume would be applied twice.
|
|
662
713
|
const backend = detectPlayer();
|
|
663
714
|
const gain = bakedGain(backend, this.volume);
|
|
664
|
-
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain);
|
|
715
|
+
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar);
|
|
716
|
+
// Advanced after the choice, so the bar that plays first is bar 0.
|
|
717
|
+
this.bar = (this.bar + 1) % LOOP_BARS;
|
|
665
718
|
const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
|
|
666
719
|
|
|
667
720
|
this.procs.add(proc);
|
|
@@ -748,6 +801,7 @@ module.exports = {
|
|
|
748
801
|
projectSeed,
|
|
749
802
|
wavDurationMs,
|
|
750
803
|
generateLoop,
|
|
804
|
+
LOOP_BARS,
|
|
751
805
|
AVAILABLE_GENRES,
|
|
752
806
|
SHUFFLE_GENRES,
|
|
753
807
|
synthFingerprint,
|
package/src/render.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering a piece to a file, rather than to the speakers.
|
|
3
|
+
*
|
|
4
|
+
* The live player spawns one bar at a time and crossfades the boundary by
|
|
5
|
+
* re-spawning early (LOOP_OVERLAP_MS in player.js); a file has to do that
|
|
6
|
+
* stitching itself, or the seams click. Everything here is pure - buffers in,
|
|
7
|
+
* buffer out - so both callers (`vibe --render` and the landing page's
|
|
8
|
+
* scripts/render-demo.js) get the same audio the player would have produced.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { generateLoop, LOOP_BARS } = require("./player");
|
|
12
|
+
const { generateSuccessChime } = require("./synth/chime");
|
|
13
|
+
const { createWavBuffer } = require("./synth/generator");
|
|
14
|
+
|
|
15
|
+
// Matches the live player's own crossfade window.
|
|
16
|
+
const LOOP_OVERLAP_MS = 120;
|
|
17
|
+
|
|
18
|
+
function parseWav(buf) {
|
|
19
|
+
const numChannels = buf.readUInt16LE(22);
|
|
20
|
+
const sampleRate = buf.readUInt32LE(24);
|
|
21
|
+
const dataSize = buf.readUInt32LE(40);
|
|
22
|
+
const numSamples = Math.floor(dataSize / 2 / numChannels);
|
|
23
|
+
const left = new Float64Array(numSamples);
|
|
24
|
+
const right = numChannels === 2 ? new Float64Array(numSamples) : null;
|
|
25
|
+
let offset = 44;
|
|
26
|
+
for (let i = 0; i < numSamples; i++) {
|
|
27
|
+
left[i] = buf.readInt16LE(offset) / 32768;
|
|
28
|
+
offset += 2;
|
|
29
|
+
if (right) {
|
|
30
|
+
right[i] = buf.readInt16LE(offset) / 32768;
|
|
31
|
+
offset += 2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { sampleRate, left, right };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Crossfades consecutive clips over `overlapMs`, mirroring the live player's loop boundary. */
|
|
38
|
+
function crossfadeConcat(clips, overlapMs = LOOP_OVERLAP_MS) {
|
|
39
|
+
const sampleRate = clips[0].sampleRate;
|
|
40
|
+
const overlapSamples = Math.floor((sampleRate * overlapMs) / 1000);
|
|
41
|
+
const stereo = !!clips[0].right;
|
|
42
|
+
let totalLen = 0;
|
|
43
|
+
for (const c of clips) totalLen += c.left.length;
|
|
44
|
+
totalLen -= overlapSamples * (clips.length - 1);
|
|
45
|
+
|
|
46
|
+
const outL = new Float64Array(totalLen);
|
|
47
|
+
const outR = stereo ? new Float64Array(totalLen) : null;
|
|
48
|
+
const starts = [];
|
|
49
|
+
let pos = 0;
|
|
50
|
+
|
|
51
|
+
for (let ci = 0; ci < clips.length; ci++) {
|
|
52
|
+
const c = clips[ci];
|
|
53
|
+
starts.push(pos);
|
|
54
|
+
for (let i = 0; i < c.left.length; i++) {
|
|
55
|
+
const idx = pos + i;
|
|
56
|
+
if (idx >= totalLen) break;
|
|
57
|
+
let gL = c.left[i];
|
|
58
|
+
let gR = stereo ? c.right[i] : 0;
|
|
59
|
+
if (ci > 0 && i < overlapSamples) {
|
|
60
|
+
const fadeIn = i / overlapSamples;
|
|
61
|
+
gL *= fadeIn;
|
|
62
|
+
gR *= fadeIn;
|
|
63
|
+
}
|
|
64
|
+
if (ci < clips.length - 1 && i >= c.left.length - overlapSamples) {
|
|
65
|
+
const fadeOut = (c.left.length - i) / overlapSamples;
|
|
66
|
+
gL *= fadeOut;
|
|
67
|
+
gR *= fadeOut;
|
|
68
|
+
}
|
|
69
|
+
outL[idx] += gL;
|
|
70
|
+
if (outR) outR[idx] += gR;
|
|
71
|
+
}
|
|
72
|
+
pos += c.left.length - overlapSamples;
|
|
73
|
+
}
|
|
74
|
+
return { sampleRate, left: outL, right: outR, starts, totalLen };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Plain append, no crossfade - used for the trailing chime. */
|
|
78
|
+
function append(a, b) {
|
|
79
|
+
const totalLen = a.left.length + b.left.length;
|
|
80
|
+
const stereo = !!a.right;
|
|
81
|
+
const outL = new Float64Array(totalLen);
|
|
82
|
+
const outR = stereo ? new Float64Array(totalLen) : null;
|
|
83
|
+
outL.set(a.left, 0);
|
|
84
|
+
outL.set(b.left, a.left.length);
|
|
85
|
+
if (stereo) {
|
|
86
|
+
outR.set(a.right, 0);
|
|
87
|
+
outR.set(b.right, a.left.length);
|
|
88
|
+
}
|
|
89
|
+
return { sampleRate: a.sampleRate, left: outL, right: outR, chimeStart: a.left.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A whole session as one file: every bar of the phrase at each tier in turn,
|
|
94
|
+
* then the success chime - what someone would have heard sitting through a
|
|
95
|
+
* turn long enough to reach tier 3.
|
|
96
|
+
*
|
|
97
|
+
* `tiers` repeated per bar rather than per tier, so the escalation is audible
|
|
98
|
+
* without the phrase being cut short at the point it starts to make sense.
|
|
99
|
+
*/
|
|
100
|
+
function renderPiece({ genre = "lofi", seed = 0, tiers = [1, 2, 3], chime = true } = {}) {
|
|
101
|
+
const clips = [];
|
|
102
|
+
const tierStarts = [];
|
|
103
|
+
for (const tier of tiers) {
|
|
104
|
+
tierStarts.push(clips.length);
|
|
105
|
+
for (let bar = 0; bar < LOOP_BARS; bar++) {
|
|
106
|
+
clips.push(parseWav(generateLoop(genre, tier, seed, bar)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const stitched = crossfadeConcat(clips);
|
|
111
|
+
const piece = chime ? append(stitched, parseWav(generateSuccessChime())) : stitched;
|
|
112
|
+
const wav = createWavBuffer({ left: piece.left, right: piece.right, sampleRate: piece.sampleRate });
|
|
113
|
+
|
|
114
|
+
const msAt = (samples) => Math.round((samples / piece.sampleRate) * 1000);
|
|
115
|
+
return {
|
|
116
|
+
wav,
|
|
117
|
+
durationMs: msAt(piece.left.length),
|
|
118
|
+
tierStartsMs: tierStarts.map((i) => msAt(stitched.starts[i])),
|
|
119
|
+
chimeStartMs: chime ? msAt(piece.chimeStart) : null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { parseWav, crossfadeConcat, append, renderPiece, LOOP_OVERLAP_MS };
|
package/src/synth/chiptune.js
CHANGED
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
softPulse,
|
|
10
10
|
triangle,
|
|
11
11
|
makeRng,
|
|
12
|
-
|
|
12
|
+
rotate,
|
|
13
13
|
createWavBuffer
|
|
14
14
|
} = require("./generator");
|
|
15
15
|
|
|
@@ -33,8 +33,8 @@ const VARIANTS = [
|
|
|
33
33
|
}
|
|
34
34
|
];
|
|
35
35
|
|
|
36
|
-
function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0) {
|
|
37
|
-
const variant =
|
|
36
|
+
function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0, bar = 0) {
|
|
37
|
+
const variant = rotate(makeRng(seed), VARIANTS, bar);
|
|
38
38
|
const bpm = 128;
|
|
39
39
|
const secPerBeat = 60.0 / bpm;
|
|
40
40
|
const totalBeats = Math.floor(durationSec / secPerBeat);
|
package/src/synth/drone.js
CHANGED
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
noteToFreq,
|
|
13
13
|
sine,
|
|
14
14
|
makeRng,
|
|
15
|
-
|
|
15
|
+
rotate,
|
|
16
16
|
ornamentRng,
|
|
17
17
|
createWavBuffer
|
|
18
18
|
} = require("./generator");
|
|
@@ -26,10 +26,10 @@ const DRONE_VARIANTS = [
|
|
|
26
26
|
{ root: "E1", partial: 1.335, cutoff: 0.070, label: "open fourth" } // E + A
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
function generateDroneLoop(durationSec = 7.0, tier = 2, seed = 0) {
|
|
29
|
+
function generateDroneLoop(durationSec = 7.0, tier = 2, seed = 0, bar = 0) {
|
|
30
30
|
// Drawn before any tier gating, so gating a layer can't shift these.
|
|
31
31
|
const orn = ornamentRng(seed);
|
|
32
|
-
const variant =
|
|
32
|
+
const variant = rotate(orn, DRONE_VARIANTS, bar);
|
|
33
33
|
const sweepDepth = 0.55 + orn() * 0.35;
|
|
34
34
|
const breathOffset = orn() * Math.PI * 2;
|
|
35
35
|
|
package/src/synth/electronic.js
CHANGED
|
@@ -11,7 +11,7 @@ const {
|
|
|
11
11
|
triangle,
|
|
12
12
|
sine,
|
|
13
13
|
makeRng,
|
|
14
|
-
|
|
14
|
+
rotate,
|
|
15
15
|
ornamentRng,
|
|
16
16
|
pluckEnv,
|
|
17
17
|
createWavBuffer
|
|
@@ -34,8 +34,8 @@ const VARIANTS = [
|
|
|
34
34
|
}
|
|
35
35
|
];
|
|
36
36
|
|
|
37
|
-
function generateElectronicLoop(durationSec = 6.4, tier = 2, seed = 0) {
|
|
38
|
-
const variant =
|
|
37
|
+
function generateElectronicLoop(durationSec = 6.4, tier = 2, seed = 0, bar = 0) {
|
|
38
|
+
const variant = rotate(makeRng(seed), VARIANTS, bar);
|
|
39
39
|
const orn = ornamentRng(seed);
|
|
40
40
|
const bpm = 116;
|
|
41
41
|
const secPerBeat = 60.0 / bpm;
|
package/src/synth/generator.js
CHANGED
|
@@ -59,6 +59,28 @@ function pick(rng, options) {
|
|
|
59
59
|
return options[Math.floor(rng() * options.length) % options.length];
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The seed picks which curated variant a project gets; `bar` steps to the next
|
|
64
|
+
* one along, so one piece can be several bars long without any of them being
|
|
65
|
+
* invented.
|
|
66
|
+
*
|
|
67
|
+
* A loop is ~7 seconds and a tier stops changing at 45, so without this every
|
|
68
|
+
* turn past its first minute was one file on repeat - 85 identical plays
|
|
69
|
+
* across ten minutes, 250 across half an hour. Rotation is what makes the
|
|
70
|
+
* bars *guaranteed* different: salting the seed instead leaves it to chance,
|
|
71
|
+
* and with three variants per genre, three salted draws land on three
|
|
72
|
+
* different ones only 2 times in 9.
|
|
73
|
+
*
|
|
74
|
+
* Draws exactly once, like `pick`, so a stream shared with ornaments below is
|
|
75
|
+
* left in the same place and `bar = 0` renders what the seed alone always
|
|
76
|
+
* rendered.
|
|
77
|
+
*/
|
|
78
|
+
function rotate(rng, options, bar = 0) {
|
|
79
|
+
const index = Math.floor(rng() * options.length) % options.length;
|
|
80
|
+
const step = ((bar % options.length) + options.length) % options.length;
|
|
81
|
+
return options[(index + step) % options.length];
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
/**
|
|
63
85
|
* Ornament placement draws from its own stream so that gating a layer by tier
|
|
64
86
|
* can't shift the choices made by other layers - tier 1 and tier 3 stay the
|
|
@@ -196,6 +218,7 @@ module.exports = {
|
|
|
196
218
|
hashString,
|
|
197
219
|
pick,
|
|
198
220
|
ornamentRng,
|
|
221
|
+
rotate,
|
|
199
222
|
noteToFreq,
|
|
200
223
|
sine,
|
|
201
224
|
triangle,
|
package/src/synth/jazz.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
sine,
|
|
10
10
|
triangle,
|
|
11
11
|
makeRng,
|
|
12
|
+
rotate,
|
|
12
13
|
pick,
|
|
13
14
|
ornamentRng,
|
|
14
15
|
createWavBuffer
|
|
@@ -36,9 +37,9 @@ const CHANGES = [
|
|
|
36
37
|
|
|
37
38
|
const STAB_NOTES = ["D5", "E5", "G5", "A5", "B5"];
|
|
38
39
|
|
|
39
|
-
function generateJazzLoop(durationSec = 6.26, tier = 2, seed = 0) {
|
|
40
|
+
function generateJazzLoop(durationSec = 6.26, tier = 2, seed = 0, bar = 0) {
|
|
40
41
|
const rng = makeRng(seed);
|
|
41
|
-
const changes =
|
|
42
|
+
const changes = rotate(rng, CHANGES, bar);
|
|
42
43
|
const bpm = 92;
|
|
43
44
|
const secPerBeat = 60.0 / bpm;
|
|
44
45
|
const totalBeats = Math.floor(durationSec / secPerBeat);
|
package/src/synth/lofi.js
CHANGED
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
SAMPLE_RATE,
|
|
8
8
|
noteToFreq,
|
|
9
9
|
makeRng,
|
|
10
|
+
rotate,
|
|
10
11
|
pick,
|
|
11
12
|
ornamentRng,
|
|
12
13
|
createWavBuffer
|
|
@@ -24,9 +25,9 @@ const PROGRESSIONS = [
|
|
|
24
25
|
const DROP_NOTES = ["C5", "D5", "E5", "G5", "A5", "B5", "C6"];
|
|
25
26
|
const SHIMMER_NOTES = ["E6", "G6", "A6", "B6"];
|
|
26
27
|
|
|
27
|
-
function generateLofiLoop(durationSec = 6.4, tier = 2, seed = 0) {
|
|
28
|
+
function generateLofiLoop(durationSec = 6.4, tier = 2, seed = 0, bar = 0) {
|
|
28
29
|
const rng = makeRng(seed);
|
|
29
|
-
const progression =
|
|
30
|
+
const progression = rotate(rng, PROGRESSIONS, bar);
|
|
30
31
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
31
32
|
const left = new Float64Array(totalSamples);
|
|
32
33
|
const right = new Float64Array(totalSamples);
|
package/src/synth/piano.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
noteToFreq,
|
|
14
14
|
sine,
|
|
15
15
|
makeRng,
|
|
16
|
+
rotate,
|
|
16
17
|
pick,
|
|
17
18
|
ornamentRng,
|
|
18
19
|
createWavBuffer
|
|
@@ -52,9 +53,9 @@ const PARTIALS = [
|
|
|
52
53
|
{ mult: 5.05, amp: 0.035, decay: 4.5 }
|
|
53
54
|
];
|
|
54
55
|
|
|
55
|
-
function generatePianoLoop(durationSec = 7.6, tier = 2, seed = 0) {
|
|
56
|
+
function generatePianoLoop(durationSec = 7.6, tier = 2, seed = 0, bar = 0) {
|
|
56
57
|
const rng = makeRng(seed);
|
|
57
|
-
const variant =
|
|
58
|
+
const variant = rotate(rng, PIANO_VARIANTS, bar);
|
|
58
59
|
|
|
59
60
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
60
61
|
const left = new Float64Array(totalSamples);
|
package/src/synth/synthwave.js
CHANGED
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
analogSaw,
|
|
10
10
|
triangle,
|
|
11
11
|
makeRng,
|
|
12
|
-
|
|
12
|
+
rotate,
|
|
13
13
|
pluckEnv,
|
|
14
14
|
createWavBuffer
|
|
15
15
|
} = require("./generator");
|
|
@@ -52,8 +52,8 @@ const PROGRESSIONS = [
|
|
|
52
52
|
}
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
-
function generateSynthwaveLoop(durationSec = 6.8, tier = 2, seed = 0) {
|
|
56
|
-
const variant =
|
|
55
|
+
function generateSynthwaveLoop(durationSec = 6.8, tier = 2, seed = 0, bar = 0) {
|
|
56
|
+
const variant = rotate(makeRng(seed), PROGRESSIONS, bar);
|
|
57
57
|
const progression = variant.steps;
|
|
58
58
|
const arps = variant.arps;
|
|
59
59
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
package/src/synth/zen.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
noteToFreq,
|
|
9
9
|
sine,
|
|
10
10
|
makeRng,
|
|
11
|
+
rotate,
|
|
11
12
|
pick,
|
|
12
13
|
ornamentRng,
|
|
13
14
|
createWavBuffer
|
|
@@ -24,9 +25,9 @@ const PAD_PAIRS = [
|
|
|
24
25
|
// D major pentatonic - the safest set to strike a bowl on over any pad above.
|
|
25
26
|
const BOWL_NOTES = ["D4", "E4", "F#4", "A4", "B4", "D5"];
|
|
26
27
|
|
|
27
|
-
function generateZenLoop(durationSec = 7.2, tier = 2, seed = 0) {
|
|
28
|
+
function generateZenLoop(durationSec = 7.2, tier = 2, seed = 0, bar = 0) {
|
|
28
29
|
const rng = makeRng(seed);
|
|
29
|
-
const pads =
|
|
30
|
+
const pads = rotate(rng, PAD_PAIRS, bar);
|
|
30
31
|
|
|
31
32
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
32
33
|
const left = new Float64Array(totalSamples);
|
package/src/update.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "A newer version is out", said where a person is looking.
|
|
3
|
+
*
|
|
4
|
+
* A global CLI has no other way to reach its users: nothing tells them to
|
|
5
|
+
* re-run `npm i -g`, so fixes never arrive. This asks the registry at most once
|
|
6
|
+
* a day, in a detached process, and shows the answer the *next* time - a run
|
|
7
|
+
* never waits on the network, and an offline machine is simply never told.
|
|
8
|
+
*
|
|
9
|
+
* The request is the one `npm outdated` makes, to the public registry, and
|
|
10
|
+
* carries nothing about the user: no id, no settings, no usage. It is the only
|
|
11
|
+
* network call VibeAudio makes, which is why it can be switched off.
|
|
12
|
+
*
|
|
13
|
+
* Shown only by `--status`, `--help` and the menu. Never by hooks - their
|
|
14
|
+
* output goes to the agent, not the person - and never by the wrapper, whose
|
|
15
|
+
* terminal belongs to the command it runs.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { spawn } = require("child_process");
|
|
21
|
+
const pkg = require("../package.json");
|
|
22
|
+
|
|
23
|
+
const CHECK_FILE = path.join(os.homedir(), ".vibeaudio", "update.json");
|
|
24
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${pkg.name}/latest`;
|
|
26
|
+
|
|
27
|
+
// CI and the de-facto standard opt-out (update-notifier's) as well as our own.
|
|
28
|
+
function checkDisabled(env = process.env) {
|
|
29
|
+
return Boolean(env.VIBE_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Numeric x.y.z comparison; a prerelease or malformed version is never "newer". */
|
|
33
|
+
function isNewer(latest, current) {
|
|
34
|
+
const parse = (v) => (/^\d+\.\d+\.\d+$/.test(v || "") ? v.split(".").map(Number) : null);
|
|
35
|
+
const a = parse(latest);
|
|
36
|
+
const b = parse(current);
|
|
37
|
+
if (!a || !b) return false;
|
|
38
|
+
for (let i = 0; i < 3; i++) {
|
|
39
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readCheck(file) {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
47
|
+
return data && typeof data === "object" ? data : {};
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeCheck(file, data) {
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
+
// Per-process temp name: two runs refreshing at once must not share one.
|
|
56
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
57
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`);
|
|
58
|
+
fs.renameSync(tmp, file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function refreshInBackground(file) {
|
|
62
|
+
try {
|
|
63
|
+
// Stamp the attempt first, so a burst of runs starts one check, not many.
|
|
64
|
+
writeCheck(file, { ...readCheck(file), checked: Date.now() });
|
|
65
|
+
const child = spawn(process.execPath, [__filename, file], { detached: true, stdio: "ignore" });
|
|
66
|
+
child.unref();
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// An update notice is never worth an error.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The line to print, or null. Synchronous and offline: it reads what the last
|
|
74
|
+
* check found, and starts the next check if that one is a day old.
|
|
75
|
+
* `ephemeral`: running from an npx cache, where "npm i -g" is the wrong advice
|
|
76
|
+
* and npx fetches the latest on its own anyway.
|
|
77
|
+
*/
|
|
78
|
+
function updateNotice({ file = CHECK_FILE, env = process.env, now = Date.now(), current = pkg.version, ephemeral = false, refresh = refreshInBackground } = {}) {
|
|
79
|
+
if (checkDisabled(env) || ephemeral) return null;
|
|
80
|
+
|
|
81
|
+
const state = readCheck(file);
|
|
82
|
+
if (!(now - (state.checked || 0) < CHECK_INTERVAL_MS)) refresh(file);
|
|
83
|
+
|
|
84
|
+
if (!isNewer(state.latest, current)) return null;
|
|
85
|
+
return `\x1b[33mUpdate available ${current} → ${state.latest}\x1b[0m \x1b[1mnpm i -g ${pkg.name}\x1b[0m` +
|
|
86
|
+
` \x1b[90m(what's new: https://github.com/kiril6/vibeaudio/releases)\x1b[0m`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The detached child: one request, bounded, and silent whatever happens.
|
|
90
|
+
async function runCheck(file) {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(REGISTRY_URL, {
|
|
93
|
+
headers: { accept: "application/json" },
|
|
94
|
+
signal: AbortSignal.timeout(5000)
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) return;
|
|
97
|
+
const { version } = await res.json();
|
|
98
|
+
if (typeof version === "string") writeCheck(file, { ...readCheck(file), latest: version });
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Offline, blocked, or a registry hiccup: try again tomorrow.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (require.main === module) runCheck(process.argv[2] || CHECK_FILE);
|
|
105
|
+
|
|
106
|
+
module.exports = { updateNotice, isNewer, checkDisabled, CHECK_FILE };
|