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:
@@ -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<AudioContext | null>(null)
|
||||
const stopFnsRef = useRef<(() => void)[]>([])
|
||||
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
|
||||
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)
|
||||
}, 1600)
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user