Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top (on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and `catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning green, the desktop release timeline and Store readiness "No blockers", Submit and the end card. Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths, caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB. Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
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', 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 };
|
|
}
|