Portfolio (every app, both stores, rollout %, crash sparklines), the Growth and Health charts with a real hover sweep (one captured frame per hovered day, the pointer follows the recorded positions), the Prices matrix (both stores per country, gaps marked), then v5's release timeline and 0 blockers. All frames are real fixture-mode captures (desktop.ts). Tests: scenes 4-5 s, 50-60 s total, captions >= 3 s, every desktop frame captured by desktop.ts and present.
172 lines
11 KiB
JavaScript
172 lines
11 KiB
JavaScript
// 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', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', desktop: 'A', submit: 'D' };
|
|
const ARP = [0, 1, 2, 3, 2, 1, 2, 3];
|
|
const STEP = 0.5; // one soft note every half second
|
|
|
|
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 };
|
|
}
|