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.
- package/README.md +124 -44
- package/package.json +5 -3
- package/scripts/postinstall.js +33 -0
- package/src/cli.js +284 -18
- package/src/hooks.js +56 -14
- package/src/interactive.js +56 -4
- package/src/mcp.js +9 -3
- package/src/player.js +97 -0
- package/src/synth/chiptune.js +32 -3
- package/src/synth/electronic.js +7 -2
- package/src/synth/generator.js +27 -0
- package/src/synth/synthwave.js +3 -2
package/src/interactive.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
};
|
package/src/synth/chiptune.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
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);
|