import { DURATION, LINES, SHOTS, soundCues, type CueKind } from './scenes' export const SR = 48000 const BPM = 100 const BEAT = 60 / BPM const BAR = BEAT * 4 type Ctx = OfflineAudioContext const midi = (m: number) => 440 * Math.pow(2, (m - 69) / 12) function noiseBuffer(ctx: Ctx, sec: number) { const b = ctx.createBuffer(1, Math.ceil(sec * ctx.sampleRate), ctx.sampleRate) const d = b.getChannelData(0) let s = 1234567 for (let i = 0; i < d.length; i++) { s = (s * 16807) % 2147483647; d[i] = (s / 2147483647) * 2 - 1 } return b } function impulse(ctx: Ctx, sec: number, decay: number) { const n = Math.ceil(sec * ctx.sampleRate) const b = ctx.createBuffer(2, n, ctx.sampleRate) let s = 98765 for (let c = 0; c < 2; c++) { const d = b.getChannelData(c) for (let i = 0; i < n; i++) { s = (s * 16807) % 2147483647; d[i] = ((s / 2147483647) * 2 - 1) * Math.pow(1 - i / n, decay) } } return b } function env(g: GainNode, t: number, a: number, peak: number, hold: number, r: number) { g.gain.setValueAtTime(0, t) g.gain.linearRampToValueAtTime(peak, t + a) g.gain.setValueAtTime(peak, t + a + hold) g.gain.exponentialRampToValueAtTime(0.0001, t + a + hold + r) } /* ---------------- music ---------------- */ // sections: [start, end, chords(midi roots+voicing), mood] interface Section { a: number; b: number; chords: number[][]; arp: boolean; kick: boolean; bright: number } function sections(): Section[] { const S = (i: number) => SHOTS[i].start const minor = [[50, 57, 60, 64, 65], [46, 53, 57, 60, 62], [43, 50, 53, 58, 62], [45, 52, 57, 59, 64]] // Dm9 Bbmaj7 Gm9 Asus const tense = [[38, 50, 53, 57], [38, 50, 52, 57]] const major = [[41, 53, 57, 60, 64], [48, 55, 60, 64, 67], [50, 57, 60, 62, 65], [46, 53, 58, 62, 65]] // Fmaj7 C Dm Bb return [ { a: 0, b: S(1), chords: tense, arp: false, kick: false, bright: 0.2 }, { a: S(1), b: S(4), chords: minor, arp: true, kick: false, bright: 0.45 }, { a: S(4), b: S(8), chords: minor, arp: true, kick: true, bright: 0.6 }, { a: S(8), b: DURATION, chords: major, arp: true, kick: true, bright: 0.85 }, ] } function music(ctx: Ctx, out: AudioNode, rev: AudioNode) { const secs = sections() const nb = noiseBuffer(ctx, 1) for (const s of secs) { const nBars = Math.ceil((s.b - s.a) / BAR) for (let k = 0; k < nBars; k++) { const t0 = s.a + k * BAR if (t0 >= s.b) break const len = Math.min(BAR, s.b - t0) + 0.6 const ch = s.chords[k % s.chords.length] // pad ch.forEach((m, vi) => { for (const det of [-7, 7]) { const o = ctx.createOscillator(); o.type = vi === 0 ? 'sine' : 'sawtooth' o.frequency.value = midi(vi === 0 ? m - 12 : m); o.detune.value = det const f = ctx.createBiquadFilter(); f.type = 'lowpass' f.frequency.setValueAtTime(500 + 900 * s.bright, t0) f.frequency.linearRampToValueAtTime(900 + 1600 * s.bright, t0 + len * 0.6) const g = ctx.createGain() env(g, t0, 0.5, vi === 0 ? 0.11 : 0.028, len - 1.1, 0.9) o.connect(f).connect(g); g.connect(out); g.connect(rev) o.start(t0); o.stop(t0 + len + 0.3) } }) // arpeggio pluck (16ths in later sections, 8ths in middle) if (s.arp) { const step = s.kick ? BEAT / 4 : BEAT / 2 const notes = ch.slice(1).concat(ch.slice(1).map(x => x + 12)) const pat = [0, 2, 1, 3, 2, 4, 3, 5] for (let i = 0; i * step < BAR && t0 + i * step < s.b; i++) { const t = t0 + i * step const m = notes[pat[i % pat.length] % notes.length] const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = midi(m + 12) const g = ctx.createGain(); env(g, t, 0.004, (i % 4 === 0 ? 0.05 : 0.032) * (0.7 + s.bright * 0.4), 0.02, step * 1.6) const p = ctx.createStereoPanner(); p.pan.value = Math.sin(i * 1.3) * 0.5 o.connect(g).connect(p); p.connect(out); p.connect(rev) o.start(t); o.stop(t + step * 2 + 0.1) } } // heartbeat in the tense intro if (!s.arp) { for (let b = 0; b < 4; b += 2) for (const off of [0, 0.22]) { const t = t0 + b * BEAT + off if (t > s.b) continue const o = ctx.createOscillator(); o.frequency.setValueAtTime(70, t); o.frequency.exponentialRampToValueAtTime(38, t + 0.18) const g = ctx.createGain(); env(g, t, 0.005, off ? 0.22 : 0.32, 0.02, 0.25) o.connect(g).connect(out); o.start(t); o.stop(t + 0.35) } } // soft kick + hat if (s.kick) { for (let b = 0; b < 4; b++) { const t = t0 + b * BEAT if (t > s.b) continue const o = ctx.createOscillator(); o.frequency.setValueAtTime(120, t); o.frequency.exponentialRampToValueAtTime(42, t + 0.12) const g = ctx.createGain(); env(g, t, 0.003, 0.26, 0.01, 0.22) o.connect(g).connect(out); o.start(t); o.stop(t + 0.3) const h = ctx.createBufferSource(); h.buffer = nb const hf = ctx.createBiquadFilter(); hf.type = 'highpass'; hf.frequency.value = 7000 const hg = ctx.createGain(); env(hg, t + BEAT / 2, 0.002, 0.035, 0, 0.05) h.connect(hf).connect(hg).connect(out); h.start(t + BEAT / 2, ((k * 4 + b) % 5) * 0.1, 0.1) } } } } // final resolve chord swell const tf = SHOTS[SHOTS.length - 1].start + 0.4 ;[41, 53, 60, 65, 69, 72].forEach(m => { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(m) const g = ctx.createGain(); env(g, tf, 0.8, 0.05, 2, Math.max(0.5, DURATION - tf - 2.8)) o.connect(g); g.connect(out); g.connect(rev); o.start(tf); o.stop(DURATION) }) } /* ---------------- sfx ---------------- */ function sfx(ctx: Ctx, out: AudioNode, rev: AudioNode) { const nb = noiseBuffer(ctx, 2) const noise = (t: number, dur: number) => { const s = ctx.createBufferSource(); s.buffer = nb; s.start(t, 0, dur); return s } const tone = (t: number, type: OscillatorType, f0: number, f1: number, dur: number) => { const o = ctx.createOscillator(); o.type = type o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur) o.start(t); o.stop(t + dur + 0.05); return o } const make: Record void> = { whoosh: (t, v) => { const s = noise(t, 0.6), f = ctx.createBiquadFilter(); f.type = 'bandpass'; f.Q.value = 2 f.frequency.setValueAtTime(300, t); f.frequency.exponentialRampToValueAtTime(3500, t + 0.45) const g = ctx.createGain(); env(g, t, 0.25, 0.22 * v, 0, 0.3) s.connect(f).connect(g); g.connect(out); g.connect(rev) }, pain: (t, v) => { const o = tone(t, 'sawtooth', 110, 55, 0.5), f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 600 const g = ctx.createGain(); env(g, t, 0.01, 0.2 * v, 0.05, 0.45) o.connect(f).connect(g).connect(out) const o2 = tone(t, 'sine', 80, 40, 0.4), g2 = ctx.createGain(); env(g2, t, 0.005, 0.4 * v, 0.02, 0.35) o2.connect(g2).connect(out) const o3 = tone(t + 0.02, 'sine', 1900, 1500, 0.35), g3 = ctx.createGain(); env(g3, t + 0.02, 0.01, 0.03 * v, 0, 0.3) o3.connect(g3); g3.connect(out); g3.connect(rev) }, tick: (t, v) => { const o = tone(t, 'sine', 2400, 2300, 0.04), g = ctx.createGain(); env(g, t, 0.002, 0.08 * v, 0, 0.04); o.connect(g).connect(out) }, scan: (t, v) => { const o = tone(t, 'sine', 500, 1800, 0.8), g = ctx.createGain(); env(g, t, 0.05, 0.06 * v, 0.4, 0.35) const lfo = ctx.createOscillator(); lfo.frequency.value = 18; const lg = ctx.createGain(); lg.gain.value = 0.03 * v lfo.connect(lg).connect(g.gain); lfo.start(t); lfo.stop(t + 0.9) o.connect(g); g.connect(out); g.connect(rev) }, pop: (t, v) => { const o = tone(t, 'sine', 520, 980, 0.08), g = ctx.createGain(); env(g, t, 0.003, 0.12 * v, 0, 0.12); o.connect(g); g.connect(out); g.connect(rev) }, release: (t, v) => { const s = noise(t, 1.2), f = ctx.createBiquadFilter(); f.type = 'lowpass' f.frequency.setValueAtTime(2500, t); f.frequency.exponentialRampToValueAtTime(200, t + 1.1) const g = ctx.createGain(); env(g, t, 0.02, 0.12 * v, 0.05, 1.0) s.connect(f).connect(g); g.connect(out); g.connect(rev) ;[72, 76, 79, 84].forEach((m, i) => { const o = tone(t + i * 0.07, 'sine', midi(m), midi(m) * 0.998, 1.4), gg = ctx.createGain() env(gg, t + i * 0.07, 0.01, 0.05 * v, 0, 1.3); o.connect(gg); gg.connect(out); gg.connect(rev) }) }, chime: (t, v) => { for (const [r, a] of [[1, 1], [2.76, 0.5], [5.4, 0.25]]) { const o = tone(t, 'sine', 880 * r, 880 * r, 1.6), g = ctx.createGain(); env(g, t, 0.003, 0.06 * v * a, 0, 1.5) o.connect(g); g.connect(out); g.connect(rev) } }, thud: (t, v) => { const o = tone(t, 'sine', 90, 38, 0.3), g = ctx.createGain(); env(g, t, 0.003, 0.45 * v, 0.02, 0.3); o.connect(g).connect(out) }, click: (t, v) => { const o = tone(t, 'square', 1600, 1200, 0.02), g = ctx.createGain(); env(g, t, 0.001, 0.04 * v, 0, 0.03); o.connect(g).connect(out) const s = noise(t, 0.04), f = ctx.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000 const g2 = ctx.createGain(); env(g2, t, 0.001, 0.08 * v, 0, 0.03); s.connect(f).connect(g2).connect(out) }, swipe: (t, v) => { const s = noise(t, 0.3), f = ctx.createBiquadFilter(); f.type = 'highpass' f.frequency.setValueAtTime(1200, t); f.frequency.linearRampToValueAtTime(4000, t + 0.25) const g = ctx.createGain(); env(g, t, 0.06, 0.1 * v, 0, 0.2); s.connect(f).connect(g).connect(out) }, } for (const c of soundCues()) if (c.t >= 0 && c.t < DURATION) make[c.k](c.t, c.v ?? 1) } /* ---------------- narration ---------------- */ async function loadVoices(base: string, onProgress?: (p: number) => void) { const tmp = new OfflineAudioContext(1, 1, SR) let done = 0 return Promise.all(LINES.map(async l => { const r = await fetch(base + 'audio/' + l.file) if (!r.ok) throw new Error('voice ' + l.file + ' ' + r.status) const buf = await tmp.decodeAudioData(await r.arrayBuffer()) onProgress?.(++done / LINES.length) return { l, buf } })) } let cached: Promise | null = null /** Full mixed soundtrack (narration + music + sfx), cached */ export function getSoundtrack(onProgress?: (p: number, label: string) => void, synth = false) { if (!cached) cached = (synth ? build(onProgress) : baked(onProgress).catch(() => build(onProgress))).catch(e => { cached = null; throw e }) return cached } /** Pre-baked master (same mix, rendered once) — fast path */ async function baked(onProgress?: (p: number, label: string) => void) { const base = (import.meta.env.BASE_URL || './').replace(/\/?$/, '/') onProgress?.(0.1, '加载配乐') const r = await fetch(base + 'audio/soundtrack.mp3') if (!r.ok) throw new Error('soundtrack ' + r.status) const ab = await r.arrayBuffer() onProgress?.(0.6, '解码音频') const ctx = new OfflineAudioContext(2, 1, SR) const buf = await ctx.decodeAudioData(ab) onProgress?.(1, '完成') return buf } async function build(onProgress?: (p: number, label: string) => void) { const base = (import.meta.env.BASE_URL || './').replace(/\/?$/, '/') const voices = await loadVoices(base, p => onProgress?.(p * 0.6, '加载旁白')) onProgress?.(0.65, '合成配乐') const ctx = new OfflineAudioContext(2, Math.ceil(DURATION * SR), SR) const master = ctx.createGain(); master.gain.value = 0.9 const comp = ctx.createDynamicsCompressor() comp.threshold.value = -10; comp.knee.value = 6; comp.ratio.value = 8; comp.attack.value = 0.003; comp.release.value = 0.15 master.connect(comp).connect(ctx.destination) master.gain.setValueAtTime(0.9, DURATION - 1.2); master.gain.linearRampToValueAtTime(0, DURATION) const rev = ctx.createConvolver(); rev.buffer = impulse(ctx, 2.6, 3) const revG = ctx.createGain(); revG.gain.value = 0.35 rev.connect(revG).connect(master) // music bus with narration ducking const mus = ctx.createGain(); mus.connect(master) const musRev = ctx.createGain(); musRev.connect(rev) const base0 = 0.85, duck = 0.38 for (const g of [mus.gain, musRev.gain]) { g.setValueAtTime(0, 0); g.linearRampToValueAtTime(base0, 1.2) const iv: [number, number][] = [] for (const { l } of voices) { const a = l.start, b = l.start + l.dur, last = iv[iv.length - 1] if (last && a - last[1] < 0.5) last[1] = Math.max(last[1], b); else iv.push([a, b]) } for (const [a, b] of iv) { g.setTargetAtTime(duck, Math.max(0, a - 0.12), 0.06) g.setTargetAtTime(base0, b + 0.05, 0.25) } } music(ctx, mus, musRev) const fx = ctx.createGain(); fx.gain.value = 0.9; fx.connect(master) sfx(ctx, fx, rev) // narration with light EQ presence const vo = ctx.createGain(); vo.gain.value = 1.15 const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 90 const pres = ctx.createBiquadFilter(); pres.type = 'peaking'; pres.frequency.value = 3000; pres.gain.value = 2.5; pres.Q.value = 0.8 vo.connect(hp).connect(pres).connect(master) const voRev = ctx.createGain(); voRev.gain.value = 0.06; vo.connect(voRev).connect(rev) for (const { l, buf } of voices) { const s = ctx.createBufferSource(); s.buffer = buf s.connect(vo) s.start(l.start, l.off, Math.min(l.dur, buf.duration - l.off)) } const out = await ctx.startRendering() onProgress?.(1, '完成') return out }