Initial commit
This commit is contained in:
@@ -0,0 +1,848 @@
|
||||
// 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) {}
|
||||
},
|
||||
};
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user