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/README.md +140 -49
- package/package.json +7 -5
- package/scripts/postinstall.js +33 -0
- package/src/cli.js +286 -19
- package/src/hooks.js +217 -93
- package/src/interactive.js +56 -4
- package/src/mcp.js +9 -3
- package/src/player.js +102 -2
- package/src/synth/electronic.js +7 -2
- package/src/synth/generator.js +27 -0
- package/src/synth/synthwave.js +3 -2
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
|
-
|
|
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
|
};
|
package/src/synth/electronic.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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;
|
package/src/synth/generator.js
CHANGED
|
@@ -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
|
};
|
package/src/synth/synthwave.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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);
|