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.
242 lines
15 KiB
JavaScript
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 };
|
|
}
|