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:
denshooter
2026-02-16 02:37:09 +01:00
parent 74b253095e
commit 00abbfda51
+123 -62
View File
@@ -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 (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).
// 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)
}, [])