From 74b253095e30eb5edd847213ff8e57b001083adf Mon Sep 17 00:00:00 2001 From: denshooter Date: Mon, 16 Feb 2026 02:33:08 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20ambient=20pad=20=E2=80=93=20emotional=20?= =?UTF-8?q?choir=20swell,=20no=20bass=20buzz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace static LFO drone with proper breathing envelope pad: - Remove low bass (55/110 Hz) that caused "surren" (buzzing) - Am9 chord in mid range: A3·C4·E4·G4·B4 (220–494 Hz) - 10 voices total (2 detuned copies per note) → natural chorus shimmer - Each note breathes independently: 10–18s swell cycle, staggered offsets - Envelope: gradual rise (45% of cycle) + smooth fade → sounds like choir - Two long hall-reverb delay lines (2.6s / 3.9s, no feedback) for depth - Clean fade-out on stop Co-Authored-By: Claude Sonnet 4.5 --- src/components/MusicPlayer.tsx | 127 ++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 50 deletions(-) diff --git a/src/components/MusicPlayer.tsx b/src/components/MusicPlayer.tsx index 6faf784..8796df7 100644 --- a/src/components/MusicPlayer.tsx +++ b/src/components/MusicPlayer.tsx @@ -43,11 +43,17 @@ function WaveformBars({ playing }: { playing: boolean }) { } // ─── ambient audio via Web Audio API ──────────────────────────────────────── -// A-minor chord: A1 55Hz · A2 110Hz · C3 130.81Hz · E3 164.81Hz · A3 220Hz +// 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). function useAmbient() { - const ctxRef = useRef(null) - const stopFnsRef = useRef<(() => void)[]>([]) + const ctxRef = useRef(null) + const oscsRef = useRef([]) + const timerRef = useRef[]>([]) const [playing, setPlaying] = useState(false) const start = useCallback(() => { @@ -55,74 +61,95 @@ function useAmbient() { const ctx = new AudioContext() ctxRef.current = ctx + // ── output chain ────────────────────────────────────────────────── const master = ctx.createGain() - master.gain.setValueAtTime(0, ctx.currentTime) - master.gain.linearRampToValueAtTime(0.11, ctx.currentTime + 6) + master.gain.value = 0.18 master.connect(ctx.destination) - // Feedback delay for spaciousness - const delay = ctx.createDelay(2.0) - delay.delayTime.value = 1.4 - const fbGain = ctx.createGain() - fbGain.gain.value = 0.22 - delay.connect(fbGain) - fbGain.connect(delay) - fbGain.connect(master) + // 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 + const g = ctx.createGain() + g.gain.value = wet + d.connect(g) + g.connect(master) + return d + } + const hall1 = mkDelay(2.6, 0.20) + const hall2 = mkDelay(3.9, 0.12) - const notes = [ - { freq: 55, gain: 0.55 }, - { freq: 110, gain: 0.45 }, - { freq: 130.81, gain: 0.38 }, - { freq: 164.81, gain: 0.48 }, - { freq: 220, gain: 0.30 }, + // ── 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 }, ] - notes.forEach(({ freq, gain }, i) => { + voices.forEach(({ freq, peak, dur, offset }) => { const osc = ctx.createOscillator() osc.type = 'sine' - osc.frequency.value = freq + (i % 2 === 0 ? 0.15 : -0.15) // micro-detune - const g = ctx.createGain() - g.gain.value = gain + osc.frequency.value = freq - // Slow per-note swell LFO - const lfo = ctx.createOscillator() - lfo.type = 'sine' - lfo.frequency.value = 0.06 + i * 0.018 - const lfoG = ctx.createGain() - lfoG.gain.value = gain * 0.38 - lfo.connect(lfoG) - lfoG.connect(g.gain) - lfo.start() + const g = ctx.createGain() + g.gain.value = 0 osc.connect(g) g.connect(master) - g.connect(delay) + g.connect(hall1) + g.connect(hall2) osc.start() + oscsRef.current.push(osc) - stopFnsRef.current.push(() => { - try { osc.stop(); lfo.stop() } catch { /* already stopped */ } - }) + // Breathing envelope: fade in over 45 % of cycle, fade out rest + const scheduleSwell = () => { + 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 id = setTimeout(scheduleSwell, offset * 1000) + timerRef.current.push(id) }) setPlaying(true) }, []) const stop = useCallback(() => { + timerRef.current.forEach(clearTimeout) + timerRef.current = [] + oscsRef.current.forEach(o => { try { o.stop() } catch { /**/ } }) + oscsRef.current = [] + // Fade out master before closing const ctx = ctxRef.current - if (!ctx) return - const master = ctx.destination - // Fade out gracefully - const g = ctx.createGain() - g.gain.setValueAtTime(1, ctx.currentTime) - g.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.5) - g.connect(master) - setTimeout(() => { - stopFnsRef.current.forEach((fn) => fn()) - stopFnsRef.current = [] - ctx.close() - ctxRef.current = null - setPlaying(false) - }, 1600) + 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) + } + setPlaying(false) }, []) const toggle = useCallback(() => {