vibeaudio 0.7.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,14 @@ 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
+
90
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).
91
109
 
92
110
  <details>
@@ -464,7 +482,20 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
464
482
  | `drone` | 🌫️ **Deep Drone** | A held tone and filtered noise — **no melody at all** |
465
483
  | `random` | 🎲 **Shuffle Mode** | Picks a surprise genre for the run — **never `drone`** |
466
484
 
467
- Aliases also work: `chiptune` → `8bit`, `downtempo` → `electronic`, `bossa` → `jazz`, `ambient` → `zen`, `sparse`/`satie` → `piano`, `noise`/`focus` → `drone`.
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.
468
499
 
469
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.
470
501
  >
@@ -526,6 +557,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
526
557
  | `--no-chime` | Disable the resolution completion chime | `false` |
527
558
  | `--no-hud` | Disable terminal window/tab title animation | `false` |
528
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` |
529
561
  | `--status` | Show what's installed, running and detected, then exit | — |
530
562
  | `--stop` | Stop the background player, then exit | — |
531
563
  | `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
@@ -692,6 +724,10 @@ Apart from those two, the three commands above remove everything VibeAudio write
692
724
 
693
725
  ---
694
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
+
695
731
  ## 🤝 Contributing
696
732
 
697
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.7.0",
4
- "description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think \u2014 a different arrangement per project.",
3
+ "version": "0.8.2",
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
- "gemini",
35
- "cli",
36
- "audio",
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
- "focus-music",
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)
@@ -164,6 +168,7 @@ function parseArgs(argv) {
164
168
  let noChime = false;
165
169
  let noHud = false;
166
170
  let preview = null;
171
+ let render = null;
167
172
  let clearCacheFlag = false;
168
173
  let statusFlag = false;
169
174
  let stopFlag = false;
@@ -250,6 +255,20 @@ function parseArgs(argv) {
250
255
  continue;
251
256
  }
252
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
+
253
272
  if (arg === "--clear-cache") {
254
273
  clearCacheFlag = true;
255
274
  i += 1;
@@ -401,6 +420,7 @@ function parseArgs(argv) {
401
420
  noChime,
402
421
  noHud,
403
422
  preview,
423
+ render,
404
424
  clearCache: clearCacheFlag,
405
425
  status: statusFlag,
406
426
  stop: stopFlag,
@@ -915,6 +935,15 @@ function signalExitCode(signal) {
915
935
  return 128 + (os.constants.signals[signal] || 0);
916
936
  }
917
937
 
938
+ function previewAudioFile(genre, volume, backend) {
939
+ return getAudioPath(
940
+ resolveGenre(genre),
941
+ 2,
942
+ projectSeed(),
943
+ bakedGain(backend, volume)
944
+ );
945
+ }
946
+
918
947
  function previewGenre(genre, volume) {
919
948
  if (!isKnownGenre(genre)) {
920
949
  console.error(`\x1b[31m[vibeaudio] Unknown genre '${genre}'.\x1b[0m Available: ${AVAILABLE_GENRES.join(", ")}, random`);
@@ -928,13 +957,45 @@ function previewGenre(genre, volume) {
928
957
  }
929
958
 
930
959
  const resolved = resolveGenre(genre);
931
- const audioFile = getAudioPath(resolved, 2);
960
+ const audioFile = previewAudioFile(resolved, volume, backend);
932
961
  const seconds = ((wavDurationMs(audioFile) || 6500) / 1000).toFixed(1);
933
962
 
934
963
  console.log(`\x1b[36m♫ Previewing \x1b[1m${resolved}\x1b[0m\x1b[36m (tier 2, ${seconds}s) — Ctrl+C to stop\x1b[0m`);
935
964
  spawnSync(backend.cmd, backend.args(audioFile, volume), { stdio: "ignore" });
936
965
  }
937
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
+
938
999
  function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
939
1000
  const hooks = require("./hooks");
940
1001
 
@@ -1025,6 +1086,20 @@ function hooksAlreadyCover(cmdArgs, settingsFile = null) {
1025
1086
  }
1026
1087
  }
1027
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
+
1028
1103
  function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false) {
1029
1104
  const player = new AudioPlayer();
1030
1105
  const hookDriven = hooksAlreadyCover(cmdArgs);
@@ -1066,7 +1141,7 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
1066
1141
 
1067
1142
  const child = spawn(command, commandArgs, {
1068
1143
  stdio: "inherit",
1069
- shell: process.platform === "win32"
1144
+ shell: process.platform === "win32" && windowsCommandNeedsShell(command)
1070
1145
  });
1071
1146
 
1072
1147
  // Runs exactly once: both the signal path and the close path lead here.
@@ -1132,6 +1207,7 @@ async function run() {
1132
1207
  noChime,
1133
1208
  noHud,
1134
1209
  preview,
1210
+ render,
1135
1211
  clearCache: shouldClear,
1136
1212
  status: showStatus,
1137
1213
  stop: shouldStop,
@@ -1218,6 +1294,10 @@ async function run() {
1218
1294
  return previewGenre(preview, volume);
1219
1295
  }
1220
1296
 
1297
+ if (render !== null) {
1298
+ return renderToFile(render, genre);
1299
+ }
1300
+
1221
1301
  // Settings typed with no command to run: save them, don't open the launcher.
1222
1302
  if (cmdArgs.length === 0 && Object.keys(typed).length > 0) {
1223
1303
  return saveDefaults(typed, hereOnly);
@@ -1270,4 +1350,10 @@ async function run() {
1270
1350
  executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud);
1271
1351
  }
1272
1352
 
1273
- module.exports = { run, parseArgs, hooksAlreadyCover };
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
 
@@ -251,9 +294,21 @@ function seedDir(seed) {
251
294
  return path.join(CACHE_DIR, `s${seed >>> 0}`);
252
295
  }
253
296
 
254
- /** Keep a small working set of projects cached; regenerating costs ~100ms. */
297
+ /**
298
+ * Keep a small working set of projects cached; regenerating costs ~100ms.
299
+ *
300
+ * A directory touched within PRUNE_MIN_AGE_MS is left alone whoever owns it.
301
+ * Another process may be part-way through a render there - it creates the
302
+ * directory, then writes its .tmp into it - and deleting it underneath that
303
+ * write throws ENOENT on the playback path, which is the one thing audio is
304
+ * never allowed to do. Time is the only signal we have across processes;
305
+ * skipping only our own directory would not have helped the other one.
306
+ */
307
+ const PRUNE_MIN_AGE_MS = 10_000;
308
+
255
309
  function pruneSeedDirs(keep = 3) {
256
310
  try {
311
+ const now = Date.now();
257
312
  const dirs = fs
258
313
  .readdirSync(CACHE_DIR, { withFileTypes: true })
259
314
  .filter((e) => e.isDirectory() && /^s\d+$/.test(e.name))
@@ -264,6 +319,7 @@ function pruneSeedDirs(keep = 3) {
264
319
  .sort((a, b) => b.mtime - a.mtime);
265
320
 
266
321
  for (const stale of dirs.slice(keep)) {
322
+ if (now - stale.mtime < PRUNE_MIN_AGE_MS) continue;
267
323
  fs.rmSync(stale.full, { recursive: true, force: true });
268
324
  }
269
325
  } catch (e) {
@@ -506,6 +562,23 @@ function writeCacheFileAtomic(filePath, buffer) {
506
562
  } catch (e) {
507
563
  fs.rmSync(tmp, { force: true });
508
564
 
565
+ // The seed directory can be pruned by another process between our mkdir
566
+ // and this write. The mtime floor in pruneSeedDirs makes that rare rather
567
+ // than impossible, so recreate the directory and try once more - a second
568
+ // ENOENT would mean a prune landed inside a window of microseconds.
569
+ if (e.code === "ENOENT") {
570
+ try {
571
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
572
+ fs.writeFileSync(tmp, buffer);
573
+ fs.renameSync(tmp, filePath);
574
+ return;
575
+ } catch (retryErr) {
576
+ fs.rmSync(tmp, { force: true });
577
+ if (fs.existsSync(filePath)) return;
578
+ throw retryErr;
579
+ }
580
+ }
581
+
509
582
  // POSIX rename replaces the destination silently. Windows does not: it
510
583
  // fails with EPERM/EACCES when another process holds the destination
511
584
  // open - which is precisely the concurrent case this function exists to
@@ -519,17 +592,23 @@ function writeCacheFileAtomic(filePath, buffer) {
519
592
  }
520
593
  }
521
594
 
522
- function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1) {
595
+ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0) {
523
596
  ensureCacheDir();
524
597
  const normalizedGenre = resolveGenre(genre);
525
598
  const safeTier = Math.max(1, Math.min(3, tier));
599
+ const safeBar = ((bar % LOOP_BARS) + LOOP_BARS) % LOOP_BARS;
526
600
 
601
+ // Bars of one piece share the project's seed directory, so a project's
602
+ // music is still pruned as a unit. Bar 0 keeps the unsuffixed name and
603
+ // renders byte-identically to the pre-bars loop, so a project upgrading
604
+ // into this opens on exactly the bar it has always opened on.
605
+ const barSuffix = safeBar === 0 ? "" : `_b${safeBar}`;
527
606
  const dir = seedDir(seed);
528
- const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${gainSuffix(gain)}.wav`);
607
+ const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${barSuffix}${gainSuffix(gain)}.wav`);
529
608
 
530
609
  if (!fs.existsSync(filePath)) {
531
610
  fs.mkdirSync(dir, { recursive: true });
532
- writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0), gain));
611
+ writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0, safeBar), gain));
533
612
  pruneSeedDirs();
534
613
  }
535
614
 
@@ -592,6 +671,7 @@ class AudioPlayer {
592
671
  this.intensity = null;
593
672
  this.minTier = null;
594
673
  this.currentTier = 1;
674
+ this.bar = 0;
595
675
  this.nextTimer = null;
596
676
  this.watchdog = null;
597
677
  }
@@ -621,6 +701,7 @@ class AudioPlayer {
621
701
  this.genre = resolved;
622
702
  this.volume = targetVolume;
623
703
  this.seed = projectSeed();
704
+ this.bar = 0; // Every run opens on the project's own bar.
624
705
  this.intensity = intensity;
625
706
  this.minTier = minTier;
626
707
 
@@ -661,7 +742,9 @@ class AudioPlayer {
661
742
  // never both, or the volume would be applied twice.
662
743
  const backend = detectPlayer();
663
744
  const gain = bakedGain(backend, this.volume);
664
- const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain);
745
+ const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar);
746
+ // Advanced after the choice, so the bar that plays first is bar 0.
747
+ this.bar = (this.bar + 1) % LOOP_BARS;
665
748
  const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
666
749
 
667
750
  this.procs.add(proc);
@@ -736,6 +819,8 @@ class AudioPlayer {
736
819
  module.exports = {
737
820
  AudioPlayer,
738
821
  getAudioPath,
822
+ pruneSeedDirs,
823
+ writeCacheFileAtomic,
739
824
  getChimePath,
740
825
  clearCache,
741
826
  detectPlayer,
@@ -748,6 +833,7 @@ module.exports = {
748
833
  projectSeed,
749
834
  wavDurationMs,
750
835
  generateLoop,
836
+ LOOP_BARS,
751
837
  AVAILABLE_GENRES,
752
838
  SHUFFLE_GENRES,
753
839
  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 };
@@ -9,7 +9,7 @@ const {
9
9
  softPulse,
10
10
  triangle,
11
11
  makeRng,
12
- pick,
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 = pick(makeRng(seed), VARIANTS);
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);
@@ -12,7 +12,7 @@ const {
12
12
  noteToFreq,
13
13
  sine,
14
14
  makeRng,
15
- pick,
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 = pick(orn, DRONE_VARIANTS);
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
 
@@ -11,7 +11,7 @@ const {
11
11
  triangle,
12
12
  sine,
13
13
  makeRng,
14
- pick,
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 = pick(makeRng(seed), VARIANTS);
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;
@@ -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 = pick(rng, 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 = pick(rng, PROGRESSIONS);
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);
@@ -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 = pick(rng, PIANO_VARIANTS);
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);
@@ -9,7 +9,7 @@ const {
9
9
  analogSaw,
10
10
  triangle,
11
11
  makeRng,
12
- pick,
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 = pick(makeRng(seed), PROGRESSIONS);
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 = pick(rng, PAD_PAIRS);
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);