Spaces:
Running
Running
Download src/video/audio.ts from Ethscriptions/mx: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/audio.ts
- Command line
-
hf download hf://spaces/Ethscriptions/mx/src/video/audio.ts
-
curl -L -o audio.ts https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/audio.ts
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 | |
| } | |