// Original score; no recordings or melodies copied from the reference film. // node scripts/render-audio.mjs [path/to/ffmpeg.exe] import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; const rate = 48000, bpm = 72, beat = 60 / bpm, duration = 64 * beat; const length = Math.round(rate * duration), tau = Math.PI * 2; const folder = "public/audio"; fs.mkdirSync(folder, { recursive: true }); fs.mkdirSync(".tools/audio-render", { recursive: true }); const hz = (midi) => 440 * 2 ** ((midi - 69) / 12); let seed = 93271; const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296) * 2 - 1; const stems = Object.fromEntries( ["atmosphere", "motif", "pulse"].map((k) => [ k, [new Float32Array(length), new Float32Array(length)], ]), ); function add(stem, start, seconds, pan, synth) { const out = stems[stem], offset = Math.round(start * rate); const l = Math.cos(((pan + 1) * Math.PI) / 4), r = Math.sin(((pan + 1) * Math.PI) / 4); for (let i = 0; i < Math.round(seconds * rate); i++) { const sample = synth(i / rate, i / (seconds * rate)); const index = (((offset + i) % length) + length) % length; out[0][index] += sample * l; out[1][index] += sample * r; } } // Dmaj9 / Bm11 / Gmaj9 / Asus2, followed by a quieter answer. const chords = [ [50, 57, 61, 64, 69], [47, 54, 57, 62, 64], [43, 54, 57, 62, 66], [45, 52, 57, 59, 64], [50, 57, 61, 64, 66], [47, 54, 57, 61, 64], [43, 50, 57, 59, 66], [45, 52, 57, 62, 64], ]; for (let bar = 0; bar < 8; bar++) { chords[bar].forEach((note, voice) => { const f = hz(note), seconds = beat * 10; add( "atmosphere", bar * 8 * beat - beat, seconds, (voice - 2) * 0.28, (t, p) => { const env = Math.sin(Math.PI * p) ** 2; const drift = 0.003 * Math.sin(tau * 0.17 * t + voice); return ( 0.035 * env * (Math.sin(tau * f * t + drift) + 0.22 * Math.sin(tau * f * 2 * t) + 0.12 * Math.sin(tau * f * 1.0007 * t)) ); }, ); }); // Quiet sub pulse, like equipment breathing, only twice per two-bar phrase. for (const b of [0, 4.5]) add( "pulse", (bar * 8 + b) * beat, 1.5, 0, (t, p) => 0.12 * (1 - Math.exp(-t * 20)) * Math.exp(-t * 4) * (1 - p) * Math.sin(tau * hz(chords[bar][0] - 12) * t), ); for (const b of [1.5, 3, 5.5, 7]) add( "pulse", (bar * 8 + b) * beat, 0.085, b < 4 ? -0.3 : 0.3, (t, p) => 0.018 * Math.sin(Math.PI * p) * Math.exp(-t * 55) * (random() * 0.35 + Math.sin(tau * 1240 * t) * 0.65), ); } // Spacious, hand-written question/answer phrases, with deliberate rests. const phrases = [ [ [0, 74], [2.5, 76], [5, 69], ], [ [1, 73], [4, 69], ], [ [0, 71], [3, 74], [6, 78], ], [ [2, 76], [5.5, 71], ], [ [0, 78], [3, 76], [6, 73], ], [ [1, 74], [4.5, 69], ], [ [0, 71], [2.5, 69], [6, 66], ], [ [1, 69], [4, 76], ], ]; phrases.forEach((phrase, bar) => phrase.forEach(([b, note], i) => { const f = hz(note), velocity = 0.12 * (i === 0 ? 1 : 0.78); for (let echo = 0; echo < 4; echo++) { add( "motif", (bar * 8 + b) * beat + echo * beat * 0.75, 4.8, (i % 2 ? 0.22 : -0.22) * (echo % 2 ? -1 : 1), (t, p) => { const attack = 1 - Math.exp(-t * 110), end = Math.min(1, (1 - p) * 12); const body = Math.sin( tau * f * t + 0.65 * Math.exp(-t * 7) * Math.sin(tau * f * 2 * t), ); return ( velocity * 0.27 ** echo * attack * end * Math.exp(-t * 1.2) * (body + 0.24 * Math.exp(-t * 2) * Math.sin(tau * f * 3 * t) + 0.035 * Math.exp(-t * 8) * random()) ); }, ); } }), ); // Circular, decorrelated early reflections keep the loop continuous. for (const [name, channels] of Object.entries(stems)) { const original = channels.map((c) => c.slice()); for (const [seconds, gain] of [ [0.071, 0.17], [0.113, 0.13], [0.193, 0.09], [0.307, 0.065], [0.487, 0.04], ]) { const delay = Math.round(seconds * rate); for (let c = 0; c < 2; c++) for (let i = 0; i < length; i++) channels[c][(i + delay) % length] += original[1 - c][i] * gain; } if (name === "atmosphere") for (let c = 0; c < 2; c++) { let last = channels[c][length - 1]; for (let i = 0; i < length; i++) { last += 0.16 * (channels[c][i] - last); channels[c][i] = last; } } } function wav(channels, file) { const data = Buffer.alloc(44 + length * 4); data.write("RIFF"); data.writeUInt32LE(data.length - 8, 4); data.write("WAVEfmt ", 8); data.writeUInt32LE(16, 16); data.writeUInt16LE(1, 20); data.writeUInt16LE(2, 22); data.writeUInt32LE(rate, 24); data.writeUInt32LE(rate * 4, 28); data.writeUInt16LE(4, 32); data.writeUInt16LE(16, 34); data.write("data", 36); data.writeUInt32LE(length * 4, 40); for (let i = 0; i < length; i++) for (let c = 0; c < 2; c++) data.writeInt16LE( Math.round(Math.max(-1, Math.min(1, channels[c][i])) * 32767), 44 + i * 4 + c * 2, ); fs.writeFileSync(file, data); } const mix = [new Float32Array(length), new Float32Array(length)]; const metrics = {}; for (const [name, channels] of Object.entries(stems)) { for (const channel of channels) for (let i = 0; i < length; i++) channel[i] *= 2.2; let peak = 0, square = 0; for (let c = 0; c < 2; c++) for (let i = 0; i < length; i++) { const x = channels[c][i]; peak = Math.max(peak, Math.abs(x)); square += x * x; mix[c][i] += x; } metrics[name] = { peakDb: 20 * Math.log10(peak), rmsDb: 10 * Math.log10(square / (length * 2)), seamDelta: Math.max(...channels.map((c) => Math.abs(c[0] - c[length - 1]))), }; const file = path.join(".tools/audio-render", name + ".wav"); wav(channels, file); if (process.argv[2]) execFileSync(process.argv[2], [ "-y", "-v", "error", "-i", file, "-c:a", "libvorbis", "-q:a", "5", path.join(folder, name + ".ogg"), ]); } wav(mix, ".tools/audio-render/observatory.wav"); if (process.argv[2]) execFileSync(process.argv[2], [ "-y", "-v", "error", "-i", ".tools/audio-render/observatory.wav", "-af", "afade=t=in:d=1,afade=t=out:st=50.833333:d=2.5", "-c:a", "libmp3lame", "-b:a", "192k", path.join(folder, "observatory-preview.mp3"), ]); fs.writeFileSync( path.join(folder, "score.json"), JSON.stringify( { title: "Observatory / 观测室", bpm, duration, rate, seed: 93271, metrics, }, null, 2, ) + "\n", ); console.log(JSON.stringify({ duration, metrics }, null, 2));