fix: ambient pad – emotional choir swell, no bass buzz

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 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-02-16 02:33:08 +01:00
parent 1ab5809a82
commit 74b253095e
+77 -50
View File
@@ -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 (818s 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<AudioContext | null>(null)
const stopFnsRef = useRef<(() => void)[]>([])
const ctxRef = useRef<AudioContext | null>(null)
const oscsRef = useRef<OscillatorNode[]>([])
const timerRef = useRef<ReturnType<typeof setTimeout>[]>([])
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(() => {