feat: emotional ambient – melody + dynamic arc + vibrato
Three layers: 1. PAD: Am9 breathing pad (unchanged structure, more varied mid-valley shape) 2. MELODY: slow A-minor cello phrase (A4→Ab4→G4→F4→E4→D4→C4→D4→E4→G4→A4) - ~45s per cycle, soft attack/release per note - Vibrato LFO (4.5 Hz, ±4 Hz) for natural voice quality - Scheduled via AudioContext timing for sample-accurate handoffs 3. DYNAMIC ARC: 60s master gain cycle (0.12 → 0.26 → 0.12) for emotional peaks - Builds over 18s, peaks at 30s, exhales to 60s - Repeats → each minute has a distinct emotional shape Result: sounds like a distant cello line over a soft choir pad, with emotional crescendos every minute. Not just breathing – actual movement. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+123
-62
@@ -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<AudioContext | null>(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)
|
||||
|
||||
// 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)
|
||||
return [osc, g]
|
||||
}
|
||||
|
||||
const id = setTimeout(scheduleSwell, offset * 1000)
|
||||
timerRef.current.push(id)
|
||||
// ── 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 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))
|
||||
}
|
||||
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)
|
||||
}, [])
|
||||
|
||||
Reference in New Issue
Block a user