// Original teaser music and sound design (v4, calm), synthesized from nothing with the Web Audio API // (OfflineAudioContext): no samples, no downloaded audio. D major, one chord per scene: warm pads that cross-fade on the // scene changes, a slow, soft plucked arpeggio, a quiet sub, and only soft accents on the scene changes (a bell and a // short airy swell into each one). On top, quiet UI sounds from script.js: key clicks for typed text, ticks for lines // and rows, a low tone per finding, rising chimes for the fixes, a warm swell under the end card. Deterministic. // renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg loudnorm. import { CUTS, DURATION, keyTimes, SCENES, scene, SUBMIT, ticks } 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 (root midi in octave 3, intervals): one per scene, in scene order const CH = { Bm: [47, [0, 3, 7, 14]], G: [43, [0, 4, 7, 14]], D: [50, [0, 4, 7, 11]], A: [45, [0, 4, 7, 14]], Em: [52, [0, 3, 7, 10]], Dadd9: [50, [0, 4, 7, 14]], }; const SCENE_CHORD = { hook: 'Bm', init: 'G', plan: 'D', store: 'A', products: 'Bm', forms: 'G', consoles: 'A', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', resubmit: 'G', submit: 'D' }; const ARP = [0, 1, 2, 3, 2, 1, 2, 3]; /** One soft note every half second: every scene is a whole number of half seconds long (tests/teaser.test.ts), so each * cut lands on a note of the arpeggio. */ export const STEP = 0.5; export async function renderMusic() { const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(DURATION * SR) + SR * 2, sampleRate: SR }); const R = rng(20261012); 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; } const ir = ctx.createBuffer(2, Math.floor(SR * 3.2), 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.6); } // buses: a gentle compressor, a long room const comp = ctx.createDynamicsCompressor(); comp.threshold.value = -18; comp.knee.value = 10; comp.ratio.value = 2.5; comp.attack.value = 0.02; comp.release.value = 0.4; 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.55; verbIn.connect(verb); verb.connect(master); const sfx = ctx.createGain(); sfx.gain.value = 0.7; sfx.connect(master); const sfxVerb = ctx.createGain(); sfxVerb.gain.value = 0.3; sfxVerb.connect(verbIn); const env = (g, t, a, peak, d, end = 0.0003) => { 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; }; /** A warm pad: detuned saws through a soft low-pass, slow attack and release (cross-fades with the next one). */ function pad(t0, t1, notes, g = 0.035, cutoff = 1100) { const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cutoff; lp.Q.value = 0.5; const v = ctx.createGain(); v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + 1.2); v.gain.setValueAtTime(g, t1 - 0.2); v.gain.linearRampToValueAtTime(0, t1 + 1.0); lp.connect(v); v.connect(master); const s = ctx.createGain(); s.gain.value = 0.7; v.connect(s); s.connect(verbIn); for (const m of notes) for (const det of [-7, 7]) { 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.35 : 0.35; o.connect(p); p.connect(lp); o.start(t0); o.stop(t1 + 1.1); } } /** A soft pluck (sine + a little triangle), the arpeggio voice. */ function pluck(t, m, g = 0.05, pan = 0) { const v = ctx.createGain(); env(v, t, 0.006, g, 1.1); const p = ctx.createStereoPanner(); p.pan.value = pan; v.connect(p); p.connect(master); const s = ctx.createGain(); s.gain.value = 0.6; p.connect(s); s.connect(verbIn); for (const [type, k, mul] of [['sine', 1, 1], ['triangle', 2, 0.18]]) { const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m) * k; const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(v); o.start(t); o.stop(t + 1.3); } } /** A quiet sub under each scene. */ function sub(t0, t1, m, g = 0.07) { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(m - 12); const v = ctx.createGain(); v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + 0.8); v.gain.setValueAtTime(g, t1 - 0.3); v.gain.linearRampToValueAtTime(0, t1 + 0.4); o.connect(v); v.connect(master); o.start(t0); o.stop(t1 + 0.5); } /** The soft accent on a scene change: a bell an octave or two up, with a long tail. */ function bell(t, m, g = 0.05, pan = 0) { const p = ctx.createStereoPanner(); p.pan.value = pan; p.connect(sfx); p.connect(sfxVerb); for (const [k, mul, d] of [[1, 1, 2.2], [2.01, 0.25, 1.2], [3.0, 0.08, 0.7]]) { const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(m) * k; const v = ctx.createGain(); env(v, t, 0.004, g * mul, d); o.connect(v); v.connect(p); o.start(t); o.stop(t + d + 0.1); } } /** A short airy swell into a scene change (very quiet). */ function swell(t, dur = 0.9, g = 0.03) { const n = noiseSrc(t - dur, dur + 0.1); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 0.8; bp.frequency.setValueAtTime(600, t - dur); bp.frequency.exponentialRampToValueAtTime(3200, t); const v = ctx.createGain(); v.gain.setValueAtTime(0.0001, t - dur); v.gain.exponentialRampToValueAtTime(g, t - 0.05); v.gain.linearRampToValueAtTime(0, t + 0.1); n.connect(bp); bp.connect(v); v.connect(sfx); v.connect(sfxVerb); } function tone(t, m, g, len = 0.25, type = 'sine', pan = 0) { const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m); const p = ctx.createStereoPanner(); p.pan.value = pan; const v = ctx.createGain(); env(v, t, 0.004, g, len); o.connect(v); v.connect(p); p.connect(sfx); p.connect(sfxVerb); o.start(t); o.stop(t + len + 0.05); } function tick(t, g = 0.03, f0 = 3000) { const n = noiseSrc(t, 0.015); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = f0; bp.Q.value = 3; const v = ctx.createGain(); env(v, t, 0.0006, g, 0.014); n.connect(bp); bp.connect(v); v.connect(sfx); } function key(t) { const n = noiseSrc(t, 0.02); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2400 + R() * 1800; bp.Q.value = 2.5; const p = ctx.createStereoPanner(); p.pan.value = R() * 0.4 - 0.2; const v = ctx.createGain(); env(v, t, 0.0008, 0.05 * (0.7 + R() * 0.6), 0.016); n.connect(bp); bp.connect(v); v.connect(p); p.connect(sfx); } // ---- the bed: one chord per scene ---- SCENES.forEach((s, i) => { const [root, iv] = CH[SCENE_CHORD[s.id]]; const last = i === SCENES.length - 1; const t0 = Math.max(0, s.start - 0.4), t1 = last ? DURATION + 0.6 : s.end; const notes = [root, root + iv[1], root + iv[2], root + 12]; pad(t0, last ? scene('submit').start + SUBMIT.logoAt : t1, notes, s.id === 'hook' ? 0.03 : 0.034, s.id === 'hook' ? 800 : 1100); sub(t0, t1, root, s.id === 'hook' ? 0.05 : 0.065); // the arpeggio: quiet under the hook, then a steady, soft pulse const tones = iv.map(x => root + 12 + x).concat(iv.map(x => root + 24 + x)); for (let t = Math.max(0.5, s.start), k = 0; t < (last ? s.start + SUBMIT.logoAt : s.end) - 0.05; t += STEP, k++) { const m = tones[ARP[k % ARP.length] + (k % 16 >= 8 ? 1 : 0)] ?? tones[0]; pluck(t, m, s.id === 'hook' ? 0.028 : 0.04, (k % 2 ? 0.25 : -0.25)); } }); // the end card: a warm, open chord swell and one bell const end = scene('submit').start + SUBMIT.logoAt; const [er, eiv] = CH.Dadd9; pad(end - 0.3, DURATION + 0.6, [er - 12, er, er + eiv[1], er + eiv[2], er + eiv[3]], 0.045, 1500); bell(end + 0.2, er + 24, 0.06); bell(end + 0.55, er + 24 + 7, 0.04, 0.2); // ---- soft accents on the scene changes ---- for (const c of CUTS) { const [root, iv] = CH[SCENE_CHORD[c.id]]; swell(c.t, 0.9, 0.025); bell(c.t + 0.02, root + 24 + iv[2], 0.045, (c.t * 7) % 2 - 1 > 0 ? 0.15 : -0.15); } // ---- UI sounds ---- for (const x of ticks()) { if (x.kind === 'tiny') tick(x.t, 0.022, 2800); else if (x.kind === 'soft') { tick(x.t, 0.03, 2400); tone(x.t, 86, 0.012, 0.18); } else if (x.kind === 'mail') { tone(x.t, 81, 0.04, 0.2); tone(x.t + 0.09, 88, 0.035, 0.4); } else if (x.kind === 'low') { tone(x.t, 50, 0.07, 0.5, 'triangle'); tone(x.t, 57, 0.02, 0.3); } else if (x.kind === 'ask') { tone(x.t, 83, 0.035, 0.3); tone(x.t + 0.14, 81, 0.03, 0.45); } else if (x.kind === 'good') { const m = [74, 78, 81][x.step] ?? 81; tone(x.t, m, 0.04, 0.6, 'sine', -0.2 + 0.2 * x.step); tone(x.t, m + 12, 0.012, 0.4); } } for (const t of keyTimes()) key(t); 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 }; }