storebound/media/teaser/music.js
CRT b142cf6d21 Teaser video source: scripted terminal timeline, procedural music, render script (media/teaser)
HTML/CSS/JS timeline rendered frame by frame in headless Chrome, original music synthesized with OfflineAudioContext, ffmpeg encode + loudnorm. Outputs go to media/teaser/out (gitignored); poster.jpg is the end card.
2026-10-11 01:02:17 +08:00

242 lines
15 KiB
JavaScript

// Original teaser music, synthesized from nothing with the Web Audio API (OfflineAudioContext): no samples, no
// downloaded audio. 128 BPM, F minor, tight 16th arpeggio, kick / hats / clap, offbeat bass, a pad, impacts on every
// scene cut (script.js CUTS), key clicks on every typed character, a riser into the end card. Deterministic (seeded).
// renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg loudnorm.
import { BAR, BEAT, CUTS, DURATION, keyTimes, scene } from './script.js';
const SR = 48000;
const midi = m => 440 * Math.pow(2, (m - 69) / 12);
function rng(seed) { // mulberry32
let a = seed >>> 0;
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
// chords per bar (root midi in octave 3, intervals)
const CH = { Fm: [53, [0, 3, 7]], Db: [49, [0, 4, 7]], Ab: [56, [0, 4, 7]], Eb: [51, [0, 4, 7]] };
const BARS = ['Ab', 'Eb', 'Fm', 'Db', 'Ab', 'Eb', 'Fm', 'Db', 'Ab', 'Eb', 'Fm', 'Db', 'Ab', 'Eb', 'Eb', 'Db', 'Eb', 'Fm', 'Eb', 'Fm'];
const ARP = [0, 2, 4, 3, 5, 4, 2, 3, 0, 2, 4, 6, 5, 4, 3, 2];
export async function renderMusic() {
const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(DURATION * SR) + SR, sampleRate: SR });
const R = rng(20261010);
// shared noise
const noise = ctx.createBuffer(2, SR * 3, SR);
for (let c = 0; c < 2; c++) { const d = noise.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = R() * 2 - 1; }
// reverb impulse
const ir = ctx.createBuffer(2, Math.floor(SR * 2.6), SR);
for (let c = 0; c < 2; c++) { const d = ir.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = (R() * 2 - 1) * Math.pow(1 - i / d.length, 3.2); }
// buses
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -14; comp.knee.value = 6; comp.ratio.value = 3.5; comp.attack.value = 0.004; comp.release.value = 0.16;
const master = ctx.createGain(); master.gain.value = 0.7;
master.connect(comp); comp.connect(ctx.destination);
const verb = ctx.createConvolver(); verb.buffer = ir;
const verbIn = ctx.createGain(); verbIn.gain.value = 0.5; verbIn.connect(verb); verb.connect(master);
const duck = ctx.createGain(); duck.connect(master); // sidechained group: pad, bass, arp
const drums = ctx.createGain(); drums.gain.value = 0.9; drums.connect(master);
const arpBus = ctx.createGain(); arpBus.gain.value = 0.55;
const arpLP = ctx.createBiquadFilter(); arpLP.type = 'lowpass'; arpLP.Q.value = 6;
arpBus.connect(arpLP); arpLP.connect(duck);
// stereo echo on the arp
for (const [time, pan] of [[BEAT * 0.75, -0.6], [BEAT * 1.5, 0.6]]) {
const dl = ctx.createDelay(2); dl.delayTime.value = time;
const fb = ctx.createGain(); fb.gain.value = 0.28;
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600;
const p = ctx.createStereoPanner(); p.pan.value = pan;
const wet = ctx.createGain(); wet.gain.value = 0.32;
arpLP.connect(dl); dl.connect(lp); lp.connect(fb); fb.connect(dl); lp.connect(wet); wet.connect(p); p.connect(duck);
}
const arpVerb = ctx.createGain(); arpVerb.gain.value = 0.18; arpLP.connect(arpVerb); arpVerb.connect(verbIn);
// arp filter: closed intro, open drop, wide on the riser, dark under the Submit card, bright on the brand
const f = arpLP.frequency;
const at = s => scene(s).start;
f.setValueAtTime(420, 0);
f.exponentialRampToValueAtTime(2400, at('config') - 0.02);
f.setValueAtTime(3000, at('config'));
f.linearRampToValueAtTime(3400, at('submit') - BAR);
f.exponentialRampToValueAtTime(7000, at('submit') - 0.01);
f.setValueAtTime(900, at('submit'));
f.exponentialRampToValueAtTime(1800, at('brand') - 0.01);
f.setValueAtTime(4200, at('brand'));
f.setValueAtTime(4200, at('brand') + BAR * 2);
f.exponentialRampToValueAtTime(800, DURATION);
const env = (g, t, a, peak, d, end = 0.0005) => {
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + a); g.gain.exponentialRampToValueAtTime(end, t + a + d);
};
const noiseSrc = (t, dur, off = R() * 1.5) => { const s = ctx.createBufferSource(); s.buffer = noise; s.start(t, off, dur); return s; };
// ---- voices ----
function kick(t, g = 1) {
const o = ctx.createOscillator(); o.type = 'sine';
o.frequency.setValueAtTime(155, t); o.frequency.exponentialRampToValueAtTime(44, t + 0.11);
const v = ctx.createGain(); env(v, t, 0.002, 1.0 * g, 0.42);
o.connect(v); v.connect(drums); o.start(t); o.stop(t + 0.5);
const n = noiseSrc(t, 0.01); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 3500;
const nv = ctx.createGain(); env(nv, t, 0.001, 0.22 * g, 0.012); n.connect(hp); hp.connect(nv); nv.connect(drums);
duck.gain.setValueAtTime(0.28, t); duck.gain.linearRampToValueAtTime(1, t + 0.22);
}
function hat(t, g, pan) {
const n = noiseSrc(t, 0.06); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 7500;
const p = ctx.createStereoPanner(); p.pan.value = pan;
const v = ctx.createGain(); env(v, t, 0.001, g, 0.04); n.connect(hp); hp.connect(v); v.connect(p); p.connect(drums);
}
function clap(t, g = 0.4) {
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1300; bp.Q.value = 0.9;
const v = ctx.createGain(); v.gain.setValueAtTime(0, t);
for (let i = 0; i < 3; i++) { v.gain.setValueAtTime(g, t + i * 0.011); v.gain.exponentialRampToValueAtTime(g * 0.2, t + i * 0.011 + 0.009); }
v.gain.setValueAtTime(g * 0.8, t + 0.034); v.gain.exponentialRampToValueAtTime(0.0005, t + 0.2);
const n = noiseSrc(t, 0.25); n.connect(bp); bp.connect(v); v.connect(drums);
const s = ctx.createGain(); s.gain.value = 0.5; v.connect(s); s.connect(verbIn);
}
function snare(t, g) {
const n = noiseSrc(t, 0.12); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1900; bp.Q.value = 0.7;
const v = ctx.createGain(); env(v, t, 0.001, g, 0.09); n.connect(bp); bp.connect(v); v.connect(drums);
const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.setValueAtTime(240, t); o.frequency.exponentialRampToValueAtTime(160, t + 0.05);
const ov = ctx.createGain(); env(ov, t, 0.001, g * 0.5, 0.06); o.connect(ov); ov.connect(drums); o.start(t); o.stop(t + 0.12);
}
function arpNote(t, m, g = 0.12, len = 0.17) {
const v = ctx.createGain(); env(v, t, 0.003, g, len); v.connect(arpBus);
for (const [type, det] of [['sawtooth', -7], ['square', 7]]) {
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m); o.detune.value = det;
const og = ctx.createGain(); og.gain.value = type === 'square' ? 0.5 : 0.8;
o.connect(og); og.connect(v); o.start(t); o.stop(t + len + 0.05);
}
}
function bass(t, m, g = 0.32, len = BEAT * 0.45) {
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(900, t); lp.frequency.exponentialRampToValueAtTime(220, t + len); lp.Q.value = 4;
const v = ctx.createGain(); v.gain.setValueAtTime(0, t); v.gain.linearRampToValueAtTime(g, t + 0.004); v.gain.setValueAtTime(g, t + len * 0.7); v.gain.linearRampToValueAtTime(0, t + len);
lp.connect(v); v.connect(duck);
const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = midi(m); o.connect(lp); o.start(t); o.stop(t + len + 0.02);
const sub = ctx.createOscillator(); sub.type = 'sine'; sub.frequency.value = midi(m - 12);
const sg = ctx.createGain(); sg.gain.value = 0.9; sub.connect(sg); sg.connect(v); sub.start(t); sub.stop(t + len + 0.02);
}
function pad(t, dur, notes, g = 0.05, cutoff = 1500) {
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cutoff; lp.Q.value = 0.7;
const v = ctx.createGain(); v.gain.setValueAtTime(0, t); v.gain.linearRampToValueAtTime(g, t + 0.25); v.gain.setValueAtTime(g, t + dur - 0.1); v.gain.linearRampToValueAtTime(0, t + dur + 0.25);
lp.connect(v); v.connect(duck);
const s = ctx.createGain(); s.gain.value = 0.6; v.connect(s); s.connect(verbIn);
for (const m of notes) for (const det of [-9, 9]) {
const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = midi(m); o.detune.value = det;
const p = ctx.createStereoPanner(); p.pan.value = det < 0 ? -0.4 : 0.4;
o.connect(p); p.connect(lp); o.start(t); o.stop(t + dur + 0.3);
}
}
function impact(t, big = false) {
const o = ctx.createOscillator(); o.type = 'sine';
o.frequency.setValueAtTime(big ? 120 : 100, t); o.frequency.exponentialRampToValueAtTime(32, t + (big ? 0.9 : 0.5));
const v = ctx.createGain(); env(v, t, 0.003, big ? 0.95 : 0.6, big ? 1.3 : 0.6); o.connect(v); v.connect(master); o.start(t); o.stop(t + 1.6);
const n = noiseSrc(t, 2.0); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = big ? 1800 : 3200;
const nv = ctx.createGain(); env(nv, t, 0.002, big ? 0.32 : 0.16, big ? 1.8 : 0.9); n.connect(hp); hp.connect(nv); nv.connect(master);
const s = ctx.createGain(); s.gain.value = big ? 0.7 : 0.4; nv.connect(s); s.connect(verbIn);
}
function stab(t, name, g = 0.09) {
const [root, iv] = CH[name];
const notes = [root - 12, ...iv.map(i => root + i), ...iv.map(i => root + 12 + i)];
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(5000, t); lp.frequency.exponentialRampToValueAtTime(600, t + 1.4);
const v = ctx.createGain(); env(v, t, 0.004, g, 1.6); lp.connect(v); v.connect(master);
const s = ctx.createGain(); s.gain.value = 0.8; v.connect(s); s.connect(verbIn);
for (const m of notes) for (const det of [-11, 0, 11]) {
const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = midi(m); o.detune.value = det;
o.connect(lp); o.start(t); o.stop(t + 1.8);
}
}
function whoosh(t, dur, g = 0.22, from = 500, to = 7000) {
const n = noiseSrc(t, dur + 0.05); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 2.2;
bp.frequency.setValueAtTime(from, t); bp.frequency.exponentialRampToValueAtTime(to, t + dur);
const v = ctx.createGain(); v.gain.setValueAtTime(0.0001, t); v.gain.exponentialRampToValueAtTime(g, t + dur * 0.95); v.gain.linearRampToValueAtTime(0, t + dur);
n.connect(bp); bp.connect(v); v.connect(master);
}
function riser(t, dur, g = 0.07) {
whoosh(t, dur, 0.3, 300, 9000);
const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.setValueAtTime(midi(53), t); o.frequency.exponentialRampToValueAtTime(midi(77), t + dur);
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(800, t); lp.frequency.exponentialRampToValueAtTime(6000, t + dur);
const v = ctx.createGain(); v.gain.setValueAtTime(0, t); v.gain.linearRampToValueAtTime(g, t + dur); v.gain.linearRampToValueAtTime(0, t + dur + 0.02);
o.connect(lp); lp.connect(v); v.connect(master); o.start(t); o.stop(t + dur + 0.05);
}
function key(t, g) {
const n = noiseSrc(t, 0.02); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2600 + R() * 2600; bp.Q.value = 3;
const p = ctx.createStereoPanner(); p.pan.value = R() * 0.5 - 0.25;
const v = ctx.createGain(); env(v, t, 0.0008, g * (0.7 + R() * 0.6), 0.012); n.connect(bp); bp.connect(v); v.connect(p); p.connect(master);
}
// ---- arrangement ----
const sub = scene('submit').start, brand = scene('brand').start, drop = scene('config').start;
const rollBar = scene('validate').end - BAR; // last bar before the Submit card
duck.gain.setValueAtTime(1, 0);
for (let b = 0; b < 20; b++) {
const t0 = b * BAR, name = BARS[b], [root, iv] = CH[name];
const tones = [0, iv[1], iv[2], 12, 12 + iv[1], 12 + iv[2], 24].map(x => root + 12 + x);
const main = t0 >= drop && t0 < sub, end = b === 19, calm = t0 >= sub && t0 < brand;
const groove = main || (t0 >= brand && !end);
// pad
if (!end) pad(t0, BAR, [root, root + iv[1], root + iv[2], root + 12], calm ? 0.06 : t0 < drop ? 0.045 : 0.035, calm ? 1100 : 1500);
for (let s = 0; s < 16; s++) {
const t = t0 + s * BEAT / 4;
const beat = s % 4 === 0, off = s % 4 === 2;
const cutDrums = t0 === rollBar && s >= 8; // drums drop out for the last half bar of the riser
// arp
if (!end) {
if (calm) { if (s % 2 === 0) arpNote(t, tones[ARP[s]] ?? root, 0.09, 0.3); }
else arpNote(t, tones[ARP[s]] ?? root, t0 < drop ? 0.08 : 0.11, 0.16);
}
if (groove && !cutDrums) {
if (beat) kick(t);
if (off) { hat(t, 0.14, 0.2); bass(t, root - 12); }
else if (!beat) hat(t, 0.045, -0.2);
if (t0 >= drop + BAR * 2 && (s === 4 || s === 12)) clap(t);
}
if (calm && off) hat(t, 0.05, 0.15);
if (b === 1 && s >= 8 && s % 2 === 0) hat(t, 0.02 + 0.06 * (s - 8) / 8, 0); // intro tick-in
}
}
// snare roll + riser into the Submit card
for (let i = 0; i < 8; i++) snare(rollBar + i * BEAT / 4, 0.04 + 0.1 * i / 7);
for (let i = 0; i < 16; i++) snare(rollBar + BAR / 2 + i * BEAT / 8, 0.14 + 0.18 * i / 15);
riser(rollBar, BAR);
riser(brand - BAR / 2, BAR / 2, 0.05);
whoosh(drop - BAR / 2, BAR / 2, 0.25);
// hits on the cuts
for (const c of CUTS) {
const big = c === sub || c === brand;
if (!big && c !== drop) whoosh(c - 0.32, 0.32, 0.16, 800, 6000);
impact(c, big || c === drop);
}
stab(drop, 'Fm', 0.06);
stab(sub, 'Db', 0.1);
stab(brand, 'Fm', 0.1);
stab(19 * BAR, 'Fm', 0.11);
impact(19 * BAR, true);
kick(19 * BAR, 1);
// key clicks
for (const t of keyTimes()) key(t, t < drop ? 0.16 : 0.07);
const buf = await ctx.startRendering();
const n = Math.round(DURATION * SR);
return { sampleRate: SR, left: buf.getChannelData(0).slice(0, n), right: buf.getChannelData(1).slice(0, n) };
}
/** 32-bit float stereo WAV, base64. */
export async function renderWav() {
const { sampleRate, left, right } = await renderMusic();
const n = left.length, bytes = 44 + n * 8;
const ab = new ArrayBuffer(bytes), dv = new DataView(ab);
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, 'RIFF'); dv.setUint32(4, bytes - 8, true); str(8, 'WAVE'); str(12, 'fmt ');
dv.setUint32(16, 16, true); dv.setUint16(20, 3, true); dv.setUint16(22, 2, true); dv.setUint32(24, sampleRate, true);
dv.setUint32(28, sampleRate * 8, true); dv.setUint16(32, 8, true); dv.setUint16(34, 32, true); str(36, 'data'); dv.setUint32(40, n * 8, true);
let peak = 0;
for (let i = 0; i < n; i++) {
dv.setFloat32(44 + i * 8, left[i], true); dv.setFloat32(48 + i * 8, right[i], true);
peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i]));
}
const u8 = new Uint8Array(ab);
let bin = '';
for (let i = 0; i < u8.length; i += 0x8000) bin += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000));
return { base64: btoa(bin), peak, seconds: n / sampleRate };
}