mx / src /video /audio.ts
Ethscriptions's picture
Step 5: 配乐音效与混音
68fdb58 verified
Raw History Blame Contribute Delete
13.6 kB
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<CueKind, (t: number, v: number) => 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<AudioBuffer> | 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
}