312 lines
19 KiB
JavaScript
312 lines
19 KiB
JavaScript
// Original teaser music and sound design (v8, a story arc), synthesized from nothing with the Web Audio API
|
|
// (OfflineAudioContext): no samples, no downloaded audio. 120 BPM (a beat every half second; every cut lands on one).
|
|
// calm game a warm D major pad, a slow soft pluck
|
|
// tension pile B minor, a low pulse in eighths that grows, a ticking clock, a riser; a thud per card
|
|
// dark reject a low boom, a dark drone that sinks to almost nothing
|
|
// silence ask room tone, the key clicks of the request, one soft note when it is sent; a swell into the drop
|
|
// drive montage+scan kick, bass, arpeggio, hats, claps: one layer more per montage beat (I-vi-IV-V, a chord per bar);
|
|
// a chime per fix, rising
|
|
// stop button the music stops on the cut; only a quiet low pulse, like holding your breath
|
|
// resolve waiting+end the click, then a wide D major add9 swell, bells, a slow arpeggio under the end card
|
|
// Deterministic. renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg.
|
|
import { CLICK_T, CUTS, DURATION, keyTimes, MONTAGE, scene, ticks } from './script.js';
|
|
|
|
const SR = 48000;
|
|
/** One beat (s): 120 BPM. Every scene starts on a whole number of beats (tests/teaser.test.ts). */
|
|
export const STEP = 0.5;
|
|
const BAR = STEP * 4;
|
|
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; };
|
|
}
|
|
|
|
/** The arc: section boundaries in seconds, from the scenes. */
|
|
export function sections() {
|
|
const s = id => scene(id).start, e = id => scene(id).end;
|
|
return { calm: [0, s('pile')], tension: [s('pile'), s('reject')], dark: [s('reject'), s('ask')], silence: [s('ask'), s('builds')],
|
|
drive: [s('builds'), s('button')], stop: [s('button'), CLICK_T], resolve: [CLICK_T, e('end')] };
|
|
}
|
|
// chords (root midi, intervals)
|
|
const CH = { D: [50, [0, 4, 7, 14]], Bm: [47, [0, 3, 7, 14]], G: [43, [0, 4, 7, 14]], A: [45, [0, 4, 7, 14]], Dmaj9: [50, [0, 4, 7, 11, 14]] };
|
|
const PROG = ['D', 'Bm', 'G', 'A'];
|
|
|
|
export async function renderMusic() {
|
|
const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(DURATION * SR) + SR * 2, sampleRate: SR });
|
|
const R = rng(20261013);
|
|
const S = sections();
|
|
|
|
const noise = ctx.createBuffer(2, SR * 6, 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.4), 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.4); }
|
|
|
|
// buses: a gentle compressor, a long room
|
|
const comp = ctx.createDynamicsCompressor();
|
|
comp.threshold.value = -16; comp.knee.value = 8; comp.ratio.value = 3; comp.attack.value = 0.008; comp.release.value = 0.25;
|
|
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 sfx = ctx.createGain(); sfx.gain.value = 0.75; sfx.connect(master);
|
|
const sfxVerb = ctx.createGain(); sfxVerb.gain.value = 0.3; sfxVerb.connect(verbIn);
|
|
// the drums and bass: their own bus, so the stop can cut them all at once
|
|
const drive = ctx.createGain(); drive.connect(master);
|
|
drive.gain.setValueAtTime(1, 0); drive.gain.setValueAtTime(1, S.stop[0] - 0.01); drive.gain.linearRampToValueAtTime(0, S.stop[0] + 0.03);
|
|
|
|
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. */
|
|
function pad(t0, t1, notes, g = 0.035, cutoff = 1100, attack = 1.2, release = 1.0, to = master, cutoffTo = null) {
|
|
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cutoff; lp.Q.value = 0.5;
|
|
if (cutoffTo) { lp.frequency.setValueAtTime(cutoff, t0); lp.frequency.exponentialRampToValueAtTime(cutoffTo, t1); }
|
|
const v = ctx.createGain();
|
|
v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + attack); v.gain.setValueAtTime(g, Math.max(t0 + attack, t1 - 0.05)); v.gain.linearRampToValueAtTime(0, t1 + release);
|
|
lp.connect(v); v.connect(to);
|
|
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 + release + 0.1);
|
|
}
|
|
return v;
|
|
}
|
|
/** A soft pluck (sine + a little triangle). */
|
|
function pluck(t, m, g = 0.05, pan = 0, len = 1.1, to = master) {
|
|
const v = ctx.createGain(); env(v, t, 0.006, g, len);
|
|
const p = ctx.createStereoPanner(); p.pan.value = pan;
|
|
v.connect(p); p.connect(to);
|
|
const s = ctx.createGain(); s.gain.value = 0.5; 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 + len + 0.2);
|
|
}
|
|
}
|
|
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.4], [2.01, 0.25, 1.3], [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);
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
/** Filtered noise: a swell / riser into time t (dur long), band-pass sweeping up. */
|
|
function riser(t, dur, g = 0.04, f0 = 400, f1 = 5000, to = sfx) {
|
|
const n = noiseSrc(t - dur, Math.min(dur + 0.1, 4.4)); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 0.9;
|
|
bp.frequency.setValueAtTime(f0, t - dur); bp.frequency.exponentialRampToValueAtTime(f1, t);
|
|
const v = ctx.createGain(); v.gain.setValueAtTime(0.0001, t - dur); v.gain.exponentialRampToValueAtTime(g, t - 0.03); v.gain.linearRampToValueAtTime(0, t + 0.08);
|
|
n.connect(bp); bp.connect(v); v.connect(to); v.connect(sfxVerb);
|
|
}
|
|
function tick(t, g = 0.03, f0 = 3000, to = sfx) {
|
|
const n = noiseSrc(t, 0.02); 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.016); n.connect(bp); bp.connect(v); v.connect(to);
|
|
}
|
|
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.06 * (0.7 + R() * 0.6), 0.018); n.connect(bp); bp.connect(v); v.connect(p); p.connect(sfx);
|
|
}
|
|
/** A soft synth kick: a sine with a fast pitch drop. */
|
|
function kick(t, g = 0.5, to = drive) {
|
|
const o = ctx.createOscillator(); o.type = 'sine';
|
|
o.frequency.setValueAtTime(130, t); o.frequency.exponentialRampToValueAtTime(44, t + 0.12);
|
|
const v = ctx.createGain(); env(v, t, 0.002, g, 0.32); o.connect(v); v.connect(to); o.start(t); o.stop(t + 0.4);
|
|
tick(t, g * 0.06, 1800, to);
|
|
}
|
|
/** A clap / snare: noise through a band-pass, a short tail. */
|
|
function clap(t, g = 0.12) {
|
|
const n = noiseSrc(t, 0.25); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1500; bp.Q.value = 0.8;
|
|
const v = ctx.createGain(); env(v, t, 0.002, g, 0.16); n.connect(bp); bp.connect(v); v.connect(drive);
|
|
const s = ctx.createGain(); s.gain.value = 0.4; v.connect(s); s.connect(verbIn);
|
|
}
|
|
function hat(t, g = 0.03, open = false) {
|
|
const n = noiseSrc(t, open ? 0.2 : 0.05); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 7500;
|
|
const p = ctx.createStereoPanner(); p.pan.value = 0.2;
|
|
const v = ctx.createGain(); env(v, t, 0.001, g, open ? 0.14 : 0.035); n.connect(hp); hp.connect(v); v.connect(p); p.connect(drive);
|
|
}
|
|
/** The bass: a filtered saw + sine, one note. */
|
|
function bass(t, m, len, g = 0.11, cutoff = 520) {
|
|
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(cutoff * 2.2, t); lp.frequency.exponentialRampToValueAtTime(cutoff, t + len * 0.8); lp.Q.value = 2;
|
|
const v = ctx.createGain(); v.gain.setValueAtTime(0, t); v.gain.linearRampToValueAtTime(g, t + 0.008); v.gain.setValueAtTime(g * 0.8, t + len * 0.7); v.gain.linearRampToValueAtTime(0, t + len);
|
|
lp.connect(v); v.connect(drive);
|
|
for (const [type, mul] of [['sawtooth', 0.6], ['sine', 1]]) {
|
|
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m);
|
|
const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(lp); o.start(t); o.stop(t + len + 0.05);
|
|
}
|
|
}
|
|
/** A deep, slow boom (the rejection): a sine falling from 70 Hz plus low noise, a long room. */
|
|
function boom(t, g = 0.5) {
|
|
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(72, t); o.frequency.exponentialRampToValueAtTime(30, t + 1.6);
|
|
const v = ctx.createGain(); env(v, t, 0.005, g, 2.2); o.connect(v); v.connect(master);
|
|
const n = noiseSrc(t, 1.2); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 240;
|
|
const nv = ctx.createGain(); env(nv, t, 0.004, g * 0.5, 0.9); n.connect(lp); lp.connect(nv); nv.connect(master); nv.connect(verbIn);
|
|
}
|
|
function thud(t, g = 0.18) {
|
|
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(110, t); o.frequency.exponentialRampToValueAtTime(48, t + 0.15);
|
|
const v = ctx.createGain(); env(v, t, 0.002, g, 0.28); o.connect(v); v.connect(sfx);
|
|
const n = noiseSrc(t, 0.12); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900;
|
|
const nv = ctx.createGain(); env(nv, t, 0.002, g * 0.35, 0.09); n.connect(lp); lp.connect(nv); nv.connect(sfx); nv.connect(sfxVerb);
|
|
}
|
|
/** The mouse click on Submit. */
|
|
function click(t) {
|
|
tick(t, 0.09, 4200); tick(t + 0.055, 0.05, 2800);
|
|
}
|
|
|
|
// ---------- calm: the game is done ----------
|
|
{
|
|
const [r, iv] = CH.Dmaj9;
|
|
pad(0, S.calm[1] + 0.2, [r, r + iv[1], r + iv[2], r + iv[3]], 0.032, 900, 1.6, 1.2);
|
|
pad(0, S.calm[1] + 0.2, [r - 12], 0.02, 300, 1.6, 1.0);
|
|
const tones = [62, 66, 69, 73, 74, 73, 69, 66];
|
|
for (let t = 0.5, k = 0; t < S.calm[1] - 0.1; t += 2 * STEP, k++) pluck(t, tones[k % tones.length], 0.036, k % 2 ? 0.25 : -0.25, 1.6);
|
|
}
|
|
|
|
// ---------- tension: the stores pile up ----------
|
|
{
|
|
const [a, b] = S.tension;
|
|
const [r, iv] = CH.Bm;
|
|
pad(a - 0.3, b, [r, r + iv[1], r + iv[2], r + 12], 0.03, 700, 1.0, 0.4, master, 2600);
|
|
// a b9 creeps in over the last two seconds
|
|
pad(b - 2.2, b, [r + 13], 0.016, 1200, 1.8, 0.3);
|
|
// the low pulse: eighths on B, louder and brighter towards the rejection
|
|
for (let t = a, k = 0; t < b - 0.01; t += STEP / 2, k++) {
|
|
const p = (t - a) / (b - a);
|
|
bass(t, k % 8 === 7 ? 42 : 35, STEP / 2 * 0.9, 0.05 + 0.07 * p, 260 + 900 * p);
|
|
}
|
|
// a clock ticking in sixteenths over the second half
|
|
for (let t = a + 3, k = 0; t < b - 0.01; t += STEP / 4, k++) tick(t, (k % 4 === 0 ? 0.03 : 0.016) * (0.6 + (t - a) / (b - a) * 0.6), k % 4 === 0 ? 5200 : 6800);
|
|
riser(b, 3, 0.07, 300, 6000);
|
|
}
|
|
|
|
// ---------- dark: one missing line ----------
|
|
{
|
|
const [a, b] = S.dark;
|
|
boom(a, 0.55);
|
|
const v = pad(a, b - 1.2, [35, 38, 42, 47], 0.04, 420, 0.1, 1.6, master, 260);
|
|
void v;
|
|
pad(a, b - 1.4, [59, 60], 0.008, 1800, 1.0, 1.4); // a faint, uneasy cluster above
|
|
}
|
|
|
|
// ---------- near silence: the request ----------
|
|
{
|
|
const [a, b] = S.silence;
|
|
// room tone: very quiet, dark noise
|
|
const n = noiseSrc(a, b - a); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
|
|
const v = ctx.createGain(); v.gain.setValueAtTime(0, a); v.gain.linearRampToValueAtTime(0.012, a + 0.8); v.gain.setValueAtTime(0.012, b - 0.6); v.gain.linearRampToValueAtTime(0, b);
|
|
n.connect(lp); lp.connect(v); v.connect(master);
|
|
pluck(a + 0.5, 66, 0.022, 0, 2.5);
|
|
// the swell into the drop
|
|
riser(b, 1.5, 0.08, 250, 7000);
|
|
pad(b - 1.5, b, [62, 69, 74], 0.02, 600, 1.4, 0.05, master, 3000);
|
|
}
|
|
|
|
// ---------- drive: the montage and the scan ----------
|
|
{
|
|
const [a, b] = S.drive;
|
|
const layer = id => scene(id).start;
|
|
const L = { hats: layer('shots'), claps: layer('products'), sixteenths: layer('forms'), lead: layer('consoles') };
|
|
for (let t = a, k = 0; t < b - 0.01; t += STEP, k++) {
|
|
const bar = Math.floor((t - a) / BAR), beat = k % 4;
|
|
const [root, iv] = CH[PROG[bar % 4]];
|
|
kick(t, beat === 0 ? 0.55 : 0.46);
|
|
if (t >= L.claps && (beat === 1 || beat === 3)) clap(t, 0.11);
|
|
if (t >= L.hats) hat(t + STEP / 2, 0.028, beat === 3);
|
|
if (t >= L.sixteenths) { hat(t + STEP / 4, 0.014); hat(t + STEP * 3 / 4, 0.014); }
|
|
// the bass: root eighths, the fifth on the last one of the bar
|
|
const bm = root - 12 + (root < 46 ? 12 : 0);
|
|
bass(t, bm, STEP / 2 * 0.85, 0.1);
|
|
bass(t + STEP / 2, beat === 3 ? bm + 7 : bm, STEP / 2 * 0.85, 0.085);
|
|
// the arpeggio: sixteenths over the chord, brighter layer by layer
|
|
const tones = iv.slice(0, 4).map(x => root + 24 + x);
|
|
for (let j = 0; j < 4; j++) {
|
|
const m = tones[(k * 4 + j) % tones.length];
|
|
pluck(t + j * STEP / 4, m, (t >= L.lead ? 0.034 : 0.026) * (j === 0 ? 1.2 : 1), (j % 2 ? 0.3 : -0.3), 0.35, drive);
|
|
}
|
|
if (beat === 0) pad(t, t + BAR - 0.05, [root + 12, root + 12 + iv[1], root + 12 + iv[2]], t >= L.lead ? 0.022 : 0.016, t >= L.lead ? 2600 : 1600, 0.05, 0.12, drive);
|
|
if (beat === 0 && t >= L.lead) pluck(t, root + 36 + iv[2], 0.03, 0, 1.4, drive);
|
|
}
|
|
// a cymbal-like swell into each montage cut, a big one into the drop and into the stop
|
|
for (const id of MONTAGE.slice(1).concat(['scan'])) riser(scene(id).start, 0.5, 0.03, 2000, 9000, drive);
|
|
riser(b, 2, 0.06, 500, 8000, drive);
|
|
}
|
|
|
|
// ---------- stop: one button is yours ----------
|
|
{
|
|
const [a, b] = S.stop;
|
|
// the last hit rings out into the room, then only a quiet low pulse, slower than the beat
|
|
bell(a, 74 + 12, 0.035);
|
|
for (let t = a + 1.0; t < b - 0.3; t += 1.0) {
|
|
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(38);
|
|
const v = ctx.createGain(); env(v, t, 0.02, 0.025 * Math.min(1, (t - a) / 3), 0.5); o.connect(v); v.connect(master); o.start(t); o.stop(t + 0.6);
|
|
}
|
|
}
|
|
|
|
// ---------- resolve: the click, waiting for review, the end card ----------
|
|
{
|
|
const [a, b] = S.resolve;
|
|
click(a);
|
|
const [r, iv] = CH.Dmaj9;
|
|
pad(a + 0.08, b + 0.6, [r - 12, r, r + iv[1], r + iv[2], r + iv[3], r + iv[4]], 0.048, 1600, 0.9, 1.0);
|
|
pad(a + 0.08, b + 0.6, [r - 24], 0.03, 200, 1.2, 1.0);
|
|
bell(a + 0.3, r + 24, 0.06);
|
|
bell(a + 0.65, r + 24 + 7, 0.045, 0.2);
|
|
bell(a + 1.0, r + 24 + 14, 0.035, -0.2);
|
|
// a slow arpeggio returns (the calm of the opening, now open)
|
|
const tones = [74, 78, 81, 85, 86, 85, 81, 78];
|
|
for (let t = a + 1.5, k = 0; t < b - 1.2; t += STEP, k++) pluck(t, tones[k % tones.length], 0.03, k % 2 ? 0.25 : -0.25, 1.4);
|
|
// the end card: one more bell as the logo lands
|
|
const e = scene('end').start;
|
|
bell(e + 0.3, r + 24, 0.05);
|
|
bell(e + 0.55, r + 31, 0.035, 0.2);
|
|
}
|
|
|
|
// ---------- soft accents on the story cuts ----------
|
|
for (const c of CUTS) {
|
|
if (MONTAGE.includes(c.id) || c.id === 'scan') continue;
|
|
if (c.id === 'reject' || c.id === 'builds') continue; // the boom and the drop are the accents
|
|
bell(c.t + 0.01, c.id === 'pile' ? 71 : 74, 0.03, 0.1);
|
|
}
|
|
|
|
// ---------- UI sounds ----------
|
|
for (const x of ticks()) {
|
|
if (x.kind === 'tiny') tick(x.t, 0.02, 2800);
|
|
else if (x.kind === 'thud') thud(x.t, 0.12 + x.i * 0.015);
|
|
else if (x.kind === 'low') tone(x.t, 38, 0.05, 0.9, 'triangle');
|
|
else if (x.kind === 'send') { tone(x.t, 81, 0.03, 0.3); tone(x.t + 0.1, 86, 0.025, 0.6); }
|
|
else if (x.kind === 'good') { const m = [74, 76, 78, 81, 83][x.step] ?? 86; tone(x.t, m + 12, 0.045, 0.5, 'sine', -0.3 + 0.15 * x.step); tone(x.t, m + 24, 0.012, 0.3); }
|
|
else if (x.kind === 'clean') { tone(x.t, 86, 0.04, 0.8); tone(x.t + 0.08, 90, 0.03, 0.9); tone(x.t + 0.16, 93, 0.025, 1.0); }
|
|
}
|
|
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 };
|
|
}
|