vibeaudio 0.4.0
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/LICENSE +21 -0
- package/README.md +601 -0
- package/bin/vibeaudio.js +16 -0
- package/package.json +51 -0
- package/src/cli.js +993 -0
- package/src/hooks.js +980 -0
- package/src/hud.js +84 -0
- package/src/interactive.js +338 -0
- package/src/mcp.js +292 -0
- package/src/player.js +666 -0
- package/src/synth/chime.js +137 -0
- package/src/synth/chiptune.js +132 -0
- package/src/synth/drone.js +133 -0
- package/src/synth/electronic.js +133 -0
- package/src/synth/generator.js +179 -0
- package/src/synth/jazz.js +178 -0
- package/src/synth/lofi.js +148 -0
- package/src/synth/piano.js +158 -0
- package/src/synth/synthwave.js +170 -0
- package/src/synth/zen.js +148 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Audio Synthesis & WAV Encoder Utilities
|
|
3
|
+
* Zero dependencies - Pure Node.js Buffers and Math
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SAMPLE_RATE = 44100;
|
|
7
|
+
|
|
8
|
+
const NOTE_INDICES = {
|
|
9
|
+
C: 0, "C#": 1, DB: 1,
|
|
10
|
+
D: 2, "D#": 3, EB: 3,
|
|
11
|
+
E: 4,
|
|
12
|
+
F: 5, "F#": 6, GB: 6,
|
|
13
|
+
G: 7, "G#": 8, AB: 8,
|
|
14
|
+
A: 9, "A#": 10, BB: 10,
|
|
15
|
+
B: 11
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts note string (e.g. "C4", "A#3", "Eb5") to frequency in Hz.
|
|
20
|
+
*/
|
|
21
|
+
function noteToFreq(noteStr) {
|
|
22
|
+
const clean = noteStr.trim().toUpperCase();
|
|
23
|
+
const name = clean.slice(0, -1);
|
|
24
|
+
const octave = parseInt(clean.slice(-1), 10);
|
|
25
|
+
const semitone = NOTE_INDICES[name];
|
|
26
|
+
if (semitone === undefined || isNaN(octave)) {
|
|
27
|
+
throw new Error(`Invalid note name: ${noteStr}`);
|
|
28
|
+
}
|
|
29
|
+
const midi = semitone + (octave + 1) * 12;
|
|
30
|
+
return 440.0 * Math.pow(2.0, (midi - 69) / 12.0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Deterministic PRNG (mulberry32). Same seed always yields the same music,
|
|
35
|
+
* which is what makes a project's sound stable across sessions and keeps the
|
|
36
|
+
* audio cache valid.
|
|
37
|
+
*/
|
|
38
|
+
function makeRng(seed) {
|
|
39
|
+
let a = seed >>> 0;
|
|
40
|
+
return function next() {
|
|
41
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
42
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
43
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
44
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** FNV-1a: turns a project path into a stable 32-bit seed. */
|
|
49
|
+
function hashString(str) {
|
|
50
|
+
let h = 0x811c9dc5;
|
|
51
|
+
for (let i = 0; i < str.length; i++) {
|
|
52
|
+
h ^= str.charCodeAt(i);
|
|
53
|
+
h = Math.imul(h, 0x01000193);
|
|
54
|
+
}
|
|
55
|
+
return h >>> 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pick(rng, options) {
|
|
59
|
+
return options[Math.floor(rng() * options.length) % options.length];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Ornament placement draws from its own stream so that gating a layer by tier
|
|
64
|
+
* can't shift the choices made by other layers - tier 1 and tier 3 stay the
|
|
65
|
+
* same piece, one just has more of it.
|
|
66
|
+
*/
|
|
67
|
+
function ornamentRng(seed) {
|
|
68
|
+
return makeRng((seed ^ 0x9e3779b9) >>> 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Waveform Oscillators
|
|
73
|
+
*/
|
|
74
|
+
function sine(phase) {
|
|
75
|
+
return Math.sin(2.0 * Math.PI * phase);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function triangle(phase) {
|
|
79
|
+
const p = ((phase % 1.0) + 1.0) % 1.0;
|
|
80
|
+
return 4.0 * Math.abs(p - 0.5) - 1.0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A saturated two-harmonic pulse: fundamental plus its octave, driven into
|
|
85
|
+
* tanh so the tops square off.
|
|
86
|
+
*
|
|
87
|
+
* It took a `duty` argument for a long time and never read it. Five call sites
|
|
88
|
+
* passed 0.3, 0.32, 0.35, 0.4 and 0.5, so chiptune's bass and its lead were
|
|
89
|
+
* asking for different widths and getting one waveform - pulse-width
|
|
90
|
+
* variation, the thing that makes a chiptune sound like a chiptune, was never
|
|
91
|
+
* wired up. The parameter is gone rather than implemented because implementing
|
|
92
|
+
* it changes how 8bit and electronic sound, and a level and voicing tuned by
|
|
93
|
+
* ear has to be re-checked by ear. If you want real PWM, add it here
|
|
94
|
+
* deliberately (compare `p` against the duty instead of summing harmonics),
|
|
95
|
+
* then re-level both genres against the house RMS and listen to every tier.
|
|
96
|
+
*/
|
|
97
|
+
function softPulse(phase) {
|
|
98
|
+
const p = ((phase % 1.0) + 1.0) % 1.0;
|
|
99
|
+
const s = Math.sin(2.0 * Math.PI * p) + 0.35 * Math.sin(4.0 * Math.PI * p);
|
|
100
|
+
return Math.tanh(s * 2.8);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function analogSaw(phase) {
|
|
104
|
+
const p = 2.0 * Math.PI * (((phase % 1.0) + 1.0) % 1.0);
|
|
105
|
+
return (
|
|
106
|
+
Math.sin(p) * 0.70 +
|
|
107
|
+
Math.sin(2.0 * p) * 0.35 +
|
|
108
|
+
Math.sin(3.0 * p) * 0.18 +
|
|
109
|
+
Math.sin(4.0 * p) * 0.08
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Encodes audio buffers (Float Arrays in [-1.0, 1.0]) into a valid 16-bit PCM WAV Buffer.
|
|
115
|
+
* Supports mono (single array) or stereo (array of [left, right] or { left: [], right: [] }).
|
|
116
|
+
*/
|
|
117
|
+
function createWavBuffer({ left, right = null, sampleRate = SAMPLE_RATE }) {
|
|
118
|
+
const isStereo = right != null && right.length > 0;
|
|
119
|
+
const numChannels = isStereo ? 2 : 1;
|
|
120
|
+
const numSamples = left.length;
|
|
121
|
+
const bytesPerSample = 2; // 16-bit
|
|
122
|
+
const blockAlign = numChannels * bytesPerSample;
|
|
123
|
+
const byteRate = sampleRate * blockAlign;
|
|
124
|
+
const dataSize = numSamples * blockAlign;
|
|
125
|
+
const bufferSize = 44 + dataSize;
|
|
126
|
+
|
|
127
|
+
const buf = Buffer.alloc(bufferSize);
|
|
128
|
+
|
|
129
|
+
// RIFF Header
|
|
130
|
+
buf.write("RIFF", 0);
|
|
131
|
+
buf.writeUInt32LE(bufferSize - 8, 4);
|
|
132
|
+
buf.write("WAVE", 8);
|
|
133
|
+
|
|
134
|
+
// fmt subchunk
|
|
135
|
+
buf.write("fmt ", 12);
|
|
136
|
+
buf.writeUInt32LE(16, 16); // Subchunk1Size for PCM
|
|
137
|
+
buf.writeUInt16LE(1, 20); // AudioFormat 1 = PCM
|
|
138
|
+
buf.writeUInt16LE(numChannels, 22); // NumChannels
|
|
139
|
+
buf.writeUInt32LE(sampleRate, 24); // SampleRate
|
|
140
|
+
buf.writeUInt32LE(byteRate, 28); // ByteRate
|
|
141
|
+
buf.writeUInt16LE(blockAlign, 32); // BlockAlign
|
|
142
|
+
buf.writeUInt16LE(16, 34); // BitsPerSample (16-bit)
|
|
143
|
+
|
|
144
|
+
// data subchunk
|
|
145
|
+
buf.write("data", 36);
|
|
146
|
+
buf.writeUInt32LE(dataSize, 40);
|
|
147
|
+
|
|
148
|
+
let offset = 44;
|
|
149
|
+
for (let i = 0; i < numSamples; i++) {
|
|
150
|
+
// Clamp sample between -1.0 and 1.0
|
|
151
|
+
const valL = Math.max(-1.0, Math.min(1.0, left[i]));
|
|
152
|
+
const intL = valL < 0 ? Math.floor(valL * 32768) : Math.floor(valL * 32767);
|
|
153
|
+
buf.writeInt16LE(intL, offset);
|
|
154
|
+
offset += 2;
|
|
155
|
+
|
|
156
|
+
if (isStereo) {
|
|
157
|
+
const valR = Math.max(-1.0, Math.min(1.0, right[i]));
|
|
158
|
+
const intR = valR < 0 ? Math.floor(valR * 32768) : Math.floor(valR * 32767);
|
|
159
|
+
buf.writeInt16LE(intR, offset);
|
|
160
|
+
offset += 2;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return buf;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
SAMPLE_RATE,
|
|
169
|
+
makeRng,
|
|
170
|
+
hashString,
|
|
171
|
+
pick,
|
|
172
|
+
ornamentRng,
|
|
173
|
+
noteToFreq,
|
|
174
|
+
sine,
|
|
175
|
+
triangle,
|
|
176
|
+
softPulse,
|
|
177
|
+
analogSaw,
|
|
178
|
+
createWavBuffer
|
|
179
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Midnight Jazz / Coffee Shop Bossa Procedural Synth Engine
|
|
3
|
+
* Classic ii-V-I Jazz Chords + Walking Upright Bass + Brushed Swing Cymbal
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
SAMPLE_RATE,
|
|
8
|
+
noteToFreq,
|
|
9
|
+
sine,
|
|
10
|
+
triangle,
|
|
11
|
+
makeRng,
|
|
12
|
+
pick,
|
|
13
|
+
ornamentRng,
|
|
14
|
+
createWavBuffer
|
|
15
|
+
} = require("./generator");
|
|
16
|
+
|
|
17
|
+
// Three standard turnarounds in C, each with its own walking bass line so the
|
|
18
|
+
// bass always spells the changes it is under.
|
|
19
|
+
const CHANGES = [
|
|
20
|
+
{
|
|
21
|
+
// ii - V - I - VI7 (Dm9 - G13 - Cmaj9 - A7b9)
|
|
22
|
+
chords: [["F3", "A3", "C4", "E4"], ["F3", "B3", "E4", "A4"], ["E3", "G3", "B3", "D4"], ["G3", "A#3", "C#4", "F4"]],
|
|
23
|
+
bass: ["D2", "F2", "F#2", "G2", "B2", "C3", "B2", "A2"]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
// I - VI - ii - V (Cmaj9 - Am9 - Dm9 - G13)
|
|
27
|
+
chords: [["E3", "G3", "B3", "D4"], ["E3", "G3", "C4", "B3"], ["F3", "A3", "C4", "E4"], ["F3", "B3", "E4", "A4"]],
|
|
28
|
+
bass: ["C2", "E2", "A2", "G2", "D2", "F2", "G2", "B2"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
// iii - VI - ii - V (Em9 - Am9 - Dm9 - G13)
|
|
32
|
+
chords: [["G3", "B3", "D4", "F#4"], ["E3", "G3", "C4", "B3"], ["F3", "A3", "C4", "E4"], ["F3", "B3", "E4", "A4"]],
|
|
33
|
+
bass: ["E2", "G2", "A2", "C3", "D2", "F2", "G2", "B2"]
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const STAB_NOTES = ["D5", "E5", "G5", "A5", "B5"];
|
|
38
|
+
|
|
39
|
+
function generateJazzLoop(durationSec = 6.26, tier = 2, seed = 0) {
|
|
40
|
+
const rng = makeRng(seed);
|
|
41
|
+
const changes = pick(rng, CHANGES);
|
|
42
|
+
const bpm = 92;
|
|
43
|
+
const secPerBeat = 60.0 / bpm;
|
|
44
|
+
const totalBeats = Math.floor(durationSec / secPerBeat);
|
|
45
|
+
const actualDuration = totalBeats * secPerBeat;
|
|
46
|
+
const totalSamples = Math.floor(SAMPLE_RATE * actualDuration);
|
|
47
|
+
|
|
48
|
+
const left = new Float64Array(totalSamples);
|
|
49
|
+
const right = new Float64Array(totalSamples);
|
|
50
|
+
|
|
51
|
+
// 1. Jazz Piano Comping (ii-V-I-VI Progression: Dmin9 -> G13 -> Cmaj9 -> A7b9)
|
|
52
|
+
const comping = [
|
|
53
|
+
{ startBeat: 0.5, durBeats: 1.2 },
|
|
54
|
+
{ startBeat: 2.0, durBeats: 1.5 },
|
|
55
|
+
{ startBeat: 4.5, durBeats: 1.2 },
|
|
56
|
+
{ startBeat: 6.0, durBeats: 1.5 }
|
|
57
|
+
];
|
|
58
|
+
const jazzChords = comping.map((slot, i) => ({ ...slot, notes: changes.chords[i] }));
|
|
59
|
+
|
|
60
|
+
for (const chord of jazzChords) {
|
|
61
|
+
const startIdx = Math.floor(chord.startBeat * secPerBeat * SAMPLE_RATE);
|
|
62
|
+
const numSamples = Math.floor(chord.durBeats * secPerBeat * SAMPLE_RATE);
|
|
63
|
+
|
|
64
|
+
for (const note of chord.notes) {
|
|
65
|
+
const f = noteToFreq(note);
|
|
66
|
+
for (let i = 0; i < numSamples; i++) {
|
|
67
|
+
const idx = startIdx + i;
|
|
68
|
+
if (idx >= totalSamples) break;
|
|
69
|
+
const t = i / SAMPLE_RATE;
|
|
70
|
+
const attack = Math.min(1.0, t * 45.0);
|
|
71
|
+
const body = Math.exp(-t * 1.2);
|
|
72
|
+
// Soft warm jazz piano tone
|
|
73
|
+
const sample = (
|
|
74
|
+
sine(f * t) * 0.75 +
|
|
75
|
+
sine(f * 2.0 * t) * 0.20 * body +
|
|
76
|
+
sine(f * 3.0 * t) * 0.05 * Math.exp(-t * 6.0)
|
|
77
|
+
) * attack * body * (tier === 1 ? 0.13 : tier === 3 ? 0.18 : 0.16);
|
|
78
|
+
|
|
79
|
+
left[idx] += sample * 0.85;
|
|
80
|
+
right[idx] += sample * 1.15;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2. Walking Upright Acoustic Bass (Quarter notes with wood tone) - joins at Tier 2
|
|
86
|
+
const walkingBass = changes.bass;
|
|
87
|
+
for (let b = 0; tier >= 2 && b < walkingBass.length && b < totalBeats; b++) {
|
|
88
|
+
const f = noteToFreq(walkingBass[b]);
|
|
89
|
+
const startIdx = Math.floor(b * secPerBeat * SAMPLE_RATE);
|
|
90
|
+
const numSamples = Math.floor(secPerBeat * 0.95 * SAMPLE_RATE);
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < numSamples; i++) {
|
|
93
|
+
const idx = startIdx + i;
|
|
94
|
+
if (idx >= totalSamples) break;
|
|
95
|
+
const t = i / SAMPLE_RATE;
|
|
96
|
+
const attack = Math.min(1.0, t * 50.0);
|
|
97
|
+
const env = Math.exp(-t * 3.2);
|
|
98
|
+
|
|
99
|
+
// Acoustic upright bass: fundamental sine + warm triangle pluck
|
|
100
|
+
const sample = (sine(f * t) * 0.70 + triangle(f * t) * 0.30) * attack * env * 0.28;
|
|
101
|
+
left[idx] += sample;
|
|
102
|
+
right[idx] += sample;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 3. Brushed Jazz Swing Cymbal Pattern (Spang-a-lang ride cymbal swing) - joins at Tier 2
|
|
107
|
+
//
|
|
108
|
+
// The brush noise has a stream of its own rather than sharing the file's.
|
|
109
|
+
// It is the only layer here that draws at all, and it draws from inside a
|
|
110
|
+
// tier gate - so a shared stream would sit one tier-dependent number of
|
|
111
|
+
// draws ahead of anything added after it, and tier 1 would quietly stop
|
|
112
|
+
// being the same piece as tier 3. Scoping it makes that impossible rather
|
|
113
|
+
// than merely untrue today. Same seed and same draw order as before, so the
|
|
114
|
+
// audio is unchanged.
|
|
115
|
+
const brushRng = ornamentRng(seed);
|
|
116
|
+
for (let i = 0; tier >= 2 && i < totalSamples; i++) {
|
|
117
|
+
const t = i / SAMPLE_RATE;
|
|
118
|
+
const beatPos = (t / secPerBeat) % 1.0;
|
|
119
|
+
// Classic jazz triplet swing tap (on the beat and on beatPos ~ 0.66)
|
|
120
|
+
const isBeat = beatPos < 0.04;
|
|
121
|
+
const isSwingTap = beatPos > 0.64 && beatPos < 0.68;
|
|
122
|
+
|
|
123
|
+
if (isBeat || isSwingTap) {
|
|
124
|
+
const relT = isBeat ? beatPos : beatPos - 0.64;
|
|
125
|
+
const env = Math.max(0.0, 1.0 - relT / 0.04);
|
|
126
|
+
const n = (brushRng() * 2.0 - 1.0) * env * (isBeat ? 0.04 : 0.025);
|
|
127
|
+
left[i] += n * 0.7;
|
|
128
|
+
right[i] += n * 1.2;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Tier 3: sparse upper-register piano stabs for long prompts
|
|
133
|
+
if (tier >= 3) {
|
|
134
|
+
const stabRng = ornamentRng(seed ^ 0x5f356495);
|
|
135
|
+
const stabs = [1.7, 3.4, 5.6, 7.1].map((beat) => ({ note: pick(stabRng, STAB_NOTES), beat }));
|
|
136
|
+
|
|
137
|
+
for (const stab of stabs) {
|
|
138
|
+
const f = noteToFreq(stab.note);
|
|
139
|
+
const startIdx = Math.floor(stab.beat * secPerBeat * SAMPLE_RATE);
|
|
140
|
+
const numSamples = Math.floor(0.9 * secPerBeat * SAMPLE_RATE);
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < numSamples; i++) {
|
|
143
|
+
const idx = startIdx + i;
|
|
144
|
+
if (idx >= totalSamples) break;
|
|
145
|
+
const t = i / SAMPLE_RATE;
|
|
146
|
+
const env = Math.min(1.0, t * 60.0) * Math.exp(-t * 2.6);
|
|
147
|
+
const sample = (sine(f * t) * 0.8 + sine(f * 2.0 * t) * 0.2) * env * 0.075;
|
|
148
|
+
|
|
149
|
+
left[idx] += sample * 1.1;
|
|
150
|
+
right[idx] += sample * 0.9;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Warm Room Stereo Reverb
|
|
156
|
+
const delayL = Math.floor(0.22 * SAMPLE_RATE);
|
|
157
|
+
const delayR = Math.floor(0.31 * SAMPLE_RATE);
|
|
158
|
+
const feedback = 0.24;
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
161
|
+
if (i >= delayL) left[i] += right[i - delayL] * feedback;
|
|
162
|
+
if (i >= delayR) right[i] += left[i - delayR] * feedback;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Boundary fade
|
|
166
|
+
const fadeLen = Math.floor(0.08 * SAMPLE_RATE);
|
|
167
|
+
for (let i = 0; i < fadeLen; i++) {
|
|
168
|
+
const s = i / fadeLen;
|
|
169
|
+
left[i] *= s;
|
|
170
|
+
right[i] *= s;
|
|
171
|
+
left[totalSamples - 1 - i] *= s;
|
|
172
|
+
right[totalSamples - 1 - i] *= s;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { generateJazzLoop };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lo-Fi Focus Procedural Synth Engine
|
|
3
|
+
* Warm Rhodes Electric Piano (Major 9th chords) + Soothing Kalimba & Stereo Delay
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
SAMPLE_RATE,
|
|
8
|
+
noteToFreq,
|
|
9
|
+
makeRng,
|
|
10
|
+
pick,
|
|
11
|
+
ornamentRng,
|
|
12
|
+
createWavBuffer
|
|
13
|
+
} = require("./generator");
|
|
14
|
+
|
|
15
|
+
// Hand-written variants, all diatonic to C major so any pairing stays
|
|
16
|
+
// consonant. The seed chooses one; it never invents harmony.
|
|
17
|
+
const PROGRESSIONS = [
|
|
18
|
+
[["F2", "A3", "C4", "E4", "G4"], ["C2", "G3", "B3", "D4", "E4"]], // Fmaj9 -> Cmaj9
|
|
19
|
+
[["D2", "F3", "A3", "C4", "E4"], ["G2", "B3", "D4", "F4", "A4"]], // Dm9 -> G11
|
|
20
|
+
[["A2", "C3", "E3", "G3", "B3"], ["F2", "A3", "C4", "E4", "G4"]] // Am9 -> Fmaj9
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Upper-register C-major tones for the kalimba to land on.
|
|
24
|
+
const DROP_NOTES = ["C5", "D5", "E5", "G5", "A5", "B5", "C6"];
|
|
25
|
+
const SHIMMER_NOTES = ["E6", "G6", "A6", "B6"];
|
|
26
|
+
|
|
27
|
+
function generateLofiLoop(durationSec = 6.4, tier = 2, seed = 0) {
|
|
28
|
+
const rng = makeRng(seed);
|
|
29
|
+
const progression = pick(rng, PROGRESSIONS);
|
|
30
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
31
|
+
const left = new Float64Array(totalSamples);
|
|
32
|
+
const right = new Float64Array(totalSamples);
|
|
33
|
+
|
|
34
|
+
// Rhodes chord renderer with soft tremolo & harmonic overtones
|
|
35
|
+
function addRhodesChord(chordNotes, startSec, durSec, vel = 0.18) {
|
|
36
|
+
const startIdx = Math.floor(startSec * SAMPLE_RATE);
|
|
37
|
+
const numSamples = Math.floor(durSec * SAMPLE_RATE);
|
|
38
|
+
|
|
39
|
+
for (const note of chordNotes) {
|
|
40
|
+
const f = noteToFreq(note);
|
|
41
|
+
for (let i = 0; i < numSamples; i++) {
|
|
42
|
+
const idx = startIdx + i;
|
|
43
|
+
if (idx >= totalSamples) break;
|
|
44
|
+
const t = i / SAMPLE_RATE;
|
|
45
|
+
|
|
46
|
+
const attack = Math.min(1.0, t * 35.0);
|
|
47
|
+
const body = Math.exp(-t * 0.95);
|
|
48
|
+
const tine = Math.exp(-t * 8.5);
|
|
49
|
+
|
|
50
|
+
// Gentle stereo tremolo (3.5 Hz)
|
|
51
|
+
const tremL = 1.0 + 0.15 * Math.sin(2 * Math.PI * 3.5 * t);
|
|
52
|
+
const tremR = 1.0 + 0.15 * Math.cos(2 * Math.PI * 3.5 * t);
|
|
53
|
+
|
|
54
|
+
const sample = (
|
|
55
|
+
Math.sin(2 * Math.PI * f * t) * 0.74 +
|
|
56
|
+
Math.sin(2 * Math.PI * 2.0 * f * t) * 0.22 * body +
|
|
57
|
+
Math.sin(2 * Math.PI * 3.0 * f * t) * 0.08 * tine
|
|
58
|
+
) * attack * body * vel;
|
|
59
|
+
|
|
60
|
+
left[idx] += sample * tremL;
|
|
61
|
+
right[idx] += sample * tremR;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Wooden Kalimba bell note renderer
|
|
67
|
+
function addKalimba(note, startSec, pan = 0.5, vel = 0.14) {
|
|
68
|
+
const f = noteToFreq(note);
|
|
69
|
+
const startIdx = Math.floor(startSec * SAMPLE_RATE);
|
|
70
|
+
const numSamples = Math.floor(1.6 * SAMPLE_RATE);
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < numSamples; i++) {
|
|
73
|
+
const idx = startIdx + i;
|
|
74
|
+
if (idx >= totalSamples) break;
|
|
75
|
+
const t = i / SAMPLE_RATE;
|
|
76
|
+
const env = Math.exp(-t * 3.6);
|
|
77
|
+
const strike = Math.exp(-t * 18.0);
|
|
78
|
+
|
|
79
|
+
const sample = (
|
|
80
|
+
Math.sin(2 * Math.PI * f * t) * 0.85 +
|
|
81
|
+
Math.sin(2 * Math.PI * 2.76 * f * t) * 0.15 * strike
|
|
82
|
+
) * env * vel;
|
|
83
|
+
|
|
84
|
+
left[idx] += sample * (1.0 - pan);
|
|
85
|
+
right[idx] += sample * pan;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const chordVel = tier === 1 ? 0.16 : tier === 3 ? 0.22 : 0.19;
|
|
90
|
+
const half = durationSec / 2;
|
|
91
|
+
addRhodesChord(progression[0], 0.0, half, chordVel);
|
|
92
|
+
addRhodesChord(progression[1], half, half, chordVel);
|
|
93
|
+
|
|
94
|
+
// Drawn regardless of tier so every tier ornaments the same piece.
|
|
95
|
+
const orn = ornamentRng(seed);
|
|
96
|
+
const drops = [0.5, 1.2, 1.8, 2.5, 3.7, 4.4, 5.1, 5.8].map((time) => ({
|
|
97
|
+
note: pick(orn, DROP_NOTES),
|
|
98
|
+
time,
|
|
99
|
+
pan: 0.3 + orn() * 0.4
|
|
100
|
+
}));
|
|
101
|
+
const shimmer = [0.8, 2.1, 3.9, 4.9].map((time) => ({
|
|
102
|
+
note: pick(orn, SHIMMER_NOTES),
|
|
103
|
+
time,
|
|
104
|
+
pan: 0.3 + orn() * 0.4
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
// Kalimba drops activate on Tier 2 and Tier 3
|
|
108
|
+
if (tier >= 2) {
|
|
109
|
+
for (const d of drops) {
|
|
110
|
+
addKalimba(d.note, d.time, d.pan, 0.14);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Tier 3: Higher octave shimmer drops (richer energy for long prompts)
|
|
115
|
+
if (tier >= 3) {
|
|
116
|
+
for (const s of shimmer) {
|
|
117
|
+
addKalimba(s.note, s.time, s.pan, 0.09);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Warm stereo delay feedback
|
|
122
|
+
const delayL = Math.floor(0.24 * SAMPLE_RATE);
|
|
123
|
+
const delayR = Math.floor(0.32 * SAMPLE_RATE);
|
|
124
|
+
const feedback = 0.28;
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
127
|
+
if (i >= delayL) {
|
|
128
|
+
left[i] += left[i - delayL] * feedback;
|
|
129
|
+
}
|
|
130
|
+
if (i >= delayR) {
|
|
131
|
+
right[i] += right[i - delayR] * feedback;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Soft master boundary fades for seamless looping
|
|
136
|
+
const fadeLen = Math.floor(0.08 * SAMPLE_RATE);
|
|
137
|
+
for (let i = 0; i < fadeLen; i++) {
|
|
138
|
+
const s = i / fadeLen;
|
|
139
|
+
left[i] *= s;
|
|
140
|
+
right[i] *= s;
|
|
141
|
+
left[totalSamples - 1 - i] *= s;
|
|
142
|
+
right[totalSamples - 1 - i] *= s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { generateLofiLoop };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparse Piano Procedural Synth Engine
|
|
3
|
+
* Single struck notes, long silences, no pulse — the reading-room genre.
|
|
4
|
+
*
|
|
5
|
+
* Every other melodic genre keeps something moving underneath (a bass line, a
|
|
6
|
+
* pad, an arpeggio). This one is mostly rest: two or three notes decaying into
|
|
7
|
+
* a room. Tiers add notes into the existing silence rather than adding layers
|
|
8
|
+
* on top, so escalation never turns it into a piece with a groove.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
SAMPLE_RATE,
|
|
13
|
+
noteToFreq,
|
|
14
|
+
sine,
|
|
15
|
+
makeRng,
|
|
16
|
+
pick,
|
|
17
|
+
ornamentRng,
|
|
18
|
+
createWavBuffer
|
|
19
|
+
} = require("./generator");
|
|
20
|
+
|
|
21
|
+
// Three curated resting points, each a slow two-chord motion with the melody
|
|
22
|
+
// pool that belongs to it. As everywhere else, the seed chooses among these -
|
|
23
|
+
// it never picks notes freely, which is what keeps it from wandering off key.
|
|
24
|
+
const PIANO_VARIANTS = [
|
|
25
|
+
{
|
|
26
|
+
// A minor - the darkest of the three
|
|
27
|
+
bass: ["A1", "F1"],
|
|
28
|
+
notes: ["A3", "C4", "E4", "G4", "A4", "B4", "E5"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
// C major - open and plain
|
|
32
|
+
bass: ["C2", "G1"],
|
|
33
|
+
notes: ["C4", "E4", "G4", "A4", "C5", "D5", "E5"]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
// D minor 7 moving to G - the one with a little pull to it
|
|
37
|
+
bass: ["D2", "G1"],
|
|
38
|
+
notes: ["D4", "F4", "A4", "C5", "D5", "E5", "A5"]
|
|
39
|
+
}
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A struck string is a fundamental plus partials that die faster the higher
|
|
44
|
+
* they are, and real piano partials sit slightly sharp of whole multiples.
|
|
45
|
+
* Without that inharmonicity the same envelope reads as a bell, not a piano.
|
|
46
|
+
*/
|
|
47
|
+
const PARTIALS = [
|
|
48
|
+
{ mult: 1.0, amp: 1.0, decay: 0.85 },
|
|
49
|
+
{ mult: 2.004, amp: 0.36, decay: 1.5 },
|
|
50
|
+
{ mult: 3.012, amp: 0.15, decay: 2.3 },
|
|
51
|
+
{ mult: 4.028, amp: 0.075, decay: 3.3 },
|
|
52
|
+
{ mult: 5.05, amp: 0.035, decay: 4.5 }
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function generatePianoLoop(durationSec = 7.6, tier = 2, seed = 0) {
|
|
56
|
+
const rng = makeRng(seed);
|
|
57
|
+
const variant = pick(rng, PIANO_VARIANTS);
|
|
58
|
+
|
|
59
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
60
|
+
const left = new Float64Array(totalSamples);
|
|
61
|
+
const right = new Float64Array(totalSamples);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One struck note. `velocity` also opens the tone slightly - a harder strike
|
|
65
|
+
* on a real piano is brighter, not just louder - so the loud notes don't
|
|
66
|
+
* sound like the quiet ones turned up.
|
|
67
|
+
*/
|
|
68
|
+
function strike(freq, startSec, velocity, pan) {
|
|
69
|
+
const startIdx = Math.floor(startSec * SAMPLE_RATE);
|
|
70
|
+
const ringSamples = Math.floor(5.0 * SAMPLE_RATE);
|
|
71
|
+
const attackSamples = Math.floor(0.004 * SAMPLE_RATE);
|
|
72
|
+
const brightness = 0.55 + velocity * 0.45;
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < ringSamples; i++) {
|
|
75
|
+
const idx = startIdx + i;
|
|
76
|
+
if (idx >= totalSamples) break;
|
|
77
|
+
const t = i / SAMPLE_RATE;
|
|
78
|
+
|
|
79
|
+
let sample = 0;
|
|
80
|
+
for (let p = 0; p < PARTIALS.length; p++) {
|
|
81
|
+
const { mult, amp, decay } = PARTIALS[p];
|
|
82
|
+
// Upper partials are scaled by brightness, the fundamental never is.
|
|
83
|
+
const weight = p === 0 ? amp : amp * brightness;
|
|
84
|
+
sample += sine(freq * mult * t) * weight * Math.exp(-t * decay);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const attack = i < attackSamples ? i / attackSamples : 1.0;
|
|
88
|
+
const out = sample * attack * velocity * 0.59;
|
|
89
|
+
|
|
90
|
+
left[idx] += out * (1.0 - pan);
|
|
91
|
+
right[idx] += out * pan;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Drawn before any tier gating, in a fixed order, so tier 3 is the same
|
|
96
|
+
// piece as tier 1 with more of it - not a different arrangement.
|
|
97
|
+
const orn = ornamentRng(seed);
|
|
98
|
+
const melody = [0.25, 3.10, 1.55, 5.20, 4.15, 6.40].map((time) => ({
|
|
99
|
+
note: pick(orn, variant.notes),
|
|
100
|
+
time,
|
|
101
|
+
// Hand-placed rubato: never exactly on a grid, never far enough off to
|
|
102
|
+
// sound wrong.
|
|
103
|
+
offset: (orn() - 0.5) * 0.12,
|
|
104
|
+
velocity: 0.55 + orn() * 0.35,
|
|
105
|
+
pan: 0.38 + orn() * 0.24
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// Tier 1 is two notes in eight seconds. That is the point of the genre.
|
|
109
|
+
const melodyCount = tier === 1 ? 2 : tier === 2 ? 4 : 6;
|
|
110
|
+
for (let i = 0; i < melodyCount; i++) {
|
|
111
|
+
const m = melody[i];
|
|
112
|
+
strike(noteToFreq(m.note), Math.max(0, m.time + m.offset), m.velocity, m.pan);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The left hand: one low octave that spells the change, struck softly and
|
|
116
|
+
// left to ring. The second one only arrives once there is enough going on
|
|
117
|
+
// above it to justify a move.
|
|
118
|
+
strike(noteToFreq(variant.bass[0]), 0.05, 0.42, 0.5);
|
|
119
|
+
if (tier >= 2) strike(noteToFreq(variant.bass[1]), 3.85, 0.36, 0.5);
|
|
120
|
+
|
|
121
|
+
// Tier 3 adds sustain rather than notes: the una corda pedal held down, so
|
|
122
|
+
// the room itself gets fuller while the playing stays as sparse as before.
|
|
123
|
+
if (tier >= 3) {
|
|
124
|
+
const pedalF = noteToFreq(variant.bass[0]) * 0.5;
|
|
125
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
126
|
+
const t = i / SAMPLE_RATE;
|
|
127
|
+
const swell = 0.55 + 0.45 * Math.sin(2 * Math.PI * 0.09 * t);
|
|
128
|
+
const sample = (sine(pedalF * t) * 0.75 + sine(pedalF * 2 * t) * 0.25) * swell * 0.105;
|
|
129
|
+
left[i] += sample;
|
|
130
|
+
right[i] += sample;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Room tone. Longer and quieter than zen's - a piano in a hall, not a bowl
|
|
135
|
+
// in a cave.
|
|
136
|
+
const delayL = Math.floor(0.37 * SAMPLE_RATE);
|
|
137
|
+
const delayR = Math.floor(0.51 * SAMPLE_RATE);
|
|
138
|
+
const feedback = 0.26;
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
141
|
+
if (i >= delayL) left[i] += right[i - delayL] * feedback;
|
|
142
|
+
if (i >= delayR) right[i] += left[i - delayR] * feedback;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Seamless crossfade boundary
|
|
146
|
+
const fadeLen = Math.floor(0.12 * SAMPLE_RATE);
|
|
147
|
+
for (let i = 0; i < fadeLen; i++) {
|
|
148
|
+
const s = i / fadeLen;
|
|
149
|
+
left[i] *= s;
|
|
150
|
+
right[i] *= s;
|
|
151
|
+
left[totalSamples - 1 - i] *= s;
|
|
152
|
+
right[totalSamples - 1 - i] *= s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { generatePianoLoop };
|