// music.jsx — procedural music engine for Sendly // Generates short looping tunes via the Web Audio API. // Tracks the last-played history in localStorage so the same visitor doesn't // hear the same track twice within 7 days. const { useState, useEffect, useRef } = React; const HISTORY_KEY = "sendly:music-history"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const MUTE_KEY = "sendly:music-muted"; /* ----------------------- Engine helpers ----------------------- */ function midi(n) { return 440 * Math.pow(2, (n - 69) / 12); } class MusicEngine { constructor() { this.ctx = null; this.master = null; this.activeNodes = []; this.timers = []; this.playing = false; this.currentTrack = null; this.playToken = 0; // bumped on every play()/stop() — in-flight calls check this before scheduling this.muted = (typeof localStorage !== "undefined" && localStorage.getItem(MUTE_KEY) === "1"); this.listeners = new Set(); } _emit() { this.listeners.forEach(fn => fn()); } on(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); } _init() { if (this.ctx) return; const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; this.ctx = new AC(); this.master = this.ctx.createGain(); this.master.gain.value = this.muted ? 0 : 0.12; // Subtle high-shelf cut so it doesn't feel piercing in the browser const lp = this.ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 4800; lp.Q.value = 0.4; this.master.connect(lp); lp.connect(this.ctx.destination); } // --- low-level note helpers --- _note({ freq, t, dur, gain = 0.3, type = "triangle", attack = 0.01, release = 0.1, detune = 0 }) { const ctx = this.ctx; const osc = ctx.createOscillator(); const env = ctx.createGain(); osc.type = type; osc.frequency.value = freq; osc.detune.value = detune; const start = ctx.currentTime + t; env.gain.setValueAtTime(0, start); env.gain.linearRampToValueAtTime(gain, start + attack); env.gain.exponentialRampToValueAtTime(0.0001, start + Math.max(dur, attack + 0.02)); osc.connect(env); env.connect(this.master); osc.start(start); osc.stop(start + dur + release); this.activeNodes.push(osc); } _kick(t) { const ctx = this.ctx; const osc = ctx.createOscillator(); const env = ctx.createGain(); const start = ctx.currentTime + t; osc.frequency.setValueAtTime(120, start); osc.frequency.exponentialRampToValueAtTime(40, start + 0.18); env.gain.setValueAtTime(0.55, start); env.gain.exponentialRampToValueAtTime(0.001, start + 0.2); osc.connect(env); env.connect(this.master); osc.start(start); osc.stop(start + 0.25); this.activeNodes.push(osc); } _hat(t, dur = 0.04) { const ctx = this.ctx; const bufferSize = 0.1 * ctx.sampleRate; const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1); const src = ctx.createBufferSource(); src.buffer = buffer; const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 7000; const env = ctx.createGain(); const start = ctx.currentTime + t; env.gain.setValueAtTime(0.13, start); env.gain.exponentialRampToValueAtTime(0.001, start + dur); src.connect(hp); hp.connect(env); env.connect(this.master); src.start(start); src.stop(start + dur + 0.02); this.activeNodes.push(src); } _snap(t) { // a soft clap/snap built from noise const ctx = this.ctx; const bufferSize = 0.12 * ctx.sampleRate; const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1); const src = ctx.createBufferSource(); src.buffer = buffer; const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 1500; bp.Q.value = 1.3; const env = ctx.createGain(); const start = ctx.currentTime + t; env.gain.setValueAtTime(0.22, start); env.gain.exponentialRampToValueAtTime(0.001, start + 0.12); src.connect(bp); bp.connect(env); env.connect(this.master); src.start(start); src.stop(start + 0.14); this.activeNodes.push(src); } // FM-style bell — for music-box and glockenspiel timbres // The optional `wobble` adds a tiny slow vibrato so it feels like a real wind-up mechanism. _bell({ freq, t, dur = 1.6, gain = 0.22, wobble = true }) { const ctx = this.ctx; const carrier = ctx.createOscillator(); const modulator = ctx.createOscillator(); const modGain = ctx.createGain(); const env = ctx.createGain(); carrier.type = "sine"; modulator.type = "sine"; carrier.frequency.value = freq; modulator.frequency.value = freq * 3.0; // bell-like ratio modGain.gain.value = freq * 2.0; const start = ctx.currentTime + t; env.gain.setValueAtTime(0, start); env.gain.linearRampToValueAtTime(gain, start + 0.005); env.gain.exponentialRampToValueAtTime(0.0001, start + dur); modulator.connect(modGain); modGain.connect(carrier.frequency); carrier.connect(env); env.connect(this.master); // Subtle wind-up wobble — makes the bell feel like a real music-box comb let vib, vibGain; if (wobble) { vib = ctx.createOscillator(); vibGain = ctx.createGain(); vib.frequency.value = 4.5; vibGain.gain.value = freq * 0.004; vib.connect(vibGain); vibGain.connect(carrier.frequency); vib.start(start); vib.stop(start + dur + 0.05); this.activeNodes.push(vib); } modulator.start(start); carrier.start(start); modulator.stop(start + dur + 0.05); carrier.stop(start + dur + 0.05); this.activeNodes.push(carrier, modulator); } // Music-box comb pluck — brighter, shorter, more mechanical than _bell _comb({ freq, t, dur = 0.9, gain = 0.18 }) { const ctx = this.ctx; // two partials: fundamental + an inharmonic high partial (real combs have one) const fund = ctx.createOscillator(); const part = ctx.createOscillator(); const env = ctx.createGain(); const env2 = ctx.createGain(); fund.type = "sine"; part.type = "sine"; fund.frequency.value = freq; part.frequency.value = freq * 4.2; // metallic shimmer partial const start = ctx.currentTime + t; env.gain.setValueAtTime(0, start); env.gain.linearRampToValueAtTime(gain, start + 0.002); env.gain.exponentialRampToValueAtTime(0.0001, start + dur); env2.gain.setValueAtTime(0, start); env2.gain.linearRampToValueAtTime(gain * 0.35, start + 0.002); env2.gain.exponentialRampToValueAtTime(0.0001, start + dur * 0.35); fund.connect(env); part.connect(env2); env.connect(this.master); env2.connect(this.master); fund.start(start); part.start(start); fund.stop(start + dur + 0.05); part.stop(start + dur * 0.4 + 0.05); this.activeNodes.push(fund, part); } // Warm sustained pad — for strings/accordion swells _pad({ freq, t, dur, gain = 0.08, detune = 0 }) { const ctx = this.ctx; // Two slightly detuned oscillators for chorus warmth const a = ctx.createOscillator(); const b = ctx.createOscillator(); const env = ctx.createGain(); const lp = ctx.createBiquadFilter(); a.type = "sawtooth"; b.type = "sawtooth"; a.frequency.value = freq; b.frequency.value = freq; a.detune.value = detune - 8; b.detune.value = detune + 8; lp.type = "lowpass"; lp.frequency.value = 1600; lp.Q.value = 0.6; const start = ctx.currentTime + t; env.gain.setValueAtTime(0, start); env.gain.linearRampToValueAtTime(gain, start + 0.35); env.gain.setValueAtTime(gain, start + dur - 0.4); env.gain.exponentialRampToValueAtTime(0.0001, start + dur); a.connect(lp); b.connect(lp); lp.connect(env); env.connect(this.master); a.start(start); b.start(start); a.stop(start + dur + 0.05); b.stop(start + dur + 0.05); this.activeNodes.push(a, b); } // Soft plucked-string (piano/guitar-ish) _pluck({ freq, t, dur = 1.2, gain = 0.2 }) { const ctx = this.ctx; const osc = ctx.createOscillator(); const env = ctx.createGain(); const lp = ctx.createBiquadFilter(); osc.type = "triangle"; osc.frequency.value = freq; lp.type = "lowpass"; lp.frequency.value = 2400; lp.Q.value = 0.5; const start = ctx.currentTime + t; env.gain.setValueAtTime(0, start); env.gain.linearRampToValueAtTime(gain, start + 0.008); env.gain.exponentialRampToValueAtTime(0.0001, start + dur); osc.connect(lp); lp.connect(env); env.connect(this.master); osc.start(start); osc.stop(start + dur + 0.05); this.activeNodes.push(osc); } /* ----------------------- public API ----------------------- */ async play(track) { // Bump the token FIRST so any earlier in-flight play() bails out after its await. const myToken = ++this.playToken; this.stop({ keepToken: true }); this._init(); if (!this.ctx) return false; if (this.ctx.state === "suspended") { try { await this.ctx.resume(); } catch (e) { /* autoplay blocked */ } } // If another play() started while we were awaiting, abandon this one. if (myToken !== this.playToken) return false; if (this.ctx.state !== "running") return false; this.currentTrack = track; this.playing = true; this._emit(); track.play(this); return true; } stop(opts = {}) { if (!opts.keepToken) this.playToken++; this.playing = false; this.activeNodes.forEach(n => { try { n.stop(); } catch (e) {} }); this.activeNodes = []; this.timers.forEach(t => clearTimeout(t)); this.timers = []; this._emit(); } setMuted(m) { this.muted = m; if (this.master) this.master.gain.value = m ? 0 : 0.12; try { localStorage.setItem(MUTE_KEY, m ? "1" : "0"); } catch (e) {} this._emit(); } /* schedule a callback at `t` seconds from now (relative to play start) */ _at(secs, fn) { const id = setTimeout(fn, secs * 1000); this.timers.push(id); } /* loop one measure of the given function every `measureSec` seconds */ _loop(measureSec, renderMeasure) { let offset = 0; const run = () => { if (!this.playing) return; // schedule notes for this measure relative to AudioContext time renderMeasure(0); // notes use _note with t relative to "now" this._at(measureSec - 0.02, run); }; run(); } } /* ============================ TRACKS ============================ Each track is a function(engine) that begins looping notes. Keep them short and distinct so visitors can tell them apart. ================================================================== */ const TRACKS = [ { id: "little-box", name: "The Little Box", emoji: "🎁", play: (e) => { // Classic jewelry-box reveal — delicate, slow, in C major const bpm = 68, beat = 60 / bpm, measure = 4 * beat; // I – vi – IV – V (the most "opened a music box" progression) const chords = [ { root: 48, tone: [72, 76, 79] }, // C { root: 45, tone: [69, 72, 76] }, // Am { root: 53, tone: [72, 77, 81] }, // F { root: 55, tone: [74, 79, 83] }, // G ]; // an original twinkling melody (not a known tune) const phrases = [ [79, 76, 72, 76, 79, 84, 79, 76], [76, 72, 69, 72, 76, 79, 76, 72], [77, 81, 84, 81, 77, 72, 77, 81], [79, 74, 71, 74, 79, 83, 79, 74], ]; let bar = 0; const renderMeasure = () => { const ch = chords[bar % chords.length]; const ph = phrases[bar % phrases.length]; // sparkling comb melody on top ph.forEach((n, i) => { e._comb({ freq: midi(n + 12), t: i * (beat / 2), dur: 0.9, gain: 0.13 }); }); // slow bell tones underneath, sustained ch.tone.forEach((n, i) => { e._bell({ freq: midi(n), t: i * 0.04, dur: measure * 0.95, gain: 0.06 }); }); // bass tick on 1 e._bell({ freq: midi(ch.root), t: 0, dur: 2.0, gain: 0.1, wobble: false }); bar++; }; e._loop(measure, renderMeasure); } }, { id: "ballerina-turn", name: "Ballerina's Turn", emoji: "🩰", play: (e) => { // The pirouetting jewelry-box ballerina — 3/4 waltz on music-box comb const bpm = 96, beat = 60 / bpm, measure = 3 * beat; // F major waltz progression const chords = [ { root: 41, top: [65, 69, 72] }, // F { root: 43, top: [67, 70, 74] }, // Gm { root: 41, top: [65, 72, 74] }, // F/A { root: 48, top: [67, 72, 76] }, // C ]; // original waltz melody, all in F major const mels = [ [77, 81, 84], [82, 79, 77], [81, 84, 81], [79, 76, 72], ]; let bar = 0; const renderMeasure = () => { const ch = chords[bar % chords.length]; // oom-pa-pa on the comb e._comb({ freq: midi(ch.root), t: 0, dur: beat * 0.7, gain: 0.18 }); e._comb({ freq: midi(ch.top[0]), t: beat, dur: beat * 0.6, gain: 0.12 }); e._comb({ freq: midi(ch.top[1]), t: beat, dur: beat * 0.6, gain: 0.10 }); e._comb({ freq: midi(ch.top[0]), t: beat * 2, dur: beat * 0.6, gain: 0.12 }); e._comb({ freq: midi(ch.top[2]), t: beat * 2, dur: beat * 0.6, gain: 0.10 }); // slow melody (half-bar notes) high above const m = mels[bar % mels.length]; m.forEach((n, i) => { e._bell({ freq: midi(n + 12), t: i * beat, dur: beat * 1.4, gain: 0.10 }); }); bar++; }; e._loop(measure, renderMeasure); } }, { id: "wind-up-lullaby", name: "Wind-Up Lullaby", emoji: "🌙", play: (e) => { // Slow, sleepy music-box lullaby — like a wind-up toy beside a bed const bpm = 60, beat = 60 / bpm, measure = 4 * beat; // G major: I – V – vi – IV const chords = [ { root: 43, notes: [67, 71, 74] }, // G { root: 50, notes: [62, 66, 69] }, // D { root: 40, notes: [64, 67, 71] }, // Em { root: 48, notes: [60, 64, 67] }, // C ]; // gentle descending phrases (lullaby contour) const phrases = [ [79, 78, 76, 74, 72, 71, 67, 71], [78, 76, 74, 72, 71, 69, 66, 69], [76, 74, 72, 71, 67, 64, 67, 71], [72, 71, 69, 67, 64, 60, 67, 72], ]; let bar = 0; const renderMeasure = () => { const ch = chords[bar % chords.length]; const ph = phrases[bar % phrases.length]; ph.forEach((n, i) => { e._comb({ freq: midi(n + 12), t: i * (beat / 2), dur: 1.4, gain: 0.11 }); }); // soft sustained pad underneath — the felt of the lid ch.notes.forEach(n => { e._pad({ freq: midi(n), t: 0, dur: measure * 0.95, gain: 0.035 }); }); // very slow root bell on beat 1 e._bell({ freq: midi(ch.root), t: 0, dur: 3.0, gain: 0.09 }); bar++; }; e._loop(measure, renderMeasure); } }, { id: "snow-globe", name: "Snow Globe", emoji: "❄️", play: (e) => { // Tiny chimes drifting through a glass globe — the music inside a souvenir const bpm = 72, beat = 60 / bpm, measure = 4 * beat; // Em – C – G – D (gentle wonder) const chords = [ { root: 40, tone: [64, 67, 71] }, { root: 36, tone: [60, 64, 67] }, { root: 43, tone: [67, 71, 74] }, { root: 38, tone: [62, 66, 69] }, ]; let bar = 0; const renderMeasure = () => { const ch = chords[bar % chords.length]; // pad cushion ch.tone.forEach((n, i) => { e._pad({ freq: midi(n), t: 0, dur: measure * 0.95, gain: 0.04, detune: i * 4 }); }); // sparse chime drops — falling snowflakes const drops = [ { n: ch.tone[2] + 12, t: 0 }, { n: ch.tone[1] + 12, t: beat * 0.75 }, { n: ch.tone[2] + 19, t: beat * 1.5 }, { n: ch.tone[0] + 12, t: beat * 2.25 }, { n: ch.tone[2] + 12, t: beat * 3 }, { n: ch.tone[1] + 19, t: beat * 3.5 }, ]; drops.forEach(d => { e._bell({ freq: midi(d.n), t: d.t, dur: 2.8, gain: 0.09 }); }); bar++; }; e._loop(measure, renderMeasure); } }, { id: "carousel-memories", name: "Carousel of Memories", emoji: "🎠", play: (e) => { // A music-box carousel theme — turning slowly, lights in the trees const bpm = 104, beat = 60 / bpm, measure = 3 * beat; // D major waltz — I – IV – V – I const chords = [ { root: 38, top: [62, 66, 69] }, // D { root: 43, top: [62, 67, 71] }, // G { root: 45, top: [64, 69, 73] }, // A { root: 38, top: [62, 66, 69] }, // D ]; // hummable original tune const mels = [ [78, 81, 78, 74, 78], [79, 83, 79, 74, 79], [80, 78, 76, 73, 76], [78, 74, 69, 74, 78], ]; let bar = 0; const renderMeasure = () => { const ch = chords[bar % chords.length]; // bass on 1, comb stabs on 2 and 3 e._comb({ freq: midi(ch.root), t: 0, dur: beat * 0.7, gain: 0.18 }); e._comb({ freq: midi(ch.top[0]), t: beat, dur: beat * 0.5, gain: 0.10 }); e._comb({ freq: midi(ch.top[1]), t: beat, dur: beat * 0.5, gain: 0.09 }); e._comb({ freq: midi(ch.top[2]), t: beat * 2, dur: beat * 0.5, gain: 0.09 }); e._comb({ freq: midi(ch.top[1]), t: beat * 2, dur: beat * 0.5, gain: 0.09 }); // melody bells const m = mels[bar % mels.length]; m.forEach((n, i) => { e._bell({ freq: midi(n), t: i * (measure / m.length), dur: 1.2, gain: 0.10 }); }); bar++; }; e._loop(measure, renderMeasure); } }, { id: "locket", name: "The Locket", emoji: "💗", play: (e) => { // Tiny, intimate, the music inside a kept locket — minor-key tenderness const bpm = 76, beat = 60 / bpm, measure = 4 * beat; // Am – Em – F – G (wistful, looking back) const chords = [ { root: 45, tone: [69, 72, 76] }, // Am { root: 40, tone: [64, 67, 71] }, // Em { root: 41, tone: [65, 69, 72] }, // F { root: 43, tone: [67, 71, 74] }, // G ]; // descending tender melodies const phrases = [ [81, 80, 76, 72, 76, 80, 76, 72], [79, 76, 72, 67, 71, 76, 72, 67], [77, 81, 84, 81, 77, 72, 69, 65], [79, 83, 79, 74, 71, 67, 71, 74], ]; let bar = 0; const renderMeasure = () => { const ch = chords[bar % chords.length]; const ph = phrases[bar % phrases.length]; ph.forEach((n, i) => { e._comb({ freq: midi(n + 12), t: i * (beat / 2), dur: 1.1, gain: 0.11 }); }); // sustained pad ch.tone.forEach(n => { e._pad({ freq: midi(n), t: 0, dur: measure * 0.95, gain: 0.04 }); }); // soft heartbeat bass e._bell({ freq: midi(ch.root), t: 0, dur: 2.5, gain: 0.09 }); e._bell({ freq: midi(ch.root), t: beat * 2, dur: 2.0, gain: 0.07 }); bar++; }; e._loop(measure, renderMeasure); } }, { id: "wish-upon", name: "Wish Upon", emoji: "✨", play: (e) => { // Rising hopeful music-box theme — the wish you made over the candles const bpm = 84, beat = 60 / bpm, measure = 4 * beat; // F – C – Dm – Bb (a hopeful ascending feel) const chords = [ { root: 41, tone: [65, 69, 72] }, // F { root: 48, tone: [67, 72, 76] }, // C { root: 38, tone: [65, 69, 74] }, // Dm { root: 46, tone: [65, 70, 74] }, // Bb ]; // gently ascending phrases (wishes lifting up) const phrases = [ [72, 74, 77, 81, 84, 81, 77, 74], [76, 79, 83, 86, 88, 86, 83, 79], [74, 77, 81, 84, 86, 84, 81, 77], [70, 74, 77, 82, 86, 82, 77, 74], ]; let bar = 0; const renderMeasure = () => { const ch = chords[bar % chords.length]; const ph = phrases[bar % phrases.length]; ph.forEach((n, i) => { e._comb({ freq: midi(n), t: i * (beat / 2), dur: 1.0, gain: 0.11 }); }); // pad shimmer ch.tone.forEach((n, i) => { e._pad({ freq: midi(n), t: 0, dur: measure * 0.95, gain: 0.04, detune: i * 5 }); }); // little high sparkle on beat 1 and 3 — like a wish bubble popping e._bell({ freq: midi(ch.tone[2] + 24), t: 0, dur: 1.6, gain: 0.07 }); e._bell({ freq: midi(ch.tone[1] + 24), t: beat * 2, dur: 1.6, gain: 0.06 }); // bass root e._bell({ freq: midi(ch.root), t: 0, dur: measure * 0.95, gain: 0.08, wobble: false }); bar++; }; e._loop(measure, renderMeasure); } }, /* ============================================================ Public-domain classics — the tunes the whole world remembers. All composers d. before 1900; melodies are public domain worldwide. ============================================================ */ { id: "twinkle-twinkle", name: "Twinkle, Twinkle", emoji: "⭐", play: (e) => { // Mozart's "Ah! vous dirai-je, maman" theme (a.k.a. Twinkle Twinkle / ABC song) // The first tune nearly every person on Earth learns. Public domain. const bpm = 80, beat = 60 / bpm; // Full 12-bar melody, one note per beat // C C G G A A G | F F E E D D C | G G F F E E D | G G F F E E D // C C G G A A G | F F E E D D C const mel = [ 60, 60, 67, 67, 69, 69, 67, /*rest*/ 65, 65, 64, 64, 62, 62, 60, 67, 67, 65, 65, 64, 64, 62, 67, 67, 65, 65, 64, 64, 62, 60, 60, 67, 67, 69, 69, 67, 65, 65, 64, 64, 62, 62, 60, ]; // simple bass — root note per 4-beat group const bass = [ 48, 48, 53, 48, 55, 55, 55, 48, 48, 48, 53, 48, ]; const tuneLen = mel.length * beat; // total tune in seconds const renderMeasure = () => { // schedule the whole tune in one shot, then loop mel.forEach((n, i) => { e._comb({ freq: midi(n + 12), t: i * beat, dur: beat * 1.4, gain: 0.13 }); }); bass.forEach((n, i) => { e._bell({ freq: midi(n), t: i * (beat * 3.5), dur: beat * 3.2, gain: 0.08, wobble: false }); }); // sparkle accents at the start and middle e._bell({ freq: midi(84), t: 0, dur: 2.5, gain: 0.07 }); e._bell({ freq: midi(84), t: tuneLen / 2, dur: 2.5, gain: 0.07 }); }; // Use the whole tune as one "measure" so it loops as a complete song e._loop(tuneLen + 1.0, renderMeasure); } }, { id: "brahms-lullaby", name: "Brahms' Lullaby", emoji: "🌙", play: (e) => { // Brahms, Wiegenlied Op. 49 No. 4 (1868). Public domain. // The music-box lullaby played in cribs around the world. // Transposed to C major, 3/4. Approximate phrase shape. const bpm = 80, beat = 60 / bpm, measure = 3 * beat; // 16-bar simplified arrangement of the melody phrase by phrase // Phrase A: G G E' (dotted) | G G E' | G E' C' B A G F E | D C ... // We'll lay it out as note+beats arrays per bar (3 beats per bar) const phrase = [ // bar 1-2: "Lul - la - by, and good night" [{ n: 67, b: 1 }, { n: 67, b: 1 }, { n: 72, b: 1 }], [{ n: 67, b: 1 }, { n: 67, b: 1 }, { n: 72, b: 1 }], // bar 3-4: "with pink ro - ses be - dight" [{ n: 67, b: 0.5 }, { n: 72, b: 0.5 }, { n: 76, b: 1 }, { n: 74, b: 1 }], [{ n: 72, b: 0.5 }, { n: 74, b: 0.5 }, { n: 72, b: 1 }, { n: 71, b: 1 }], // bar 5-6: "with li - lies o'er - spread" [{ n: 65, b: 1 }, { n: 65, b: 1 }, { n: 71, b: 1 }], [{ n: 65, b: 1 }, { n: 65, b: 1 }, { n: 71, b: 1 }], // bar 7-8: "is ba - by's wee bed" [{ n: 65, b: 0.5 }, { n: 71, b: 0.5 }, { n: 74, b: 1 }, { n: 72, b: 1 }], [{ n: 71, b: 0.5 }, { n: 69, b: 0.5 }, { n: 67, b: 2 }], ]; // bass per bar (root of underlying chord — I, V, I, V, IV, IV, V, I) const bass = [48, 55, 48, 55, 53, 53, 55, 48]; let bar = 0; const renderMeasure = () => { const ph = phrase[bar % phrase.length]; let t = 0; ph.forEach(({ n, b }) => { e._comb({ freq: midi(n + 12), t: t * beat, dur: b * beat * 1.1, gain: 0.13 }); t += b; }); // gentle bass + pad const r = bass[bar % bass.length]; e._bell({ freq: midi(r), t: 0, dur: measure * 0.9, gain: 0.08, wobble: false }); e._pad({ freq: midi(r + 12), t: 0, dur: measure * 0.9, gain: 0.035 }); e._pad({ freq: midi(r + 16), t: 0, dur: measure * 0.9, gain: 0.03 }); bar++; }; e._loop(measure, renderMeasure); } }, { id: "pachelbel-canon", name: "Canon in D", emoji: "💍", play: (e) => { // Pachelbel's Canon in D (c. 1680). Public domain. // The chord progression behind a thousand weddings, graduations and goodbyes. // Transposed to C major for simplicity. We loop the famous 8-bar ground bass // with a flowing eighth-note arpeggio over it (one of the canon's voices). const bpm = 72, beat = 60 / bpm, measure = 2 * beat; // Pachelbel ground (transposed to C): C – G – Am – Em – F – C – F – G const ground = [ { root: 48, top: [60, 64, 67, 72] }, // C { root: 43, top: [55, 59, 62, 67] }, // G { root: 45, top: [57, 60, 64, 69] }, // Am { root: 40, top: [52, 55, 59, 64] }, // Em { root: 41, top: [53, 57, 60, 65] }, // F { root: 48, top: [60, 64, 67, 72] }, // C { root: 41, top: [53, 57, 60, 65] }, // F { root: 43, top: [55, 59, 62, 67] }, // G ]; // The famous descending eighth-note melodic figure that floats above the ground. // Over 2-beat bar, schedule 8 eighth notes outlining the chord. let bar = 0; const renderMeasure = () => { const ch = ground[bar % ground.length]; // 8 eighth notes — arpeggiate top down to root and back up const arp = [ ch.top[3], ch.top[2], ch.top[1], ch.top[2], ch.top[3], ch.top[2], ch.top[1], ch.top[0], ]; arp.forEach((n, i) => { e._comb({ freq: midi(n + 12), t: i * (beat / 4), dur: 1.0, gain: 0.11 }); }); // bass — root for full bar (one of the canon's two-note ground steps per bar) e._bell({ freq: midi(ch.root - 12), t: 0, dur: measure * 0.95, gain: 0.09, wobble: false }); // inner harmony pad — root + fifth sustained e._pad({ freq: midi(ch.top[0]), t: 0, dur: measure * 0.95, gain: 0.04 }); e._pad({ freq: midi(ch.top[2]), t: 0, dur: measure * 0.95, gain: 0.035 }); bar++; }; e._loop(measure, renderMeasure); } }, ]; /* ----------------------- History (no-repeat-in-7-days) ----------------------- */ function readHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function recordPlay(trackId) { try { const now = Date.now(); const hist = readHistory().filter(h => now - h.ts < WEEK_MS); // drop entries older than a week hist.push({ id: trackId, ts: now }); localStorage.setItem(HISTORY_KEY, JSON.stringify(hist.slice(-30))); } catch (e) {} } function pickFreshTrack(excludeId = null) { const now = Date.now(); const recent = readHistory().filter(h => now - h.ts < WEEK_MS).map(h => h.id); let pool = TRACKS.filter(t => !recent.includes(t.id) && t.id !== excludeId); if (pool.length === 0) { // every track played within the last week — reset and pick any (except current) try { localStorage.removeItem(HISTORY_KEY); } catch (e) {} pool = TRACKS.filter(t => t.id !== excludeId); if (pool.length === 0) pool = TRACKS; } return pool[Math.floor(Math.random() * pool.length)]; } /* ----------------------- Singleton + React widget ----------------------- */ const musicEngine = new MusicEngine(); function MusicWidget({ t }) { const [, force] = useState(0); const [needsTap, setNeedsTap] = useState(true); const [trackInfo, setTrackInfo] = useState(null); useEffect(() => musicEngine.on(() => force(x => x + 1)), []); // Try to auto-start on mount; if blocked, wait for user tap useEffect(() => { let cancelled = false; (async () => { const track = pickFreshTrack(); const ok = await musicEngine.play(track); if (cancelled) return; if (ok) { recordPlay(track.id); setTrackInfo(track); setNeedsTap(false); } else { // autoplay blocked — wait for first interaction setTrackInfo(track); setNeedsTap(true); } })(); return () => { cancelled = true; }; }, []); async function start() { const track = trackInfo || pickFreshTrack(); const ok = await musicEngine.play(track); if (ok) { recordPlay(track.id); setNeedsTap(false); setTrackInfo(track); } } async function skip() { const next = pickFreshTrack(musicEngine.currentTrack && musicEngine.currentTrack.id); const ok = await musicEngine.play(next); if (ok) { recordPlay(next.id); setTrackInfo(next); setNeedsTap(false); } } function toggleMute() { musicEngine.setMuted(!musicEngine.muted); } // Listen for ANY user gesture and retry start if music is still blocked useEffect(() => { if (!needsTap) return; let triggered = false; const handler = () => { if (triggered) return; triggered = true; start(); }; window.addEventListener("pointerdown", handler, { once: true }); return () => window.removeEventListener("pointerdown", handler); }, [needsTap, trackInfo]); const current = trackInfo || TRACKS[0]; if (needsTap) { return ( ); } return (
Now playing {current.emoji} {current.name}
); } Object.assign(window, { MusicWidget, musicEngine, TRACKS });