vibeaudio 0.4.0 → 0.6.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/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";
@@ -498,6 +590,7 @@ class AudioPlayer {
498
590
  this.volume = 0.42;
499
591
  this.seed = projectSeed();
500
592
  this.intensity = null;
593
+ this.minTier = null;
501
594
  this.currentTier = 1;
502
595
  this.nextTimer = null;
503
596
  this.watchdog = null;
@@ -507,7 +600,7 @@ class AudioPlayer {
507
600
  * Returns true if playback actually started. Restarts when called with
508
601
  * different settings while playing, so a genre switch is not silently dropped.
509
602
  */
510
- start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null } = {}) {
603
+ start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null } = {}) {
511
604
  const resolved = resolveGenre(genre);
512
605
  const targetVolume = Math.max(0.05, Math.min(1.0, volume));
513
606
 
@@ -529,6 +622,7 @@ class AudioPlayer {
529
622
  this.volume = targetVolume;
530
623
  this.seed = projectSeed();
531
624
  this.intensity = intensity;
625
+ this.minTier = minTier;
532
626
 
533
627
  installExitHook();
534
628
  activePlayers.add(this);
@@ -560,7 +654,8 @@ class AudioPlayer {
560
654
  // An intensity source (reactive mode) overrides this when it has a signal.
561
655
  const elapsed = Date.now() - this.startTime;
562
656
  const byTime = elapsed > TIER_3_AFTER_MS ? 3 : elapsed > TIER_2_AFTER_MS ? 2 : 1;
563
- this.currentTier = (this.intensity && this.intensity()) || byTime;
657
+ // A floor (several sessions at once) only ever raises the tier.
658
+ this.currentTier = Math.max((this.intensity && this.intensity()) || byTime, (this.minTier && this.minTier()) || 1);
564
659
 
565
660
  // Either the backend attenuates, or the file is rendered pre-attenuated -
566
661
  // never both, or the volume would be applied twice.
@@ -661,6 +756,11 @@ module.exports = {
661
756
  muteRemainingText,
662
757
  DEFAULT_MUTE_MINUTES,
663
758
  MUTE_FILE,
759
+ loadConfig,
760
+ saveConfig,
761
+ projectSettings,
762
+ saveProjectConfig,
763
+ CONFIG_FILE,
664
764
  CACHE_ROOT,
665
765
  CACHE_DIR
666
766
  };
@@ -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);