nordic-skating / files / tools / sound-check.mjs
sound-check.mjs
9ff4c3fc 2026-10-09 Pavel Safronov | 1 | /** |
| 2 | * Render twelve seconds of the synthesized ice sound without playing it, and draw its | |
| 3 | * spectrogram, so that the sound can be looked at beside the film's and a change to it | |
| 4 | * can be seen as well as heard. | |
| 5 | * | |
| 6 | * npm run sound-check writes docs/sound/synth.png and docs/sound/synth.wav | |
| 7 | * | |
| 8 | * The skater strides as in the app, on ice 4.1 cm thick as in the film for six seconds, | |
| 9 | * and then onto ice thinning to 2.8 cm, so the note should hold near 740 Hz and then | |
| 10 | * climb past 1 kHz. Three cracks, 150, 500 and 60 m away. Needs ffmpeg. | |
| 11 | */ | |
| 12 | import { spawnSync } from 'node:child_process' | |
| 13 | import { mkdirSync, writeFileSync } from 'node:fs' | |
| 14 | import { chromium } from 'playwright' | |
| 15 | import { createServer } from 'vite' | |
| 16 | ||
| 17 | const OUT = 'docs/sound' | |
| 18 | const SECONDS = 12 | |
| 19 | const RATE = 44100 | |
| 20 | ||
| 21 | mkdirSync(OUT, { recursive: true }) | |
| 22 | // The dev server, because it serves the source modules one by one and the page can then | |
| 23 | // import the synthesizer by its path. | |
| 24 | const server = await createServer({ server: { port: 4318, strictPort: true }, logLevel: 'error' }) | |
| 25 | await server.listen() | |
| 26 | const browser = await chromium.launch() | |
| 27 | let samples | |
| 28 | try { | |
| 29 | const page = await browser.newPage() | |
| 30 | await page.goto(server.resolvedUrls.local[0]) | |
| 31 | samples = await page.evaluate(async ({ SECONDS, RATE }) => { | |
| 32 | const { IceSound } = await import('/src/audio/iceSound.ts') | |
| 33 | const { mulberry32 } = await import('/src/world/lake.ts') | |
| 34 | const { strideSeconds } = await import('/src/skater.ts') | |
| 35 | const ctx = new OfflineAudioContext(2, SECONDS * RATE, RATE) | |
| 36 | const random = mulberry32(1) | |
| 37 | const ice = new IceSound(ctx, random) | |
| 38 | const thickness = (t) => (t < 6 ? 0.041 : 0.041 - (0.013 * (t - 6)) / 6) | |
| 39 | const strikes = [] | |
| 40 | for (let t = 0.2, n = 0; t < SECONDS; t += strideSeconds(n++)) { | |
| 41 | strikes.push(t) | |
| 42 | ice.strike(thickness(t), t, 0.75 + 0.5 * random()) | |
| 43 | } | |
| 44 | for (let t = 0; t < SECONDS; t += 0.02) { | |
| 45 | const since = t - (strikes.findLast((s) => s <= t) ?? -Infinity) | |
| 46 | ice.glide(thickness(t), Math.exp(-since / 0.5), t) | |
| 47 | } | |
| 48 | ice.crack(thickness(2.5), 150, 2.5, -0.5) | |
| 49 | ice.crack(thickness(4.6), 500, 4.6, 0.6) | |
| 50 | ice.crack(thickness(9.3), 60, 9.3, 0) | |
| 51 | const rendered = await ctx.startRendering() | |
| 52 | const left = rendered.getChannelData(0) | |
| 53 | const right = rendered.getChannelData(1) | |
| 54 | return Array.from(left, (v, i) => [v, right[i]]).flat() | |
| 55 | }, { SECONDS, RATE }) | |
| 56 | } finally { | |
| 57 | await browser.close() | |
| 58 | await server.close() | |
| 59 | } | |
| 60 | ||
| 61 | let peak = 0 | |
| 62 | let power = 0 | |
| 63 | for (const v of samples) { | |
| 64 | peak = Math.max(peak, Math.abs(v)) | |
| 65 | power += v * v | |
| 66 | } | |
| 67 | const db = (x) => (20 * Math.log10(x)).toFixed(1) | |
| 68 | console.log(` peak ${db(peak)} dBFS, average ${db(Math.sqrt(power / samples.length))} dBFS`) | |
| 69 | ||
| 70 | const data = Buffer.alloc(samples.length * 2) | |
| 71 | samples.forEach((v, i) => data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v)) * 32767), i * 2)) | |
| 72 | const header = Buffer.alloc(44) | |
| 73 | header.write('RIFF', 0) | |
| 74 | header.writeUInt32LE(36 + data.length, 4) | |
| 75 | header.write('WAVEfmt ', 8) | |
| 76 | header.writeUInt32LE(16, 16) | |
| 77 | header.writeUInt16LE(1, 20) | |
| 78 | header.writeUInt16LE(2, 22) | |
| 79 | header.writeUInt32LE(RATE, 24) | |
| 80 | header.writeUInt32LE(RATE * 4, 28) | |
| 81 | header.writeUInt16LE(4, 32) | |
| 82 | header.writeUInt16LE(16, 34) | |
| 83 | header.write('data', 36) | |
| 84 | header.writeUInt32LE(data.length, 40) | |
| 85 | writeFileSync(`${OUT}/synth.wav`, Buffer.concat([header, data])) | |
| 86 | ||
| 87 | const drawn = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', `${OUT}/synth.wav`, '-lavfi', | |
| 88 | 'aformat=channel_layouts=mono,showspectrumpic=s=1600x600:legend=1:scale=log:fscale=lin:stop=8000', | |
| 89 | `${OUT}/synth.png`]) | |
| 90 | if (drawn.status !== 0) throw new Error(`ffmpeg: ${drawn.stderr}`) | |
| 91 | console.log(` ${OUT}/synth.wav\n ${OUT}/synth.png`) |