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.
@@ -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 };