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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🎧 VibeAudio
2
2
 
3
- [![test](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml/badge.svg)](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml)
3
+ [![test](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml/badge.svg)](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/vibeaudio)](https://www.npmjs.com/package/vibeaudio) [![downloads](https://img.shields.io/npm/d18m/vibeaudio?label=downloads)](https://npm-stat.com/charts.html?package=vibeaudio) [![downloads/month](https://img.shields.io/npm/dm/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: `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.
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.6.3",
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.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
- "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)
@@ -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 = getAudioPath(resolved, 2);
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 = { 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
 
@@ -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 };
@@ -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);
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 };