vibeaudio 0.5.0 → 0.6.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.
@@ -52,7 +52,48 @@ const GENRES = [
52
52
  { name: "🎲 Shuffle / Random", desc: "Picks a surprise vibe each time", id: "random" }
53
53
  ];
54
54
 
55
- function selectMenu(title, items, renderItem) {
55
+
56
+ /**
57
+ * One loop of a genre, started from inside the menu and replaced on each
58
+ * press. Non-blocking: the menu stays interactive while it plays, so you can
59
+ * page through the list and hear each one.
60
+ *
61
+ * The process is module-level rather than per-menu because there is only ever
62
+ * one pair of speakers - a second press has to silence the first, or two
63
+ * genres play over each other.
64
+ */
65
+ let previewChild = null;
66
+
67
+ function stopPreview() {
68
+ if (!previewChild) return;
69
+ try {
70
+ previewChild.kill();
71
+ } catch (e) {
72
+ // Already gone - the loop ended on its own.
73
+ }
74
+ previewChild = null;
75
+ }
76
+
77
+ function playPreview(genreId) {
78
+ const { spawn } = require("child_process");
79
+ const { detectPlayer, getAudioPath, resolveGenre, normalizeVolume, loadConfig, playbackDisabled } = require("./player");
80
+
81
+ stopPreview();
82
+ // A muted user pressing "p" is asking to hear something, the same as
83
+ // `--preview` is - but a mute set for a call should still hold. Silence is
84
+ // the safe read, and --status says why.
85
+ if (playbackDisabled()) return;
86
+
87
+ const backend = detectPlayer();
88
+ if (!backend) return;
89
+
90
+ const volume = normalizeVolume(loadConfig().volume, 0.4);
91
+ const file = getAudioPath(resolveGenre(genreId), 2);
92
+ previewChild = spawn(backend.cmd, backend.args(file, volume), { stdio: "ignore" });
93
+ previewChild.on("error", () => { previewChild = null; });
94
+ }
95
+
96
+ function selectMenu(title, items, renderItem, { onPreview = null } = {}) {
56
97
  return new Promise((resolve) => {
57
98
  let selected = 0;
58
99
  const stdin = process.stdin;
@@ -92,6 +133,15 @@ function selectMenu(title, items, renderItem) {
92
133
  process.exit(0);
93
134
  }
94
135
 
136
+ // Auditioning is the whole reason the genre list is hard to choose from:
137
+ // nine names and a one-line description each, and no way to hear any of
138
+ // them without leaving the menu. A loop renders in ~150ms, so this is
139
+ // just a keypress.
140
+ if (onPreview && (key === "p" || key === "P")) {
141
+ onPreview(items[selected]);
142
+ return;
143
+ }
144
+
95
145
  if (key === "\u001b[A") {
96
146
  // Up arrow
97
147
  selected = (selected - 1 + items.length) % items.length;
@@ -119,6 +169,7 @@ function selectMenu(title, items, renderItem) {
119
169
  }
120
170
 
121
171
  function cleanup() {
172
+ if (onPreview) stopPreview();
122
173
  stdin.removeListener("data", onData);
123
174
  stdin.setRawMode(false);
124
175
  stdin.pause();
@@ -299,9 +350,10 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
299
350
 
300
351
  // 3. Select Music Genre
301
352
  const selectedGenre = await selectMenu(
302
- "Choose your sound vibe:",
353
+ "Choose your sound vibe: \x1b[0m\x1b[90m(press p to hear the highlighted one)\x1b[0m",
303
354
  GENRES,
304
- (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
355
+ (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`,
356
+ { onPreview: (item) => playPreview(item.id) }
305
357
  );
306
358
 
307
359
  // 4. Select Volume Preset
@@ -335,4 +387,4 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
335
387
  };
336
388
  }
337
389
 
338
- module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, AI_TOOLS };
390
+ module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, playPreview, stopPreview, AI_TOOLS };
package/src/mcp.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  const readline = require("readline");
9
- const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre } = require("./player");
9
+ const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre, loadConfig } = require("./player");
10
10
  const pkg = require("../package.json");
11
11
 
12
12
  // A desktop client that crashes never sends vibe_stop, so playback needs its
@@ -132,7 +132,12 @@ function handleMessage(player, msg) {
132
132
  const { name, arguments: args = {} } = params || {};
133
133
 
134
134
  if (name === "vibe_play") {
135
- const requested = args.genre || process.env.VIBE_GENRE || "lofi";
135
+ // Same precedence as the CLI: what the model asked for, then the
136
+ // environment, then the user's saved default, then lofi. Reading the
137
+ // saved file here is what makes `vibe --genre jazz` mean jazz in a
138
+ // desktop client too, rather than only in the terminal.
139
+ const saved = loadConfig();
140
+ const requested = args.genre || process.env.VIBE_GENRE || saved.genre || "lofi";
136
141
  // An unknown genre already fell back to lofi inside the generator, but
137
142
  // player.genre kept the name nobody implements - so the model told the
138
143
  // user it was playing something that does not exist.
@@ -140,7 +145,8 @@ function handleMessage(player, msg) {
140
145
  // Both the tool argument and the env default go through the same parser
141
146
  // as the CLI, so a mistyped VIBE_VOLUME falls back instead of reaching
142
147
  // the player as NaN.
143
- const volume = normalizeVolume(args.volume, normalizeVolume(process.env.VIBE_VOLUME, 0.4));
148
+ const volume = normalizeVolume(args.volume,
149
+ normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(saved.volume, 0.4)));
144
150
 
145
151
  const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
146
152
  // start() returns false for three unrelated reasons, and the model
package/src/player.js CHANGED
@@ -361,6 +361,98 @@ function setMuted(muted, minutes = DEFAULT_MUTE_MINUTES) {
361
361
  return state;
362
362
  }
363
363
 
364
+ /**
365
+ * Saved defaults, and the reason there is a file for them at all.
366
+ *
367
+ * Genre and volume used to live in three unrelated places - an env var the
368
+ * wrapper read, a value baked into each hook's command line at install time,
369
+ * and an MCP client's `env` block - so "set my genre" had three different
370
+ * answers depending on how VibeAudio was being run, and the hooks' answer was
371
+ * "reinstall". A file every entry point reads at playback time collapses that
372
+ * to one: `vibe --genre jazz` means jazz everywhere, on the next prompt.
373
+ *
374
+ * Precedence is the conventional one - an explicit flag beats an env var beats
375
+ * this file beats the built-in default - so nothing that used to work stops
376
+ * working, and a one-off `--genre zen` stays a one-off.
377
+ */
378
+ const CONFIG_FILE = path.join(os.homedir(), ".vibeaudio", "config.json");
379
+
380
+ function loadConfig() {
381
+ try {
382
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
383
+ // A hand-edited file is the user's, not ours to reject: an unusable value
384
+ // falls through to the next source rather than taking playback down.
385
+ return parsed && typeof parsed === "object" ? parsed : {};
386
+ } catch (e) {
387
+ return {};
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Merges `patch` into the saved config. Written to a temp file and renamed, so
393
+ * a hook firing mid-write reads either the old file or the new one - never the
394
+ * half of one that happened to be on disk.
395
+ */
396
+ function saveConfig(patch) {
397
+ const next = { ...loadConfig(), ...patch };
398
+ for (const key of Object.keys(next)) {
399
+ if (next[key] === null || next[key] === undefined) delete next[key];
400
+ }
401
+ fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
402
+ const tmp = `${CONFIG_FILE}.tmp`;
403
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
404
+ fs.renameSync(tmp, CONFIG_FILE);
405
+ return next;
406
+ }
407
+
408
+ /**
409
+ * Per-directory overrides, kept in the same file under `projects`.
410
+ *
411
+ * The seed already makes each repo sound like itself; this is the same idea
412
+ * for the choice of instrument - zen in the docs repo, synthwave in the game -
413
+ * and it is opt-in, because a global default is what most people want.
414
+ *
415
+ * Resolved by walking up from `cwd`, so running an agent from a subdirectory
416
+ * still finds the setting saved at the repo root. Walking rather than matching
417
+ * exactly is the difference between a feature and a puzzle: `projectSeed()`
418
+ * keys on the exact directory and a subdirectory quietly gets its own
419
+ * arrangement, which is tolerable for a seed nobody chose and would not be for
420
+ * a genre somebody did.
421
+ */
422
+ function projectSettings(config = loadConfig(), cwd = process.cwd()) {
423
+ const projects = config.projects;
424
+ if (!projects || typeof projects !== "object") return {};
425
+
426
+ let dir = path.resolve(cwd);
427
+ for (;;) {
428
+ const entry = projects[dir];
429
+ if (entry && typeof entry === "object") return entry;
430
+ const parent = path.dirname(dir);
431
+ if (parent === dir) return {}; // Reached the filesystem root.
432
+ dir = parent;
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Saves `patch` under the current directory. A key whose value is null is
438
+ * removed, and a project left with no settings drops out of the file rather
439
+ * than sitting there as an empty object.
440
+ */
441
+ function saveProjectConfig(patch, cwd = process.cwd()) {
442
+ const dir = path.resolve(cwd);
443
+ const config = loadConfig();
444
+ const projects = { ...(config.projects || {}) };
445
+ const entry = { ...(projects[dir] || {}), ...patch };
446
+
447
+ for (const key of Object.keys(entry)) {
448
+ if (entry[key] === null || entry[key] === undefined) delete entry[key];
449
+ }
450
+ if (Object.keys(entry).length) projects[dir] = entry;
451
+ else delete projects[dir];
452
+
453
+ return saveConfig({ projects: Object.keys(projects).length ? projects : null });
454
+ }
455
+
364
456
  function muteRemainingText(state) {
365
457
  if (!state) return null;
366
458
  if (!state.until) return "indefinitely — until you run: vibe --unmute";
@@ -664,6 +756,11 @@ module.exports = {
664
756
  muteRemainingText,
665
757
  DEFAULT_MUTE_MINUTES,
666
758
  MUTE_FILE,
759
+ loadConfig,
760
+ saveConfig,
761
+ projectSettings,
762
+ saveProjectConfig,
763
+ CONFIG_FILE,
667
764
  CACHE_ROOT,
668
765
  CACHE_DIR
669
766
  };
@@ -64,16 +64,26 @@ function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0) {
64
64
  const startIdx = Math.floor(m.start * secPerBeat * SAMPLE_RATE);
65
65
  const numSamples = Math.floor(m.dur * secPerBeat * SAMPLE_RATE);
66
66
 
67
+ // Accumulated, for the same reason as the arpeggio below, plus one of its
68
+ // own: `softPulse((f + vib) * t)` is not vibrato but a phase that jumps
69
+ // when the vibrato switches on at t > 0.15 - by (vib * 0.15) cycles, once
70
+ // per note. Accumulating makes `vib` an actual frequency deviation of
71
+ // +/-3.5Hz, which is what it was meant to be, and the switch-on changes
72
+ // only the rate.
73
+ let leadPhase = 0.0;
74
+
67
75
  for (let i = 0; i < numSamples; i++) {
68
76
  const idx = startIdx + i;
69
77
  if (idx >= totalSamples) break;
70
78
  const t = i / SAMPLE_RATE;
71
79
  const env = Math.min(1.0, t * 75.0) * Math.max(0.0, 1.0 - (t / (m.dur * secPerBeat)));
72
80
  const vib = t > 0.15 ? Math.sin(2 * Math.PI * 5.5 * t) * 3.5 : 0.0;
73
- const sample = softPulse((f + vib) * t) * env * 0.22;
81
+ const sample = softPulse(leadPhase) * env * 0.22;
74
82
 
75
83
  left[idx] += sample;
76
84
  right[idx] += sample;
85
+
86
+ leadPhase += (f + vib) / SAMPLE_RATE;
77
87
  }
78
88
  }
79
89
 
@@ -81,6 +91,22 @@ function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0) {
81
91
  const chords = variant.chords;
82
92
  const arpSpeed = 0.075;
83
93
 
94
+ // The phase is accumulated, not computed as `f * t`.
95
+ //
96
+ // This arpeggio steps to a new note every 75ms and has no envelope at all -
97
+ // it is meant to run continuously. With `softPulse(f * t)`, changing f while
98
+ // t keeps running jumps the phase by (f2 - f1) * t: hundreds of cycles a few
99
+ // seconds in, which lands the waveform on an arbitrary value. That is a
100
+ // click, 13 times a second, with no envelope to hide it - reported as static
101
+ // on 8bit, and at tier 1 every single step over 0.05 full-scale landed
102
+ // exactly on one of these boundaries.
103
+ //
104
+ // Accumulating instead means a new note changes the *rate* the phase
105
+ // advances, never its value, so the waveform stays continuous across the
106
+ // step. It is also how a real oscillator behaves.
107
+ let arpPhase = 0.0;
108
+ let shimmerPhase = 0.0;
109
+
84
110
  for (let i = 0; i < totalSamples; i++) {
85
111
  const t = i / SAMPLE_RATE;
86
112
  const beat = Math.floor(t / secPerBeat);
@@ -88,17 +114,20 @@ function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0) {
88
114
  const chord = chords[chordIdx];
89
115
  const noteIdx = Math.floor(t / arpSpeed) % chord.length;
90
116
  const f = noteToFreq(chord[noteIdx]);
91
- const sample = softPulse(f * t) * (tier === 1 ? 0.09 : 0.075);
117
+ const sample = softPulse(arpPhase) * (tier === 1 ? 0.09 : 0.075);
92
118
 
93
119
  left[i] += sample * 0.85;
94
120
  right[i] += sample * 0.85;
95
121
 
96
122
  // Tier 3: octave-up shimmer arpeggio doubling
97
123
  if (tier >= 3) {
98
- const shimmer = softPulse(f * 2.0 * t) * 0.03;
124
+ const shimmer = softPulse(shimmerPhase) * 0.03;
99
125
  left[i] += shimmer * 1.1;
100
126
  right[i] += shimmer * 0.9;
101
127
  }
128
+
129
+ arpPhase += f / SAMPLE_RATE;
130
+ shimmerPhase += (f * 2.0) / SAMPLE_RATE;
102
131
  }
103
132
 
104
133
  // 3. NES Triangle Bass
@@ -13,6 +13,7 @@ const {
13
13
  makeRng,
14
14
  pick,
15
15
  ornamentRng,
16
+ pluckEnv,
16
17
  createWavBuffer
17
18
  } = require("./generator");
18
19
 
@@ -61,7 +62,7 @@ function generateElectronicLoop(durationSec = 6.4, tier = 2, seed = 0) {
61
62
  const tNote = t % stepSec;
62
63
  // Fast exponential decay filter simulation
63
64
  const filterCutoff = Math.exp(-tNote * 14.0);
64
- const env = Math.exp(-tNote * 7.0);
65
+ const env = pluckEnv(tNote, stepSec, 7.0);
65
66
 
66
67
  // Resonant pluck wave: mixture of soft pulse and harmonics modulated by filter cutoff
67
68
  const osc = softPulse(f * t) * 0.7 + analogSaw(f * t) * 0.3 * filterCutoff;
@@ -87,7 +88,11 @@ function generateElectronicLoop(durationSec = 6.4, tier = 2, seed = 0) {
87
88
  const bar = Math.floor(t / (secPerBeat * 2.0)) % bassSeq.length;
88
89
  const f = noteToFreq(bassSeq[bar]);
89
90
  const tBeat = t % secPerBeat;
90
- const env = Math.exp(-tBeat * 4.5);
91
+ // Same reason as the pluck above: a bare decay is 1.0 the instant tBeat
92
+ // wraps, which on a sub this deep is a thump rather than a note. It also
93
+ // lands the bar's pitch change on an envelope of zero, so the oscillator's
94
+ // phase jump there is inaudible.
95
+ const env = pluckEnv(tBeat, secPerBeat, 4.5);
91
96
 
92
97
  // Deep sine sub + triangle warmth
93
98
  const sub = (sine(f * t) * 0.75 + triangle(f * t) * 0.25) * env * 0.28;
@@ -114,6 +114,32 @@ function analogSaw(phase) {
114
114
  * Encodes audio buffers (Float Arrays in [-1.0, 1.0]) into a valid 16-bit PCM WAV Buffer.
115
115
  * Supports mono (single array) or stereo (array of [left, right] or { left: [], right: [] }).
116
116
  */
117
+ /**
118
+ * A plucked note's amplitude envelope, and the reason it isn't a bare
119
+ * `Math.exp(-tNote * decay)`.
120
+ *
121
+ * A decay curve alone is 1.0 the instant `tNote` wraps back to zero, so every
122
+ * note onset steps the waveform discontinuously - in synthwave's bass, from
123
+ * 0.113 straight to 1.0, a jump of 0.13 full-scale in a single sample. That is
124
+ * a click, and at eighth notes it arrives 3.7 times a second: reported as
125
+ * "speaker static when the music intensifies", because the layers that use it
126
+ * only exist from tier 2 up.
127
+ *
128
+ * Ramping the attack from zero and forcing the release back to zero makes the
129
+ * envelope continuous at both ends of every note. It also covers the other
130
+ * discontinuity in the same voices: an arpeggiator changes `f` while `t` keeps
131
+ * running, so the oscillator's phase jumps at each new note - inaudible only
132
+ * because the envelope is at zero when it happens.
133
+ *
134
+ * 3ms and 4ms are short enough to leave a pluck percussive; chiptune.js has
135
+ * always done this (a 25ms ramp), which is why it never clicked.
136
+ */
137
+ function pluckEnv(tNote, stepSec, decay, attackSec = 0.003, releaseSec = 0.004) {
138
+ const attack = Math.min(1.0, tNote / attackSec);
139
+ const release = Math.min(1.0, Math.max(0.0, (stepSec - tNote) / releaseSec));
140
+ return Math.exp(-tNote * decay) * attack * release;
141
+ }
142
+
117
143
  function createWavBuffer({ left, right = null, sampleRate = SAMPLE_RATE }) {
118
144
  const isStereo = right != null && right.length > 0;
119
145
  const numChannels = isStereo ? 2 : 1;
@@ -175,5 +201,6 @@ module.exports = {
175
201
  triangle,
176
202
  softPulse,
177
203
  analogSaw,
204
+ pluckEnv,
178
205
  createWavBuffer
179
206
  };
@@ -10,6 +10,7 @@ const {
10
10
  triangle,
11
11
  makeRng,
12
12
  pick,
13
+ pluckEnv,
13
14
  createWavBuffer
14
15
  } = require("./generator");
15
16
 
@@ -98,7 +99,7 @@ function generateSynthwaveLoop(durationSec = 6.8, tier = 2, seed = 0) {
98
99
  if (idx >= totalSamples) break;
99
100
  const t = i / SAMPLE_RATE;
100
101
  const tNote = t % stepSec;
101
- const env = Math.exp(-tNote * 8.0);
102
+ const env = pluckEnv(tNote, stepSec, 8.0);
102
103
  const sample = (triangle(f * t) * 0.7 + analogSaw(f * t) * 0.3) * env * 0.20;
103
104
 
104
105
  left[idx] += sample;
@@ -119,7 +120,7 @@ function generateSynthwaveLoop(durationSec = 6.8, tier = 2, seed = 0) {
119
120
  const noteIdx = Math.floor(t / stepSec) % leadNotes.length;
120
121
  const f = noteToFreq(leadNotes[noteIdx]);
121
122
  const tNote = t % stepSec;
122
- const env = Math.exp(-tNote * 6.5);
123
+ const env = pluckEnv(tNote, stepSec, 6.5);
123
124
  const sample = analogSaw(f * t) * env * 0.085;
124
125
 
125
126
  const pan = 0.5 + 0.35 * Math.sin(2 * Math.PI * 1.5 * t);