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,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delicate Glassy Resolution Completion Chime
|
|
3
|
+
* Harmonic Bell Decay (C5, G5, C6, E6)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
SAMPLE_RATE,
|
|
8
|
+
noteToFreq,
|
|
9
|
+
sine,
|
|
10
|
+
createWavBuffer
|
|
11
|
+
} = require("./generator");
|
|
12
|
+
|
|
13
|
+
function generateSuccessChime(durationSec = 1.6) {
|
|
14
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
15
|
+
const left = new Float64Array(totalSamples);
|
|
16
|
+
const right = new Float64Array(totalSamples);
|
|
17
|
+
|
|
18
|
+
const chimeNotes = [
|
|
19
|
+
{ note: "C5", delay: 0.00, pan: 0.4 },
|
|
20
|
+
{ note: "G5", delay: 0.09, pan: 0.6 },
|
|
21
|
+
{ note: "C6", delay: 0.18, pan: 0.45 },
|
|
22
|
+
{ note: "E6", delay: 0.27, pan: 0.55 }
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const c of chimeNotes) {
|
|
26
|
+
const f = noteToFreq(c.note);
|
|
27
|
+
const startIdx = Math.floor(c.delay * SAMPLE_RATE);
|
|
28
|
+
const ringSamples = Math.floor(1.2 * SAMPLE_RATE);
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < ringSamples; i++) {
|
|
31
|
+
const idx = startIdx + i;
|
|
32
|
+
if (idx >= totalSamples) break;
|
|
33
|
+
const t = i / SAMPLE_RATE;
|
|
34
|
+
const env = Math.exp(-t * 4.2);
|
|
35
|
+
|
|
36
|
+
// Sine fundamental + soft sparkling 2nd & 3rd harmonics
|
|
37
|
+
const sample = (
|
|
38
|
+
sine(f * t) * 0.72 +
|
|
39
|
+
sine(f * 2.0 * t) * 0.22 * Math.exp(-t * 8.0) +
|
|
40
|
+
sine(f * 3.0 * t) * 0.06 * Math.exp(-t * 14.0)
|
|
41
|
+
) * env * 0.24;
|
|
42
|
+
|
|
43
|
+
left[idx] += sample * (1.0 - c.pan);
|
|
44
|
+
right[idx] += sample * c.pan;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function generateFailureChime(durationSec = 1.8) {
|
|
52
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
53
|
+
const left = new Float64Array(totalSamples);
|
|
54
|
+
const right = new Float64Array(totalSamples);
|
|
55
|
+
|
|
56
|
+
// Soft, contemplative descending minor chord (A4 -> F4 -> D4)
|
|
57
|
+
const minorNotes = [
|
|
58
|
+
{ note: "A4", delay: 0.00, pan: 0.4 },
|
|
59
|
+
{ note: "F4", delay: 0.12, pan: 0.6 },
|
|
60
|
+
{ note: "D4", delay: 0.24, pan: 0.5 }
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
for (const c of minorNotes) {
|
|
64
|
+
const f = noteToFreq(c.note);
|
|
65
|
+
const startIdx = Math.floor(c.delay * SAMPLE_RATE);
|
|
66
|
+
const ringSamples = Math.floor(1.4 * SAMPLE_RATE);
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < ringSamples; i++) {
|
|
69
|
+
const idx = startIdx + i;
|
|
70
|
+
if (idx >= totalSamples) break;
|
|
71
|
+
const t = i / SAMPLE_RATE;
|
|
72
|
+
const env = Math.exp(-t * 3.2);
|
|
73
|
+
|
|
74
|
+
// Warmer, darker harmonic structure
|
|
75
|
+
const sample = (
|
|
76
|
+
sine(f * t) * 0.75 +
|
|
77
|
+
sine(f * 2.0 * t) * 0.18 * Math.exp(-t * 6.0) +
|
|
78
|
+
sine(f * 0.5 * t) * 0.12 * Math.exp(-t * 2.0)
|
|
79
|
+
) * env * 0.23;
|
|
80
|
+
|
|
81
|
+
left[idx] += sample * (1.0 - c.pan);
|
|
82
|
+
right[idx] += sample * c.pan;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* "Your turn": the agent is blocked on the user. It must not be mistaken for
|
|
91
|
+
* either outcome - success resolves downward onto the tonic and failure
|
|
92
|
+
* descends in minor, so this one rises by an open fifth and stops there,
|
|
93
|
+
* unresolved, twice. A question rather than an answer.
|
|
94
|
+
*/
|
|
95
|
+
function generateAttentionChime(durationSec = 1.3) {
|
|
96
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
97
|
+
const left = new Float64Array(totalSamples);
|
|
98
|
+
const right = new Float64Array(totalSamples);
|
|
99
|
+
|
|
100
|
+
const askNotes = [
|
|
101
|
+
{ note: "E5", delay: 0.00, pan: 0.45 },
|
|
102
|
+
{ note: "B5", delay: 0.13, pan: 0.55 },
|
|
103
|
+
{ note: "E5", delay: 0.42, pan: 0.45 },
|
|
104
|
+
{ note: "B5", delay: 0.55, pan: 0.55 }
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
for (const c of askNotes) {
|
|
108
|
+
const f = noteToFreq(c.note);
|
|
109
|
+
const startIdx = Math.floor(c.delay * SAMPLE_RATE);
|
|
110
|
+
const ringSamples = Math.floor(0.7 * SAMPLE_RATE);
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < ringSamples; i++) {
|
|
113
|
+
const idx = startIdx + i;
|
|
114
|
+
if (idx >= totalSamples) break;
|
|
115
|
+
const t = i / SAMPLE_RATE;
|
|
116
|
+
// Shorter ring than the outcome chimes: a tap on the shoulder, not a bell.
|
|
117
|
+
const env = Math.exp(-t * 7.0);
|
|
118
|
+
|
|
119
|
+
const sample = (
|
|
120
|
+
sine(f * t) * 0.78 +
|
|
121
|
+
sine(f * 2.0 * t) * 0.16 * Math.exp(-t * 12.0)
|
|
122
|
+
) * env * 0.28;
|
|
123
|
+
|
|
124
|
+
left[idx] += sample * (1.0 - c.pan);
|
|
125
|
+
right[idx] += sample * c.pan;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
generateChime: generateSuccessChime,
|
|
134
|
+
generateSuccessChime,
|
|
135
|
+
generateFailureChime,
|
|
136
|
+
generateAttentionChime
|
|
137
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cozy 8-Bit Chiptune Procedural Synth Engine
|
|
3
|
+
* Filtered/Softened Pulse Lead + Shimmering Arpeggios + NES Triangle Bass
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
SAMPLE_RATE,
|
|
8
|
+
noteToFreq,
|
|
9
|
+
softPulse,
|
|
10
|
+
triangle,
|
|
11
|
+
makeRng,
|
|
12
|
+
pick,
|
|
13
|
+
createWavBuffer
|
|
14
|
+
} = require("./generator");
|
|
15
|
+
|
|
16
|
+
// Each variant pairs a melodic contour with the chord/bass cycle it sits over,
|
|
17
|
+
// so the lead always agrees with the arpeggio underneath it.
|
|
18
|
+
const VARIANTS = [
|
|
19
|
+
{
|
|
20
|
+
motif: ["E5", "G5", "A5", "C6", "B5", "G5", "E5", "F5", "A5", "C6", "D6", "B5"],
|
|
21
|
+
chords: [["C4", "E4", "G4", "C5"], ["G3", "B3", "D4", "G4"], ["A3", "C4", "E4", "A4"], ["F3", "A3", "C4", "F4"]],
|
|
22
|
+
bass: ["C3", "G2", "A2", "F2"]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
motif: ["A5", "C6", "B5", "A5", "G5", "E5", "G5", "A5", "F5", "A5", "G5", "E5"],
|
|
26
|
+
chords: [["A3", "C4", "E4", "A4"], ["F3", "A3", "C4", "F4"], ["C4", "E4", "G4", "C5"], ["G3", "B3", "D4", "G4"]],
|
|
27
|
+
bass: ["A2", "F2", "C3", "G2"]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
motif: ["G5", "A5", "B5", "D6", "C6", "A5", "G5", "E5", "G5", "B5", "C6", "A5"],
|
|
31
|
+
chords: [["G3", "B3", "D4", "G4"], ["C4", "E4", "G4", "C5"], ["F3", "A3", "C4", "F4"], ["A3", "C4", "E4", "A4"]],
|
|
32
|
+
bass: ["G2", "C3", "F2", "A2"]
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function generateChiptuneLoop(durationSec = 7.5, tier = 2, seed = 0) {
|
|
37
|
+
const variant = pick(makeRng(seed), VARIANTS);
|
|
38
|
+
const bpm = 128;
|
|
39
|
+
const secPerBeat = 60.0 / bpm;
|
|
40
|
+
const totalBeats = Math.floor(durationSec / secPerBeat);
|
|
41
|
+
const actualDuration = totalBeats * secPerBeat;
|
|
42
|
+
const totalSamples = Math.floor(SAMPLE_RATE * actualDuration);
|
|
43
|
+
|
|
44
|
+
const left = new Float64Array(totalSamples);
|
|
45
|
+
const right = new Float64Array(totalSamples);
|
|
46
|
+
|
|
47
|
+
// 1. Melody Events - the rhythm stays fixed, the seed picks the contour.
|
|
48
|
+
const RHYTHM = [
|
|
49
|
+
[0.0, 0.45], [0.5, 0.45], [1.0, 0.45], [1.5, 0.70], [2.5, 0.45],
|
|
50
|
+
[3.0, 0.45], [3.5, 0.45], [4.0, 0.45], [4.5, 0.45], [5.0, 0.45],
|
|
51
|
+
[5.5, 0.70], [6.5, 0.45], [7.0, 0.80], [8.0, 0.45], [8.5, 0.45],
|
|
52
|
+
[9.0, 0.45], [9.5, 0.70], [10.5, 0.45], [11.0, 0.45], [11.5, 0.70],
|
|
53
|
+
[12.5, 0.45], [13.0, 0.45], [13.5, 0.45], [14.0, 0.45], [14.5, 0.85]
|
|
54
|
+
];
|
|
55
|
+
const melodyEvents = RHYTHM.map(([start, dur], i) => ({
|
|
56
|
+
note: variant.motif[i % variant.motif.length],
|
|
57
|
+
start,
|
|
58
|
+
dur
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
// Melody lead joins at Tier 2 (Tier 1 stays on arpeggio + bass only)
|
|
62
|
+
for (const m of tier >= 2 ? melodyEvents : []) {
|
|
63
|
+
const f = noteToFreq(m.note);
|
|
64
|
+
const startIdx = Math.floor(m.start * secPerBeat * SAMPLE_RATE);
|
|
65
|
+
const numSamples = Math.floor(m.dur * secPerBeat * SAMPLE_RATE);
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < numSamples; i++) {
|
|
68
|
+
const idx = startIdx + i;
|
|
69
|
+
if (idx >= totalSamples) break;
|
|
70
|
+
const t = i / SAMPLE_RATE;
|
|
71
|
+
const env = Math.min(1.0, t * 75.0) * Math.max(0.0, 1.0 - (t / (m.dur * secPerBeat)));
|
|
72
|
+
const vib = t > 0.15 ? Math.sin(2 * Math.PI * 5.5 * t) * 3.5 : 0.0;
|
|
73
|
+
const sample = softPulse((f + vib) * t) * env * 0.22;
|
|
74
|
+
|
|
75
|
+
left[idx] += sample;
|
|
76
|
+
right[idx] += sample;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Chords for Retro Arpeggiation
|
|
81
|
+
const chords = variant.chords;
|
|
82
|
+
const arpSpeed = 0.075;
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
85
|
+
const t = i / SAMPLE_RATE;
|
|
86
|
+
const beat = Math.floor(t / secPerBeat);
|
|
87
|
+
const chordIdx = Math.floor(beat / 4) % chords.length;
|
|
88
|
+
const chord = chords[chordIdx];
|
|
89
|
+
const noteIdx = Math.floor(t / arpSpeed) % chord.length;
|
|
90
|
+
const f = noteToFreq(chord[noteIdx]);
|
|
91
|
+
const sample = softPulse(f * t) * (tier === 1 ? 0.09 : 0.075);
|
|
92
|
+
|
|
93
|
+
left[i] += sample * 0.85;
|
|
94
|
+
right[i] += sample * 0.85;
|
|
95
|
+
|
|
96
|
+
// Tier 3: octave-up shimmer arpeggio doubling
|
|
97
|
+
if (tier >= 3) {
|
|
98
|
+
const shimmer = softPulse(f * 2.0 * t) * 0.03;
|
|
99
|
+
left[i] += shimmer * 1.1;
|
|
100
|
+
right[i] += shimmer * 0.9;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. NES Triangle Bass
|
|
105
|
+
const bassNotes = variant.bass;
|
|
106
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
107
|
+
const t = i / SAMPLE_RATE;
|
|
108
|
+
const beat = Math.floor(t / secPerBeat);
|
|
109
|
+
const chordIdx = Math.floor(beat / 4) % bassNotes.length;
|
|
110
|
+
const f = noteToFreq(bassNotes[chordIdx]);
|
|
111
|
+
const tNote = (t % secPerBeat);
|
|
112
|
+
const env = Math.min(1.0, tNote * 40.0) * Math.max(0.0, 1.0 - Math.pow(tNote / secPerBeat, 0.6));
|
|
113
|
+
const sample = triangle(f * t) * env * 0.24;
|
|
114
|
+
|
|
115
|
+
left[i] += sample;
|
|
116
|
+
right[i] += sample;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fade boundaries
|
|
120
|
+
const fadeLen = Math.floor(0.08 * SAMPLE_RATE);
|
|
121
|
+
for (let i = 0; i < fadeLen; i++) {
|
|
122
|
+
const s = i / fadeLen;
|
|
123
|
+
left[i] *= s;
|
|
124
|
+
right[i] *= s;
|
|
125
|
+
left[totalSamples - 1 - i] *= s;
|
|
126
|
+
right[totalSamples - 1 - i] *= s;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { generateChiptuneLoop };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep Drone / Filtered Noise Procedural Synth Engine
|
|
3
|
+
* A held fundamental + slow-swept noise bed. No notes, no rhythm, no melody.
|
|
4
|
+
*
|
|
5
|
+
* Every other genre plays something. This one deliberately doesn't: it exists
|
|
6
|
+
* for people who find any melodic line distracting, where the goal is a floor
|
|
7
|
+
* of sound to mask a room rather than music to listen past.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
SAMPLE_RATE,
|
|
12
|
+
noteToFreq,
|
|
13
|
+
sine,
|
|
14
|
+
makeRng,
|
|
15
|
+
pick,
|
|
16
|
+
ornamentRng,
|
|
17
|
+
createWavBuffer
|
|
18
|
+
} = require("./generator");
|
|
19
|
+
|
|
20
|
+
// A drone has no progression to clash with, so the seed picks its colour
|
|
21
|
+
// rather than its harmony: a root, the interval stacked above it, and how
|
|
22
|
+
// bright the noise bed sits.
|
|
23
|
+
const DRONE_VARIANTS = [
|
|
24
|
+
{ root: "D1", partial: 1.5, cutoff: 0.055, label: "deep fifth" }, // D + A
|
|
25
|
+
{ root: "A1", partial: 1.5, cutoff: 0.040, label: "dark fifth" }, // A + E
|
|
26
|
+
{ root: "E1", partial: 1.335, cutoff: 0.070, label: "open fourth" } // E + A
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function generateDroneLoop(durationSec = 7.0, tier = 2, seed = 0) {
|
|
30
|
+
// Drawn before any tier gating, so gating a layer can't shift these.
|
|
31
|
+
const orn = ornamentRng(seed);
|
|
32
|
+
const variant = pick(orn, DRONE_VARIANTS);
|
|
33
|
+
const sweepDepth = 0.55 + orn() * 0.35;
|
|
34
|
+
const breathOffset = orn() * Math.PI * 2;
|
|
35
|
+
|
|
36
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
37
|
+
const left = new Float64Array(totalSamples);
|
|
38
|
+
const right = new Float64Array(totalSamples);
|
|
39
|
+
|
|
40
|
+
const rootHz = noteToFreq(variant.root);
|
|
41
|
+
|
|
42
|
+
// 1. Noise bed, one-pole lowpassed into brown noise and slowly swept.
|
|
43
|
+
// The sweep runs exactly twice per loop so it lines up at the boundary.
|
|
44
|
+
// Generated identically at every tier: the rng stream must not depend on
|
|
45
|
+
// tier, or the same seed would render a different bed as time passes.
|
|
46
|
+
const noiseRng = makeRng(seed ^ 0x9e3779b9);
|
|
47
|
+
let lpL = 0;
|
|
48
|
+
let lpR = 0;
|
|
49
|
+
const sweepHz = 2 / durationSec;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
52
|
+
const t = i / SAMPLE_RATE;
|
|
53
|
+
// Two uncorrelated streams, so the bed is wide rather than centred.
|
|
54
|
+
const nL = noiseRng() * 2 - 1;
|
|
55
|
+
const nR = noiseRng() * 2 - 1;
|
|
56
|
+
|
|
57
|
+
// Cutoff breathes between roughly half and full openness.
|
|
58
|
+
const sweep = 0.5 + 0.5 * sine(sweepHz * t + breathOffset / (2 * Math.PI));
|
|
59
|
+
const k = variant.cutoff * (1 - sweepDepth + sweepDepth * sweep);
|
|
60
|
+
|
|
61
|
+
lpL += k * (nL - lpL);
|
|
62
|
+
lpR += k * (nR - lpR);
|
|
63
|
+
|
|
64
|
+
// Brown noise is far quieter than white after filtering, so it needs
|
|
65
|
+
// making up - but only to the level the melodic genres sit at. Measured
|
|
66
|
+
// against jazz (peak 0.64, RMS 0.108); these land at 0.43-0.61 / 0.084-0.128.
|
|
67
|
+
const bedGain = tier === 1 ? 1.02 : tier === 3 ? 1.38 : 1.2;
|
|
68
|
+
left[i] += lpL * bedGain;
|
|
69
|
+
right[i] += lpR * bedGain;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. The fundamental, held for the whole loop. Two voices a few cents apart
|
|
73
|
+
// beat slowly against each other, which is what keeps a held note from
|
|
74
|
+
// sounding like a test tone.
|
|
75
|
+
const rootGain = tier === 1 ? 0.072 : tier === 3 ? 0.108 : 0.09;
|
|
76
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
77
|
+
const t = i / SAMPLE_RATE;
|
|
78
|
+
const a = sine(rootHz * 0.9985 * t);
|
|
79
|
+
const b = sine(rootHz * 1.0015 * t);
|
|
80
|
+
const body = (a + b) * 0.5 + sine(rootHz * 2 * t) * 0.12;
|
|
81
|
+
left[i] += body * rootGain;
|
|
82
|
+
right[i] += body * rootGain;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Tier 2+: the stacked interval, panned wide. No new notes - the same
|
|
86
|
+
// drone gains a partial, so escalation is felt as weight, not movement.
|
|
87
|
+
if (tier >= 2) {
|
|
88
|
+
const partialHz = rootHz * variant.partial;
|
|
89
|
+
const gain = tier === 3 ? 0.0675 : 0.0495;
|
|
90
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
91
|
+
const t = i / SAMPLE_RATE;
|
|
92
|
+
left[i] += sine(partialHz * 0.999 * t) * gain;
|
|
93
|
+
right[i] += sine(partialHz * 1.001 * t) * gain;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 4. Tier 3 only: a high shimmer that swells once across the loop, the one
|
|
98
|
+
// thing in this genre that changes over time.
|
|
99
|
+
if (tier === 3) {
|
|
100
|
+
const shimmerHz = rootHz * 6;
|
|
101
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
102
|
+
const t = i / SAMPLE_RATE;
|
|
103
|
+
const swell = 0.5 - 0.5 * Math.cos((2 * Math.PI * t) / durationSec);
|
|
104
|
+
const s = sine(shimmerHz * t) * 0.035 * swell;
|
|
105
|
+
left[i] += s;
|
|
106
|
+
right[i] -= s;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 5. Wide, slow reverberation - the tail is most of what makes a drone feel
|
|
111
|
+
// like a space rather than an oscillator.
|
|
112
|
+
const delayL = Math.floor(0.38 * SAMPLE_RATE);
|
|
113
|
+
const delayR = Math.floor(0.51 * SAMPLE_RATE);
|
|
114
|
+
const feedback = 0.34;
|
|
115
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
116
|
+
if (i >= delayL) left[i] += right[i - delayL] * feedback;
|
|
117
|
+
if (i >= delayR) right[i] += left[i - delayR] * feedback;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Seamless crossfade boundary
|
|
121
|
+
const fadeLen = Math.floor(0.12 * SAMPLE_RATE);
|
|
122
|
+
for (let i = 0; i < fadeLen; i++) {
|
|
123
|
+
const s = i / fadeLen;
|
|
124
|
+
left[i] *= s;
|
|
125
|
+
right[i] *= s;
|
|
126
|
+
left[totalSamples - 1 - i] *= s;
|
|
127
|
+
right[totalSamples - 1 - i] *= s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { generateDroneLoop };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Melodic Electronic / Downtempo Procedural Synth Engine
|
|
3
|
+
* Resonant Pluck Arpeggio + Warm Electronic Sub-Bass + Subtle Tech Pulse
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
SAMPLE_RATE,
|
|
8
|
+
noteToFreq,
|
|
9
|
+
analogSaw,
|
|
10
|
+
softPulse,
|
|
11
|
+
triangle,
|
|
12
|
+
sine,
|
|
13
|
+
makeRng,
|
|
14
|
+
pick,
|
|
15
|
+
ornamentRng,
|
|
16
|
+
createWavBuffer
|
|
17
|
+
} = require("./generator");
|
|
18
|
+
|
|
19
|
+
// D dorian throughout; each pluck figure ships with the bass cycle that
|
|
20
|
+
// harmonises it.
|
|
21
|
+
const VARIANTS = [
|
|
22
|
+
{
|
|
23
|
+
pluck: ["D4", "A4", "F4", "C5", "D4", "G4", "A4", "F4", "A#3", "F4", "D4", "A4"],
|
|
24
|
+
bass: ["D2", "D2", "A#1", "C2"]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
pluck: ["A4", "D5", "C5", "A4", "F4", "A4", "G4", "D4", "F4", "C5", "A4", "G4"],
|
|
28
|
+
bass: ["A1", "F2", "C2", "G2"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
pluck: ["F4", "A4", "C5", "A4", "G4", "D5", "C5", "A4", "D4", "F4", "A4", "G4"],
|
|
32
|
+
bass: ["F2", "C2", "D2", "A#1"]
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function generateElectronicLoop(durationSec = 6.4, tier = 2, seed = 0) {
|
|
37
|
+
const variant = pick(makeRng(seed), VARIANTS);
|
|
38
|
+
const orn = ornamentRng(seed);
|
|
39
|
+
const bpm = 116;
|
|
40
|
+
const secPerBeat = 60.0 / bpm;
|
|
41
|
+
const totalBeats = Math.floor(durationSec / secPerBeat);
|
|
42
|
+
const actualDuration = totalBeats * secPerBeat;
|
|
43
|
+
const totalSamples = Math.floor(SAMPLE_RATE * actualDuration);
|
|
44
|
+
|
|
45
|
+
const left = new Float64Array(totalSamples);
|
|
46
|
+
const right = new Float64Array(totalSamples);
|
|
47
|
+
|
|
48
|
+
// 1. Resonant Electronic Pluck Synth (Dorian / Melodic Minor)
|
|
49
|
+
// Notes: D4, F4, G4, A4, C5, D5
|
|
50
|
+
const pluckSeq = variant.pluck.map((note, step) => ({ note, step }));
|
|
51
|
+
|
|
52
|
+
const stepSec = secPerBeat / 2.0; // 8th notes
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
55
|
+
const t = i / SAMPLE_RATE;
|
|
56
|
+
const currentStep = Math.floor(t / stepSec) % 16;
|
|
57
|
+
const activePluck = pluckSeq.find((p) => p.step === currentStep % pluckSeq.length);
|
|
58
|
+
|
|
59
|
+
if (activePluck) {
|
|
60
|
+
const f = noteToFreq(activePluck.note);
|
|
61
|
+
const tNote = t % stepSec;
|
|
62
|
+
// Fast exponential decay filter simulation
|
|
63
|
+
const filterCutoff = Math.exp(-tNote * 14.0);
|
|
64
|
+
const env = Math.exp(-tNote * 7.0);
|
|
65
|
+
|
|
66
|
+
// Resonant pluck wave: mixture of soft pulse and harmonics modulated by filter cutoff
|
|
67
|
+
const osc = softPulse(f * t) * 0.7 + analogSaw(f * t) * 0.3 * filterCutoff;
|
|
68
|
+
const sample = osc * env * (tier === 1 ? 0.19 : 0.16);
|
|
69
|
+
|
|
70
|
+
const pan = 0.5 + 0.3 * Math.sin(2 * Math.PI * 0.8 * t);
|
|
71
|
+
left[i] += sample * (1.0 - pan);
|
|
72
|
+
right[i] += sample * pan;
|
|
73
|
+
|
|
74
|
+
// Tier 3: octave-up counter-pluck for peak energy
|
|
75
|
+
if (tier >= 3) {
|
|
76
|
+
const counter = softPulse(f * 2.0 * t) * env * 0.05;
|
|
77
|
+
left[i] += counter * pan;
|
|
78
|
+
right[i] += counter * (1.0 - pan);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. Electronic Sub-Bass (Deep and punchy) - joins at Tier 2
|
|
84
|
+
const bassSeq = variant.bass;
|
|
85
|
+
for (let i = 0; tier >= 2 && i < totalSamples; i++) {
|
|
86
|
+
const t = i / SAMPLE_RATE;
|
|
87
|
+
const bar = Math.floor(t / (secPerBeat * 2.0)) % bassSeq.length;
|
|
88
|
+
const f = noteToFreq(bassSeq[bar]);
|
|
89
|
+
const tBeat = t % secPerBeat;
|
|
90
|
+
const env = Math.exp(-tBeat * 4.5);
|
|
91
|
+
|
|
92
|
+
// Deep sine sub + triangle warmth
|
|
93
|
+
const sub = (sine(f * t) * 0.75 + triangle(f * t) * 0.25) * env * 0.28;
|
|
94
|
+
left[i] += sub;
|
|
95
|
+
right[i] += sub;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. Subtle Tech Glitch/Percussion Pulse (Rhythmic noise blips) - joins at Tier 2
|
|
99
|
+
for (let i = 0; tier >= 2 && i < totalSamples; i++) {
|
|
100
|
+
const t = i / SAMPLE_RATE;
|
|
101
|
+
const t16 = t % (secPerBeat / 4.0);
|
|
102
|
+
if (t16 < 0.015) {
|
|
103
|
+
const n = (orn() * 2.0 - 1.0) * (1.0 - t16 / 0.015);
|
|
104
|
+
const sample = n * 0.045;
|
|
105
|
+
left[i] += sample;
|
|
106
|
+
right[i] += sample;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Stereo Ping-Pong Echo
|
|
111
|
+
const delayL = Math.floor(0.26 * SAMPLE_RATE);
|
|
112
|
+
const delayR = Math.floor(0.39 * SAMPLE_RATE);
|
|
113
|
+
const feedback = 0.26;
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
116
|
+
if (i >= delayL) left[i] += right[i - delayL] * feedback;
|
|
117
|
+
if (i >= delayR) right[i] += left[i - delayR] * feedback;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Smooth loop boundary fade
|
|
121
|
+
const fadeLen = Math.floor(0.08 * SAMPLE_RATE);
|
|
122
|
+
for (let i = 0; i < fadeLen; i++) {
|
|
123
|
+
const s = i / fadeLen;
|
|
124
|
+
left[i] *= s;
|
|
125
|
+
right[i] *= s;
|
|
126
|
+
left[totalSamples - 1 - i] *= s;
|
|
127
|
+
right[totalSamples - 1 - i] *= s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { generateElectronicLoop };
|