diff --git a/src/components/MusicPlayer.tsx b/src/components/MusicPlayer.tsx index 8796df7..be8a720 100644 --- a/src/components/MusicPlayer.tsx +++ b/src/components/MusicPlayer.tsx @@ -43,12 +43,11 @@ function WaveformBars({ playing }: { playing: boolean }) { } // ─── ambient audio via Web Audio API ──────────────────────────────────────── -// Am9 pad: A3·C4·E4·G4·B4 – swelling envelopes, no static buzz // -// Design: each note breathes in and out independently (8–18s cycle). -// Two slightly-detuned copies per note create a natural "chorus" shimmer. -// Two long delay lines add hall-reverb depth. -// Master gain stays low; no low bass (no 55/110 Hz → no buzzing). +// Three layers: +// 1. PAD – Am9 chord (A3·C4·E4·G4·B4), detuned pairs, breathing swells +// 2. MELODY – slow A-minor phrase (like a distant cello), with vibrato +// 3. DYNAMICS – 60-second master arc (quiet → swell → quiet) for emotional peaks function useAmbient() { const ctxRef = useRef(null) @@ -61,77 +60,144 @@ function useAmbient() { const ctx = new AudioContext() ctxRef.current = ctx - // ── output chain ────────────────────────────────────────────────── + // ── Output ───────────────────────────────────────────────────────── const master = ctx.createGain() - master.gain.value = 0.18 + master.gain.value = 0.15 master.connect(ctx.destination) - // Two hall-reverb delay lines (no feedback loop → no runaway) - const mkDelay = (t: number, wet: number) => { - const d = ctx.createDelay(4.0) - d.delayTime.value = t + // Hall reverb (two delay lines, no feedback → no runaway) + const mkHall = (delayTime: number, wet: number) => { + const d = ctx.createDelay(5.0) + d.delayTime.value = delayTime const g = ctx.createGain() g.gain.value = wet - d.connect(g) - g.connect(master) + d.connect(g); g.connect(master) return d } - const hall1 = mkDelay(2.6, 0.20) - const hall2 = mkDelay(3.9, 0.12) + const hall1 = mkHall(2.8, 0.22) + const hall2 = mkHall(4.3, 0.14) + const hall3 = mkHall(1.1, 0.28) // short pre-delay for presence - // ── note definitions (Am9 chord, mid range only) ────────────────── - // Each entry: base freq, peak amplitude, swell period (s), start offset (s) - // Two slightly-detuned voices per pitch → chorus shimmer - const voices = [ - // A3 - { freq: 220.00, peak: 0.30, dur: 14, offset: 0 }, - { freq: 220.55, peak: 0.22, dur: 16, offset: 2.5 }, - // C4 - { freq: 261.63, peak: 0.26, dur: 12, offset: 4 }, - { freq: 261.00, peak: 0.18, dur: 15, offset: 6.5 }, - // E4 - { freq: 329.63, peak: 0.28, dur: 10, offset: 1.5 }, - { freq: 330.20, peak: 0.20, dur: 13, offset: 7 }, - // G4 (7th — bittersweet tension) - { freq: 392.00, peak: 0.18, dur: 18, offset: 9 }, - { freq: 391.30, peak: 0.13, dur: 11, offset: 3 }, - // B4 (9th — openness, hope) - { freq: 493.88, peak: 0.14, dur: 15, offset: 11 }, - { freq: 494.50, peak: 0.10, dur: 17, offset: 5 }, - ] - - voices.forEach(({ freq, peak, dur, offset }) => { + // Helper: create oscillator, connect to all outputs, start it + const mkOsc = (freq: number): [OscillatorNode, GainNode] => { const osc = ctx.createOscillator() osc.type = 'sine' osc.frequency.value = freq - const g = ctx.createGain() g.gain.value = 0 - osc.connect(g) - g.connect(master) - g.connect(hall1) - g.connect(hall2) + g.connect(master); g.connect(hall1); g.connect(hall2); g.connect(hall3) osc.start() oscsRef.current.push(osc) + return [osc, g] + } - // Breathing envelope: fade in over 45 % of cycle, fade out rest - const scheduleSwell = () => { + // ── LAYER 1: Pad ──────────────────────────────────────────────────── + // Am9 chord, two detuned copies per pitch, staggered breathing envelopes + const padVoices = [ + { freq: 220.00, peak: 0.28, dur: 16, offset: 0 }, // A3 + { freq: 220.60, peak: 0.20, dur: 19, offset: 3.0 }, + { freq: 261.63, peak: 0.24, dur: 13, offset: 5.5 }, // C4 + { freq: 260.90, peak: 0.17, dur: 17, offset: 8.0 }, + { freq: 329.63, peak: 0.26, dur: 11, offset: 2.0 }, // E4 + { freq: 330.30, peak: 0.18, dur: 15, offset: 9.5 }, + { freq: 392.00, peak: 0.19, dur: 21, offset: 12 }, // G4 (7th – longing) + { freq: 391.20, peak: 0.13, dur: 12, offset: 6.5 }, + { freq: 493.88, peak: 0.12, dur: 18, offset: 15 }, // B4 (9th – openness) + { freq: 494.70, peak: 0.09, dur: 10, offset: 7.5 }, + ] + + padVoices.forEach(({ freq, peak, dur, offset }) => { + const [, g] = mkOsc(freq) + const swell = () => { if (!ctxRef.current) return - const now = ctx.currentTime - const rise = dur * 0.45 - g.gain.cancelScheduledValues(now) - g.gain.setValueAtTime(0, now) - g.gain.linearRampToValueAtTime(peak, now + rise) - g.gain.linearRampToValueAtTime(0, now + dur) - const id = setTimeout(scheduleSwell, dur * 1000) - timerRef.current.push(id) + const t = ctx.currentTime + const rise = dur * 0.38 + g.gain.cancelScheduledValues(t) + g.gain.setValueAtTime(0, t) + g.gain.linearRampToValueAtTime(peak, t + rise) + g.gain.linearRampToValueAtTime(peak * 0.72, t + dur * 0.68) // mid-valley + g.gain.linearRampToValueAtTime(0, t + dur) + timerRef.current.push(setTimeout(swell, dur * 1000)) } - - const id = setTimeout(scheduleSwell, offset * 1000) - timerRef.current.push(id) + timerRef.current.push(setTimeout(swell, offset * 1000)) }) + // ── LAYER 2: Melody ───────────────────────────────────────────────── + // A slow A-minor descending/ascending phrase – like a distant cello line. + // Uses Web Audio scheduling for sample-accurate timing. + // + // Phrase (A4 → descend to A3 → rise back, ~38 seconds total): + const phrase: { freq: number; dur: number; vol: number }[] = [ + { freq: 440.00, dur: 4.2, vol: 0.11 }, // A4 – start, open + { freq: 415.30, dur: 3.6, vol: 0.09 }, // Ab4 – slide down (chromatic tension) + { freq: 392.00, dur: 4.0, vol: 0.10 }, // G4 + { freq: 349.23, dur: 4.8, vol: 0.12 }, // F4 – linger, emotional weight + { freq: 329.63, dur: 3.8, vol: 0.10 }, // E4 + { freq: 293.66, dur: 3.5, vol: 0.08 }, // D4 + { freq: 261.63, dur: 5.5, vol: 0.13 }, // C4 – long rest, heaviest moment + { freq: 293.66, dur: 3.0, vol: 0.08 }, // D4 – start ascending + { freq: 329.63, dur: 3.5, vol: 0.10 }, // E4 + { freq: 392.00, dur: 3.8, vol: 0.09 }, // G4 – almost home + { freq: 440.00, dur: 5.8, vol: 0.12 }, // A4 – resolution, breathing out + ] // ≈ 45.5 s per cycle + + const [melOsc, melGain] = mkOsc(440) + + // Vibrato LFO on melody (4.5 Hz, ±4 Hz depth → natural voice quality) + const vibLfo = ctx.createOscillator() + vibLfo.type = 'sine' + vibLfo.frequency.value = 4.5 + const vibDepth = ctx.createGain() + vibDepth.gain.value = 4 + vibLfo.connect(vibDepth) + vibDepth.connect(melOsc.frequency) + vibLfo.start() + oscsRef.current.push(vibLfo) + + let phraseIdx = 0 + let nextNoteAt = ctx.currentTime + 5 // first note after 5 s intro + + const scheduleNote = () => { + if (!ctxRef.current) return + const { freq, dur, vol } = phrase[phraseIdx] + const t = nextNoteAt + const attack = Math.min(0.7, dur * 0.16) + const release = Math.min(1.0, dur * 0.22) + + melOsc.frequency.setValueAtTime(freq, t) + melGain.gain.cancelScheduledValues(t) + melGain.gain.setValueAtTime(0, t) + melGain.gain.linearRampToValueAtTime(vol, t + attack) + melGain.gain.setValueAtTime(vol, t + dur - release) + melGain.gain.linearRampToValueAtTime(0, t + dur) + + nextNoteAt += dur + phraseIdx = (phraseIdx + 1) % phrase.length + + // Re-schedule ~1.2 s before next note for seamless handoff + const msUntilNext = Math.max(50, (nextNoteAt - ctx.currentTime - 1.2) * 1000) + timerRef.current.push(setTimeout(scheduleNote, msUntilNext)) + } + + timerRef.current.push(setTimeout(scheduleNote, 4000)) // first trigger + + // ── LAYER 3: Dynamic master arc ───────────────────────────────────── + // 60-second cycle: soft start → emotional swell → gentle release. + // Gives a sense of "something happening" and stops it feeling static. + const dynArc = () => { + if (!ctxRef.current) return + const t = ctx.currentTime + master.gain.cancelScheduledValues(t) + master.gain.setValueAtTime(0.12, t) + master.gain.linearRampToValueAtTime(0.22, t + 18) // build up + master.gain.linearRampToValueAtTime(0.26, t + 30) // peak + master.gain.linearRampToValueAtTime(0.19, t + 45) // breathe out + master.gain.linearRampToValueAtTime(0.12, t + 60) // reset + timerRef.current.push(setTimeout(dynArc, 60000)) + } + dynArc() + setPlaying(true) }, []) @@ -140,14 +206,9 @@ function useAmbient() { timerRef.current = [] oscsRef.current.forEach(o => { try { o.stop() } catch { /**/ } }) oscsRef.current = [] - // Fade out master before closing const ctx = ctxRef.current if (ctx) { - const fade = ctx.createGain() - fade.gain.setValueAtTime(1, ctx.currentTime) - fade.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.8) - fade.connect(ctx.destination) - setTimeout(() => { ctx.close(); ctxRef.current = null }, 1900) + setTimeout(() => { ctx.close(); ctxRef.current = null }, 100) } setPlaying(false) }, [])