849 lines
32 KiB
JavaScript
849 lines
32 KiB
JavaScript
// musicEngine.js — Procedural music engine using Tone.js
|
|
// Loaded by HTML5 games via <script> tag; reads song definitions from MusicLibrary
|
|
// Global: window.MusicEngine
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ─── Constants ───────────────────────────────────────────
|
|
|
|
var NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
|
|
var SCALES = {
|
|
major: [0, 2, 4, 5, 7, 9, 11],
|
|
minor: [0, 2, 3, 5, 7, 8, 10],
|
|
dorian: [0, 2, 3, 5, 7, 9, 10],
|
|
phrygian: [0, 1, 3, 5, 7, 8, 10],
|
|
lydian: [0, 2, 4, 6, 7, 9, 11],
|
|
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
|
harmonicMinor: [0, 2, 3, 5, 7, 8, 11],
|
|
melodicMinor: [0, 2, 3, 5, 7, 9, 11],
|
|
pentatonicMajor: [0, 2, 4, 7, 9],
|
|
pentatonicMinor: [0, 3, 5, 7, 10],
|
|
blues: [0, 3, 5, 6, 7, 10],
|
|
wholeTone: [0, 2, 4, 6, 8, 10],
|
|
chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
|
};
|
|
|
|
// Roman numeral to scale degree (0-indexed)
|
|
var ROMAN = { i: 0, ii: 1, iii: 2, iv: 3, v: 4, vi: 5, vii: 6 };
|
|
|
|
// Short synth type names to Tone.js constructor names
|
|
var SYNTH_MAP = {
|
|
FM: 'FMSynth', AM: 'AMSynth', Mono: 'MonoSynth', Poly: 'PolySynth',
|
|
Membrane: 'MembraneSynth', Metal: 'MetalSynth', Pluck: 'PluckSynth',
|
|
Duo: 'DuoSynth', Noise: 'NoiseSynth',
|
|
};
|
|
|
|
// ─── Seeded PRNG ─────────────────────────────────────────
|
|
|
|
function hashStr(s) {
|
|
var h = 0;
|
|
for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
return Math.abs(h) || 1;
|
|
}
|
|
|
|
function seededRandom(seed) {
|
|
var s = hashStr(String(seed));
|
|
return function() {
|
|
s = (s * 16807) % 2147483647;
|
|
return (s - 1) / 2147483646;
|
|
};
|
|
}
|
|
|
|
// ─── Note Utilities ──────────────────────────────────────
|
|
|
|
function noteToMidi(n) {
|
|
var m = n.match(/^([A-G]#?)(\d+)$/);
|
|
return m ? (parseInt(m[2]) + 1) * 12 + NOTES.indexOf(m[1]) : 60;
|
|
}
|
|
|
|
function midiToNote(m) {
|
|
return NOTES[m % 12] + (Math.floor(m / 12) - 1);
|
|
}
|
|
|
|
function getScaleNotes(root, scale, octave) {
|
|
var ri = NOTES.indexOf(root);
|
|
if (ri < 0) return [];
|
|
return (SCALES[scale] || SCALES.major).map(function(semi) {
|
|
return midiToNote((octave + 1) * 12 + ri + semi);
|
|
});
|
|
}
|
|
|
|
function multiOctave(root, scale, lo, hi) {
|
|
var notes = [];
|
|
for (var o = lo; o <= hi; o++) notes = notes.concat(getScaleNotes(root, scale, o));
|
|
return notes;
|
|
}
|
|
|
|
// ─── Chord Parsing ───────────────────────────────────────
|
|
|
|
function parseChords(str, key, scale) {
|
|
if (!str || !str.trim()) return [];
|
|
var ri = NOTES.indexOf(key), intervals = SCALES[scale] || SCALES.major;
|
|
|
|
return str.trim().split(/\s+/).map(function(sym) {
|
|
var s = sym, sf = 0;
|
|
// Check for accidentals prefix (b or #)
|
|
if (s[0] === 'b') { sf = -1; s = s.slice(1); }
|
|
else if (s[0] === '#') { sf = 1; s = s.slice(1); }
|
|
// Check for 7th suffix
|
|
var has7 = s.endsWith('7');
|
|
if (has7) s = s.slice(0, -1);
|
|
// Check for diminished suffix
|
|
var dim = s.endsWith('\u00b0');
|
|
if (dim) s = s.slice(0, -1);
|
|
// Uppercase = major triad, lowercase = minor triad
|
|
var isUpper = s[0] === s[0].toUpperCase();
|
|
var deg = ROMAN[s.toLowerCase()];
|
|
if (deg === undefined) deg = 0;
|
|
|
|
var chordRoot = (ri + (intervals[deg % intervals.length] || 0) + sf + 12) % 12;
|
|
var baseMidi = 48 + chordRoot; // octave 3
|
|
var tones = dim ? [baseMidi, baseMidi + 3, baseMidi + 6]
|
|
: isUpper ? [baseMidi, baseMidi + 4, baseMidi + 7]
|
|
: [baseMidi, baseMidi + 3, baseMidi + 7];
|
|
if (has7) tones.push(isUpper ? baseMidi + 11 : baseMidi + 10);
|
|
|
|
return {
|
|
symbol: sym, root: midiToNote(baseMidi), rootMidi: baseMidi,
|
|
notes: tones.map(midiToNote), midiNotes: tones,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── Event shorthand constructors ────────────────────────
|
|
|
|
function E(note, dur, vel, time) { return { note: note, duration: dur, velocity: vel, time: time }; }
|
|
function D(voice, time, vel) { return { voice: voice, time: time, velocity: vel }; }
|
|
|
|
// ─── Melody Generators ───────────────────────────────────
|
|
// Each: (scaleNotes, chord, intensity, barNum, prev, rng) -> [{note, duration, velocity, time}]
|
|
|
|
var melGen = {
|
|
|
|
'arp-up': function(sn, c, I) {
|
|
var n = [c.midiNotes[0], c.midiNotes[1], c.midiNotes[2], c.midiNotes[0] + 12];
|
|
return n.map(function(m, i) { return E(midiToNote(m), '8n', 0.5 + I * 0.4, i * 0.5); });
|
|
},
|
|
|
|
'arp-down': function(sn, c, I) {
|
|
var n = [c.midiNotes[0] + 12, c.midiNotes[2], c.midiNotes[1], c.midiNotes[0]];
|
|
return n.map(function(m, i) { return E(midiToNote(m), '8n', 0.5 + I * 0.4, i * 0.5); });
|
|
},
|
|
|
|
'arp-bounce': function(sn, c, I) {
|
|
var t = c.midiNotes, s = [t[0], t[1], t[2], t[1], t[0], t[2], t[1], t[0]];
|
|
return s.map(function(m, i) { return E(midiToNote(m), '8n', 0.45 + I * 0.4, i * 0.5); });
|
|
},
|
|
|
|
'stepwise': function(sn, c, I, b, p, rng) {
|
|
var ev = [], si = Math.max(0, Math.floor(sn.length * 0.3)), t = 0;
|
|
for (var i = 0; i < 6 && t < 4; i++) {
|
|
var d = rng() > 0.5 ? 0.5 : 1;
|
|
ev.push(E(sn[Math.min(si + i, sn.length - 1)], d > 0.75 ? '4n' : '8n', 0.5 + I * 0.35, t));
|
|
t += d;
|
|
}
|
|
return ev;
|
|
},
|
|
|
|
'leap': function(sn, c, I, b, p, rng) {
|
|
var ev = [], idx = Math.floor(sn.length * 0.3), t = 0;
|
|
for (var i = 0; i < 4 && t < 4; i++) {
|
|
ev.push(E(sn[Math.min(idx, sn.length - 1)], '4n', 0.55 + I * 0.35, t));
|
|
idx += Math.floor(rng() * 3) + 2;
|
|
if (idx >= sn.length) idx = Math.floor(sn.length * 0.3);
|
|
t += rng() > 0.3 ? 1 : 0.5;
|
|
}
|
|
return ev;
|
|
},
|
|
|
|
'sustained': function(sn, c, I) {
|
|
return [E(c.notes[0], '2n', 0.4 + I * 0.3, 0), E(c.notes[2] || c.notes[1], '2n', 0.35 + I * 0.3, 2)];
|
|
},
|
|
|
|
'rhythmic': function(sn, c, I, b, p, rng) {
|
|
var ev = [], r = c.notes[0];
|
|
for (var i = 0; i < 16; i++) {
|
|
if (rng() < 0.5 + I * 0.3)
|
|
ev.push(E(r, '16n', (i % 4 === 0 ? 0.7 : 0.4) * (0.5 + I * 0.5), i * 0.25));
|
|
}
|
|
return ev;
|
|
},
|
|
|
|
'call-response': function(sn, c, I) {
|
|
var h = Math.min(Math.floor(sn.length * 0.7), sn.length - 1), l = Math.floor(sn.length * 0.3);
|
|
return [
|
|
E(sn[h], '8n', 0.6 + I * 0.3, 0),
|
|
E(sn[Math.min(h + 1, sn.length - 1)], '8n', 0.55 + I * 0.3, 0.5),
|
|
E(sn[l], '8n', 0.5 + I * 0.3, 2),
|
|
E(sn[Math.max(l - 1, 0)], '4n', 0.5 + I * 0.3, 2.5),
|
|
];
|
|
},
|
|
|
|
'pentatonic': function(sn, c, I, b, p, rng) {
|
|
var ri = NOTES.indexOf(c.root.replace(/\d+/, ''));
|
|
var pn = SCALES.pentatonicMinor.map(function(s) { return midiToNote(48 + ri + s); });
|
|
var ev = [], t = 0;
|
|
while (t < 4) { ev.push(E(pn[Math.floor(rng() * pn.length)], '8n', 0.45 + I * 0.4, t)); t += 0.5; }
|
|
return ev;
|
|
},
|
|
|
|
'ostinato': function(sn, c, I) {
|
|
var si = 0;
|
|
for (var i = 0; i < sn.length; i++) if (noteToMidi(sn[i]) >= c.rootMidi) { si = i; break; }
|
|
var motif = [];
|
|
for (var j = 0; j < Math.min(4, sn.length - si); j++) motif.push(sn[si + j]);
|
|
var ev = [];
|
|
for (var b = 0; b < 4; b++) ev.push(E(motif[b % motif.length], '8n', 0.5 + I * 0.35, b));
|
|
return ev;
|
|
},
|
|
|
|
'descending': function(sn, c, I) {
|
|
var s = Math.min(Math.floor(sn.length * 0.7), sn.length - 1), ev = [], t = 0;
|
|
for (var i = s; i >= 0 && t < 4; i--, t += 0.5) ev.push(E(sn[i], '8n', 0.5 + I * 0.35, t));
|
|
return ev;
|
|
},
|
|
|
|
'ascending': function(sn, c, I) {
|
|
var s = Math.floor(sn.length * 0.2), ev = [], t = 0;
|
|
for (var i = s; i < sn.length && t < 4; i++, t += 0.5) ev.push(E(sn[i], '8n', 0.5 + I * 0.35, t));
|
|
return ev;
|
|
},
|
|
|
|
'chromatic': function(sn, c, I) {
|
|
var ev = [], m = c.rootMidi, t = 0;
|
|
for (var i = 0; i < 8 && t < 4; i++) {
|
|
ev.push(E(midiToNote(m), '8n', 0.5 + I * 0.3, t));
|
|
m += (i % 2 === 0) ? 1 : 2; t += 0.5;
|
|
}
|
|
return ev;
|
|
},
|
|
|
|
'fade': function(sn, c, I, bar) {
|
|
var ev = [], cnt = Math.max(1, 8 - bar * 2);
|
|
for (var i = 0; i < cnt && i * 0.5 < 4; i++)
|
|
ev.push(E(c.notes[i % c.notes.length], '4n', Math.max(0.1, (0.5 + I * 0.3) * (1 - i / 8)), i * 0.5));
|
|
return ev;
|
|
},
|
|
|
|
'trill': function(sn, c, I) {
|
|
var idx = 0;
|
|
for (var i = 0; i < sn.length; i++) if (noteToMidi(sn[i]) >= c.rootMidi) { idx = i; break; }
|
|
var n1 = sn[idx], n2 = sn[Math.min(idx + 1, sn.length - 1)], ev = [];
|
|
for (var t = 0; t < 4; t += 0.25)
|
|
ev.push(E(Math.round(t / 0.25) % 2 === 0 ? n1 : n2, '16n', 0.4 + I * 0.35, t));
|
|
return ev;
|
|
},
|
|
|
|
'fanfare': function(sn, c, I) {
|
|
var v = 0.6 + I * 0.25, V = 0.7 + I * 0.25;
|
|
return [E(c.notes[0], '4n.', V, 0), E(c.notes[1] || c.notes[0], '8n', v, 1.5),
|
|
E(c.notes[2] || c.notes[0], '4n.', V, 2), E(c.notes[0], '8n', v, 3.5)];
|
|
},
|
|
|
|
'blues': function(sn, c, I, b, p, rng) {
|
|
var rm = c.rootMidi, bn = [rm, rm + 3, rm + 5, rm + 6, rm + 7, rm + 10], ev = [], t = 0;
|
|
while (t < 4) {
|
|
var sw = (Math.round(t * 2) % 2 === 1) ? 0.08 : 0;
|
|
ev.push(E(midiToNote(bn[Math.floor(rng() * bn.length)]), '8n', 0.5 + I * 0.4, t + sw));
|
|
t += 0.5;
|
|
}
|
|
return ev;
|
|
},
|
|
|
|
'staccato': function(sn, c, I, b, p, rng) {
|
|
var ev = [];
|
|
for (var t = 0; t < 4; t += 0.5)
|
|
if (rng() < 0.7) ev.push(E(sn[Math.floor(rng() * sn.length)], '16n', 0.5 + I * 0.4, t));
|
|
return ev;
|
|
},
|
|
|
|
'legato': function(sn, c, I) {
|
|
var s = Math.floor(sn.length * 0.3);
|
|
return [E(sn[s], '2n', 0.45 + I * 0.3, 0),
|
|
E(sn[Math.min(s + 2, sn.length - 1)], '4n', 0.45 + I * 0.3, 2),
|
|
E(sn[Math.min(s + 4, sn.length - 1)], '4n', 0.4 + I * 0.3, 3)];
|
|
},
|
|
|
|
'random': function(sn, c, I, b, p, rng) {
|
|
var ev = [], t = 0, ds = [0.25, 0.5, 1], dn = ['16n', '8n', '4n'];
|
|
while (t < 4) {
|
|
var n = rng() < 0.5 ? c.notes[Math.floor(rng() * c.notes.length)] : sn[Math.floor(rng() * sn.length)];
|
|
var di = Math.floor(rng() * 3);
|
|
ev.push(E(n, dn[di], 0.4 + I * 0.4 * rng(), t));
|
|
t += ds[di];
|
|
}
|
|
return ev;
|
|
},
|
|
|
|
'silence': function() { return []; },
|
|
};
|
|
|
|
// ─── Bass Generators ─────────────────────────────────────
|
|
// Each: (chord, chordTones, scaleNotes, intensity, rng) -> [{note, duration, velocity, time}]
|
|
|
|
var bassGen = {
|
|
|
|
'root': function(c, _, __, I) {
|
|
var r = midiToNote(c.rootMidi - 12), v = 0.6 + I * 0.3, v2 = 0.55 + I * 0.3;
|
|
return [E(r, '4n', v, 0), E(r, '4n', v2, 1), E(r, '4n', v, 2), E(r, '4n', v2, 3)];
|
|
},
|
|
|
|
'root-fifth': function(c, _, __, I) {
|
|
var r = midiToNote(c.rootMidi - 12), f = midiToNote(c.rootMidi - 5), v = 0.6 + I * 0.3;
|
|
return [E(r, '4n', v, 0), E(f, '4n', v - 0.05, 1), E(r, '4n', v, 2), E(f, '4n', v - 0.05, 3)];
|
|
},
|
|
|
|
'driving': function(c, _, __, I) {
|
|
var r = midiToNote(c.rootMidi - 12), ev = [];
|
|
for (var t = 0; t < 4; t += 0.5)
|
|
ev.push(E(r, '8n', (t % 1 === 0 ? 0.65 : 0.5) + I * 0.25, t));
|
|
return ev;
|
|
},
|
|
|
|
'walking': function(c, _, __, I) {
|
|
var b = c.rootMidi - 12;
|
|
return [b, b + 2, b + 4, b + 5].map(function(m, i) {
|
|
return E(midiToNote(m), '4n', 0.55 + I * 0.3, i);
|
|
});
|
|
},
|
|
|
|
'arpeggiated': function(c) {
|
|
var a = c.midiNotes.map(function(m) { return m - 12; }), ev = [], t = 0;
|
|
for (var i = 0; t < 4; i++, t += 0.5)
|
|
ev.push(E(midiToNote(a[i % a.length]), '8n', 0.55, t));
|
|
return ev;
|
|
},
|
|
|
|
'octave': function(c, _, __, I) {
|
|
var lo = midiToNote(c.rootMidi - 24), hi = midiToNote(c.rootMidi - 12), v = 0.6 + I * 0.3;
|
|
return [E(lo, '4n', v, 0), E(hi, '4n', v - 0.05, 1), E(lo, '4n', v, 2), E(hi, '4n', v - 0.05, 3)];
|
|
},
|
|
|
|
'syncopated': function(c, _, __, I) {
|
|
var r = midiToNote(c.rootMidi - 12), v = 0.6 + I * 0.25;
|
|
return [E(r, '4n', v + 0.05, 0), E(r, '8n', v - 0.05, 1.5), E(r, '4n', v - 0.05, 2.5)];
|
|
},
|
|
|
|
'pedal': function(c) {
|
|
return [E(midiToNote(c.rootMidi - 12), '1n', 0.55, 0)];
|
|
},
|
|
|
|
'pumping': function(c, _, __, I) {
|
|
var r = midiToNote(c.rootMidi - 12), ev = [];
|
|
for (var t = 0; t < 4; t += 0.5)
|
|
ev.push(E(r, '8n', 0.5 + I * 0.25 + ((t === 0 || t === 2) ? 0.15 : 0), t));
|
|
return ev;
|
|
},
|
|
|
|
'fifths': function(c, _, __, I) {
|
|
var r = c.rootMidi - 12, f = r + 7, p = r + 4, v = 0.6 + I * 0.3;
|
|
return [E(midiToNote(r), '4n', v, 0), E(midiToNote(f), '8n', v - 0.05, 1),
|
|
E(midiToNote(p), '8n', v - 0.1, 1.5), E(midiToNote(r), '4n', v, 2),
|
|
E(midiToNote(f), '4n', v - 0.05, 3)];
|
|
},
|
|
|
|
'chromatic': function(c) {
|
|
var b = c.rootMidi - 12;
|
|
return [E(midiToNote(b - 2), '4n', 0.5, 0), E(midiToNote(b - 1), '4n', 0.55, 1),
|
|
E(midiToNote(b), '2n', 0.6, 2)];
|
|
},
|
|
|
|
'silence': function() { return []; },
|
|
};
|
|
|
|
// ─── Drum Generators ─────────────────────────────────────
|
|
// Each: (intensity, timeSig, barNum) -> [{voice, time, velocity}]
|
|
|
|
var drumGen = {
|
|
|
|
'standard': function(I) {
|
|
var e = [D('kick', 0, 0.8), D('kick', 2, 0.75), D('snare', 1, 0.7), D('snare', 3, 0.7)];
|
|
for (var t = 0; t < 4; t += 0.5)
|
|
e.push(D('hihat', t, (t % 1 === 0 ? 0.4 : 0.25) * (0.5 + I * 0.5)));
|
|
return e;
|
|
},
|
|
|
|
'four-on-floor': function(I) {
|
|
var e = [D('snare', 1, 0.65), D('snare', 3, 0.65)];
|
|
for (var b = 0; b < 4; b++) e.push(D('kick', b, 0.8));
|
|
for (var t = 0; t < 4; t += 0.5)
|
|
e.push(D('hihat', t, (t % 1 === 0 ? 0.35 : 0.2) * (0.5 + I * 0.5)));
|
|
return e;
|
|
},
|
|
|
|
'half-time': function() {
|
|
return [D('kick', 0, 0.8), D('snare', 2, 0.75), D('hihat', 0, 0.3), D('hihat', 2, 0.25)];
|
|
},
|
|
|
|
'breakbeat': function() {
|
|
var e = [D('kick', 0, 0.8), D('kick', 1.5, 0.7), D('kick', 3, 0.75),
|
|
D('snare', 1, 0.7), D('snare', 3, 0.7)];
|
|
for (var t = 0; t < 4; t += 0.5)
|
|
e.push(D('hihat', t, (t % 1 === 0 ? 0.3 : 0.2) + (t === 1.5 ? 0.15 : 0)));
|
|
return e;
|
|
},
|
|
|
|
'shuffle': function() {
|
|
var e = [D('kick', 0, 0.8), D('kick', 2, 0.75), D('snare', 1, 0.7), D('snare', 3, 0.7)];
|
|
for (var b = 0; b < 4; b++) { e.push(D('hihat', b, 0.35)); e.push(D('hihat', b + 0.67, 0.2)); }
|
|
return e;
|
|
},
|
|
|
|
'full': function(I) {
|
|
var e = [D('kick', 0, 0.85), D('kick', 1.5, 0.6), D('kick', 2, 0.8), D('kick', 3.5, 0.55),
|
|
D('snare', 1, 0.75), D('snare', 3, 0.75), D('tom', 2.5, 0.5), D('crash', 0, 0.3)];
|
|
for (var t = 0; t < 4; t += 0.25)
|
|
e.push(D('hihat', t, (t % 0.5 === 0 ? 0.3 : 0.15) * (0.5 + I * 0.5)));
|
|
return e;
|
|
},
|
|
|
|
'sparse': function() {
|
|
return [D('kick', 0, 0.7), D('snare', 2, 0.55)];
|
|
},
|
|
|
|
'build': function(I, ts, bar) {
|
|
var e = [D('kick', 0, 0.7)], density = Math.min(1, (bar || 0) * 0.15 + 0.1);
|
|
if (density > 0.2) e.push(D('kick', 2, 0.65));
|
|
if (density > 0.4) { e.push(D('snare', 1, 0.5)); e.push(D('snare', 3, 0.5)); }
|
|
if (density > 0.6) for (var t = 0; t < 4; t += 0.5) e.push(D('hihat', t, 0.25));
|
|
if (density > 0.8) e.push(D('crash', 0, 0.35));
|
|
return e;
|
|
},
|
|
|
|
'tribal': function() {
|
|
return [D('tom', 0, 0.8), D('tom', 0.75, 0.5), D('tom', 1.5, 0.65), D('kick', 2, 0.75),
|
|
D('tom', 2.5, 0.6), D('tom', 3, 0.7), D('tom', 3.5, 0.45)];
|
|
},
|
|
|
|
'double-time': function(I) {
|
|
var e = [];
|
|
for (var t = 0; t < 4; t += 0.5) e.push(D('kick', t, t % 1 === 0 ? 0.75 : 0.5));
|
|
for (var s = 0.5; s < 4; s += 1) e.push(D('snare', s, 0.6));
|
|
for (var h = 0; h < 4; h += 0.25) e.push(D('hihat', h, 0.2 + I * 0.15));
|
|
return e;
|
|
},
|
|
|
|
'kick-only': function() {
|
|
return [D('kick', 0, 0.75), D('kick', 1, 0.7), D('kick', 2, 0.75), D('kick', 3, 0.7)];
|
|
},
|
|
|
|
'hats-only': function(I) {
|
|
var e = [], step = I > 0.7 ? 0.25 : 0.5;
|
|
for (var t = 0; t < 4; t += step) e.push(D('hihat', t, t % 1 === 0 ? 0.4 : 0.25));
|
|
return e;
|
|
},
|
|
|
|
'march': function(I) {
|
|
var e = [D('kick', 0, 0.8), D('kick', 2, 0.75)];
|
|
for (var b = 0; b < 4; b++) {
|
|
e.push(D('snare', b, 0.65));
|
|
if (I > 0.5) { e.push(D('snare', b + 0.25, 0.35)); e.push(D('snare', b + 0.5, 0.3)); }
|
|
}
|
|
return e;
|
|
},
|
|
|
|
'waltz': function() {
|
|
return [D('kick', 0, 0.8), D('hihat', 0.67, 0.35), D('hihat', 1.33, 0.3),
|
|
D('kick', 2, 0.7), D('hihat', 2.67, 0.35), D('hihat', 3.33, 0.3)];
|
|
},
|
|
|
|
'none': function() { return []; },
|
|
};
|
|
|
|
// ─── Engine State ────────────────────────────────────────
|
|
|
|
var Tone = null;
|
|
var initialized = false;
|
|
var currentSong = null;
|
|
var playing = false;
|
|
var masterGain = null;
|
|
var currentNodes = []; // all disposable Tone.js nodes for current song
|
|
var currentParts = []; // scheduled parts for cleanup
|
|
var loopCbId = null;
|
|
|
|
// ─── Tone.js CDN Loading ─────────────────────────────────
|
|
|
|
async function loadToneJs() {
|
|
if (window.Tone) return window.Tone;
|
|
return new Promise(function(resolve, reject) {
|
|
var script = document.createElement('script');
|
|
script.src = 'https://unpkg.com/tone@15.1.22/build/Tone.js';
|
|
script.onload = function() { resolve(window.Tone); };
|
|
script.onerror = function() { reject(new Error('Failed to load Tone.js')); };
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
// ─── Synth Creation ──────────────────────────────────────
|
|
|
|
var ROLE_DEFAULTS = {
|
|
lead: { envelope: { attack: 0.03, decay: 0.2, sustain: 0.8, release: 0.4 }, volume: -6 },
|
|
pad: { envelope: { attack: 0.5, decay: 0.3, sustain: 0.9, release: 2.0 }, volume: -12 },
|
|
bass: { envelope: { attack: 0.01, decay: 0.15, sustain: 0.7, release: 0.2 }, volume: -8 },
|
|
};
|
|
|
|
function createSynth(typeName, role, custom) {
|
|
var ctorName = SYNTH_MAP[typeName] || 'Synth';
|
|
var Ctor = Tone[ctorName];
|
|
if (!Ctor) Ctor = Tone.Synth;
|
|
var settings = Object.assign({}, ROLE_DEFAULTS[role] || {}, custom || {});
|
|
|
|
// PolySynth wraps an inner synth type
|
|
if (ctorName === 'PolySynth') return new Tone.PolySynth(Tone.Synth, settings);
|
|
// These synth types have non-standard constructors
|
|
if (ctorName === 'PluckSynth' || ctorName === 'NoiseSynth' || ctorName === 'MetalSynth') {
|
|
try { return new Ctor(custom || {}); } catch (e) { return new Ctor(); }
|
|
}
|
|
try { return new Ctor(settings); } catch (e) { return new Ctor(); }
|
|
}
|
|
|
|
// ─── Drum Kit ────────────────────────────────────────────
|
|
|
|
function createDrumKit() {
|
|
return {
|
|
kick: new Tone.MembraneSynth({ pitchDecay: 0.05, octaves: 6,
|
|
envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.4 }, volume: -6 }),
|
|
snare: new Tone.NoiseSynth({ noise: { type: 'white' },
|
|
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 }, volume: -10 }),
|
|
hihat: new Tone.MetalSynth({ frequency: 400,
|
|
envelope: { attack: 0.001, decay: 0.05, release: 0.01 },
|
|
harmonicity: 5.1, modulationIndex: 32, resonance: 4000, octaves: 1.5, volume: -18 }),
|
|
tom: new Tone.MembraneSynth({ pitchDecay: 0.08, octaves: 4,
|
|
envelope: { attack: 0.001, decay: 0.25, sustain: 0, release: 0.3 }, volume: -8 }),
|
|
crash: new Tone.NoiseSynth({ noise: { type: 'white' },
|
|
envelope: { attack: 0.001, decay: 0.8, sustain: 0, release: 0.5 }, volume: -16 }),
|
|
};
|
|
}
|
|
|
|
// ─── Effects Chain ───────────────────────────────────────
|
|
// instruments -> filter -> [chorus] -> [distortion] -> delay -> reverb -> destination
|
|
|
|
function createFxChain(fx) {
|
|
var nodes = [], chain = [];
|
|
|
|
var filter = new Tone.Filter({ frequency: fx.filter || 5000, type: 'lowpass' });
|
|
nodes.push(filter); chain.push(filter);
|
|
|
|
if (fx.chorus > 0) {
|
|
var chorus = new Tone.Chorus({ frequency: 1.5, delayTime: 3.5, depth: 0.7, wet: fx.chorus });
|
|
chorus.start();
|
|
nodes.push(chorus); chain.push(chorus);
|
|
}
|
|
if (fx.distortion > 0) {
|
|
var dist = new Tone.Distortion({ distortion: fx.distortion, wet: fx.distortion });
|
|
nodes.push(dist); chain.push(dist);
|
|
}
|
|
|
|
var delay = new Tone.FeedbackDelay({ delayTime: '8n', feedback: 0.3, wet: fx.delay || 0 });
|
|
nodes.push(delay); chain.push(delay);
|
|
|
|
var reverb = new Tone.Reverb({ decay: 2.5, wet: fx.reverb || 0 });
|
|
nodes.push(reverb); chain.push(reverb);
|
|
|
|
return { nodes: nodes, chain: chain };
|
|
}
|
|
|
|
// ─── Cleanup ─────────────────────────────────────────────
|
|
|
|
function disposeAll() {
|
|
if (Tone) {
|
|
try { Tone.getTransport().cancel(0); } catch (e) {}
|
|
try { Tone.getTransport().stop(); } catch (e) {}
|
|
}
|
|
currentParts.forEach(function(p) { try { p.dispose(); } catch (e) {} });
|
|
currentParts = [];
|
|
currentNodes.forEach(function(n) { try { if (n && n.dispose) n.dispose(); } catch (e) {} });
|
|
currentNodes = [];
|
|
if (loopCbId !== null && Tone) {
|
|
try { Tone.getTransport().clear(loopCbId); } catch (e) {}
|
|
loopCbId = null;
|
|
}
|
|
}
|
|
|
|
// ─── Song Library Access ─────────────────────────────────
|
|
|
|
function getSongs() {
|
|
return (window.MusicLibrary && Array.isArray(window.MusicLibrary.songs))
|
|
? window.MusicLibrary.songs : [];
|
|
}
|
|
|
|
// ─── Song Scheduling ────────────────────────────────────
|
|
|
|
function scheduleSong(song) {
|
|
var transport = Tone.getTransport();
|
|
transport.cancel(0);
|
|
transport.stop();
|
|
transport.bpm.value = song.tempo || 120;
|
|
transport.position = 0;
|
|
|
|
var rng = seededRandom(song.id || 'default');
|
|
var key = song.key || 'C', scale = song.scale || 'minor';
|
|
var scaleNotes = multiOctave(key, scale, 3, 5);
|
|
|
|
// Create effects chain
|
|
var fx = Object.assign({ reverb: 0.2, delay: 0.1, filter: 5000, chorus: 0, distortion: 0 }, song.fx || {});
|
|
var effects = createFxChain(fx);
|
|
currentNodes = currentNodes.concat(effects.nodes);
|
|
|
|
// Master gain -> destination
|
|
masterGain = new Tone.Gain(0.7).toDestination();
|
|
currentNodes.push(masterGain);
|
|
|
|
// Wire effects chain: each node connects to the next, last connects to master gain
|
|
for (var ci = 0; ci < effects.chain.length - 1; ci++)
|
|
effects.chain[ci].connect(effects.chain[ci + 1]);
|
|
effects.chain[effects.chain.length - 1].connect(masterGain);
|
|
|
|
// Create melodic synths and route through effects
|
|
var synths = song.synths || { lead: 'FM', pad: 'Poly', bass: 'Mono' };
|
|
var ss = song.synthSettings || {};
|
|
var lead = createSynth(synths.lead, 'lead', ss.lead);
|
|
var pad = createSynth(synths.pad, 'pad', ss.pad);
|
|
var bass = createSynth(synths.bass, 'bass', ss.bass);
|
|
[lead, pad, bass].forEach(function(s) { currentNodes.push(s); s.connect(effects.chain[0]); });
|
|
|
|
// Create drum kit and route directly to master gain (no effects)
|
|
var drums = createDrumKit();
|
|
['kick', 'snare', 'hihat', 'tom', 'crash'].forEach(function(k) {
|
|
currentNodes.push(drums[k]);
|
|
drums[k].connect(masterGain);
|
|
});
|
|
|
|
// Build the complete event list from the song form
|
|
var form = song.form || ['verse', 'chorus'];
|
|
var sections = song.sections || {};
|
|
var allEvts = [], barOffset = 0;
|
|
|
|
for (var fi = 0; fi < form.length; fi++) {
|
|
var sec = sections[form[fi]];
|
|
if (!sec) continue;
|
|
|
|
var nBars = sec[0] || 4;
|
|
var chStr = sec[1] || 'i';
|
|
var mPat = sec[2] || 'stepwise';
|
|
var bPat = sec[3] || 'root';
|
|
var dPat = sec[4] || 'standard';
|
|
var intensity = sec[5] !== undefined ? sec[5] : 0.5;
|
|
|
|
var chords = parseChords(chStr, key, scale);
|
|
if (!chords.length) chords = parseChords('i', key, scale);
|
|
|
|
var mG = melGen[mPat] || melGen.stepwise;
|
|
var bG = bassGen[bPat] || bassGen.root;
|
|
var dG = drumGen[dPat] || drumGen.standard;
|
|
|
|
for (var bar = 0; bar < nBars; bar++) {
|
|
var chord = chords[bar % chords.length];
|
|
var bt = (barOffset + bar) * 4; // bar time in beats
|
|
|
|
// Melody events
|
|
try {
|
|
mG(scaleNotes, chord, intensity, bar, null, rng).forEach(function(e) {
|
|
allEvts.push({ type: 'lead', note: e.note, duration: e.duration, velocity: e.velocity, time: bt + e.time });
|
|
});
|
|
} catch (e) {}
|
|
|
|
// Pad chords (whole notes on chord tones)
|
|
if (chord.notes && chord.notes.length >= 2) {
|
|
for (var cn = 0; cn < Math.min(chord.notes.length, 3); cn++)
|
|
allEvts.push({ type: 'pad', note: chord.notes[cn], duration: '1n', velocity: 0.25 + intensity * 0.2, time: bt });
|
|
}
|
|
|
|
// Bass events
|
|
try {
|
|
bG(chord, chord.notes, scaleNotes, intensity, rng).forEach(function(e) {
|
|
allEvts.push({ type: 'bass', note: e.note, duration: e.duration, velocity: e.velocity, time: bt + e.time });
|
|
});
|
|
} catch (e) {}
|
|
|
|
// Drum events
|
|
try {
|
|
dG(intensity, 4, bar).forEach(function(e) {
|
|
allEvts.push({ type: 'drum', voice: e.voice, velocity: e.velocity, time: bt + e.time });
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
barOffset += nBars;
|
|
}
|
|
|
|
var totalSeconds = (barOffset * 4 / song.tempo) * 60;
|
|
|
|
// Schedule all events on the Tone.js Transport
|
|
allEvts.forEach(function(evt) {
|
|
var timeInSec = (evt.time / song.tempo) * 60;
|
|
transport.schedule(function(t) {
|
|
try {
|
|
if (evt.type === 'lead') {
|
|
lead.triggerAttackRelease(evt.note, evt.duration, t, evt.velocity);
|
|
} else if (evt.type === 'pad' && typeof pad.triggerAttackRelease === 'function') {
|
|
pad.triggerAttackRelease(evt.note, evt.duration, t, evt.velocity);
|
|
} else if (evt.type === 'bass') {
|
|
bass.triggerAttackRelease(evt.note, evt.duration, t, evt.velocity);
|
|
} else if (evt.type === 'drum') {
|
|
var inst = drums[evt.voice];
|
|
if (!inst) return;
|
|
if (evt.voice === 'kick' || evt.voice === 'tom')
|
|
inst.triggerAttackRelease(evt.voice === 'kick' ? 'C1' : 'G2', '8n', t, evt.velocity);
|
|
else if (evt.voice === 'snare' || evt.voice === 'crash')
|
|
inst.triggerAttackRelease('8n', t, evt.velocity);
|
|
else if (evt.voice === 'hihat')
|
|
inst.triggerAttackRelease('32n', t, evt.velocity);
|
|
}
|
|
} catch (err) { /* skip individual note errors */ }
|
|
}, timeInSec);
|
|
});
|
|
|
|
// Schedule loop: when song ends, dispose and re-schedule (skip intro on loop)
|
|
loopCbId = transport.schedule(function() {
|
|
transport.stop();
|
|
setTimeout(function() {
|
|
if (playing && currentSong && currentSong.id === song.id) {
|
|
disposeAll();
|
|
scheduleSong(song);
|
|
Tone.getTransport().start();
|
|
if (window.MusicEngine.onSongEnd) {
|
|
try { window.MusicEngine.onSongEnd(song); } catch (e) {}
|
|
}
|
|
}
|
|
}, 50);
|
|
}, totalSeconds);
|
|
|
|
return { totalSeconds: totalSeconds, totalBars: barOffset };
|
|
}
|
|
|
|
// ─── Metadata helper ─────────────────────────────────────
|
|
|
|
function meta(s) {
|
|
return { id: s.id, name: s.name, mood: s.mood, energy: s.energy, tempo: s.tempo, tags: s.tags, desc: s.desc };
|
|
}
|
|
|
|
// ─── Public API ──────────────────────────────────────────
|
|
|
|
window.MusicEngine = {
|
|
|
|
onSongEnd: null,
|
|
|
|
/** Initialize the engine. Must be called on a user gesture (click/touch). */
|
|
async init() {
|
|
if (initialized) return;
|
|
Tone = await loadToneJs();
|
|
await Tone.start();
|
|
initialized = true;
|
|
},
|
|
|
|
/** Play a random song matching the given mood. Returns the song object or null. */
|
|
async playRandomSong(mood) {
|
|
if (!initialized) await this.init();
|
|
var songs = getSongs().filter(function(s) {
|
|
return s.mood && s.mood.toLowerCase() === mood.toLowerCase();
|
|
});
|
|
if (!songs.length) return null;
|
|
return this.playSong(songs[Math.floor(Math.random() * songs.length)].id);
|
|
},
|
|
|
|
/** Play a specific song by ID. Returns the song object or null. */
|
|
async playSong(songId) {
|
|
if (!initialized) await this.init();
|
|
var song = null;
|
|
getSongs().forEach(function(s) { if (s.id === songId) song = s; });
|
|
if (!song) return null;
|
|
this.stopMusic();
|
|
try {
|
|
currentSong = song;
|
|
playing = true;
|
|
scheduleSong(song);
|
|
Tone.getTransport().start();
|
|
return song;
|
|
} catch (e) {
|
|
this.stopMusic();
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/** Stop the current song immediately and clean up all Tone.js nodes. */
|
|
stopMusic() {
|
|
playing = false;
|
|
currentSong = null;
|
|
disposeAll();
|
|
},
|
|
|
|
/** Set master volume (0 = silent, 1 = full). */
|
|
setVolume(level) {
|
|
var v = Math.max(0, Math.min(1, level));
|
|
if (masterGain) masterGain.gain.value = v;
|
|
if (Tone && Tone.getDestination) {
|
|
try { Tone.getDestination().volume.value = v === 0 ? -Infinity : 20 * Math.log10(v); } catch (e) {}
|
|
}
|
|
},
|
|
|
|
/** Fade out current song over duration (seconds), then stop. */
|
|
fadeOut(duration) {
|
|
duration = duration || 2;
|
|
if (!masterGain || !playing) { this.stopMusic(); return; }
|
|
var self = this;
|
|
try {
|
|
masterGain.gain.linearRampTo(0, duration);
|
|
setTimeout(function() { self.stopMusic(); }, duration * 1000 + 100);
|
|
} catch (e) { self.stopMusic(); }
|
|
},
|
|
|
|
/** Get the full catalog of songs with metadata. */
|
|
getCatalog() {
|
|
return getSongs().map(meta);
|
|
},
|
|
|
|
/** Filter songs by criteria: { mood, minEnergy, maxEnergy, minTempo, maxTempo, tags }. */
|
|
findSongs(filters) {
|
|
if (!filters) return this.getCatalog();
|
|
return getSongs().filter(function(s) {
|
|
if (filters.mood && s.mood && s.mood.toLowerCase() !== filters.mood.toLowerCase()) return false;
|
|
if (filters.minEnergy !== undefined && s.energy < filters.minEnergy) return false;
|
|
if (filters.maxEnergy !== undefined && s.energy > filters.maxEnergy) return false;
|
|
if (filters.minTempo !== undefined && s.tempo < filters.minTempo) return false;
|
|
if (filters.maxTempo !== undefined && s.tempo > filters.maxTempo) return false;
|
|
if (filters.tags && filters.tags.length) {
|
|
var st = s.tags || [], found = false;
|
|
for (var i = 0; i < filters.tags.length; i++)
|
|
if (st.indexOf(filters.tags[i]) >= 0) { found = true; break; }
|
|
if (!found) return false;
|
|
}
|
|
return true;
|
|
}).map(meta);
|
|
},
|
|
|
|
/** Get metadata for a specific song (includes key and scale). */
|
|
getMetadata(songId) {
|
|
var result = null;
|
|
getSongs().forEach(function(s) {
|
|
if (s.id === songId) result = {
|
|
id: s.id, name: s.name, mood: s.mood, energy: s.energy, tempo: s.tempo,
|
|
key: s.key, scale: s.scale, tags: s.tags, desc: s.desc,
|
|
};
|
|
});
|
|
return result;
|
|
},
|
|
|
|
/** Get currently playing song info, or null. */
|
|
getNowPlaying() {
|
|
return (currentSong && playing) ? meta(currentSong) : null;
|
|
},
|
|
|
|
/** Check if music is currently playing. */
|
|
isPlaying() { return playing; },
|
|
|
|
/** Pause playback. */
|
|
pause() {
|
|
if (playing && Tone) try { Tone.getTransport().pause(); } catch (e) {}
|
|
},
|
|
|
|
/** Resume playback after pause. */
|
|
resume() {
|
|
if (playing && Tone) try { Tone.getTransport().start(); } catch (e) {}
|
|
},
|
|
};
|
|
|
|
})();
|