fuzzyworld

nordic-skating / local-littlejs / files / tools / sound-check.mjs

Branch local-littlejs · default branch · all branches

sound-check.mjs

3.6 KB · history · blame

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 */
12import { spawnSync } from 'node:child_process'
13import { mkdirSync, writeFileSync } from 'node:fs'
14import { chromium } from 'playwright'
15import { createServer } from 'vite'
16
17const OUT = 'docs/sound'
18const SECONDS = 12
19const RATE = 44100
20
21mkdirSync(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.
24const server = await createServer({ server: { port: 4318, strictPort: true }, logLevel: 'error' })
25await server.listen()
26const browser = await chromium.launch()
27let samples
28try {
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
61let peak = 0
62let power = 0
63for (const v of samples) {
64 peak = Math.max(peak, Math.abs(v))
65 power += v * v
66}
67const db = (x) => (20 * Math.log10(x)).toFixed(1)
68console.log(` peak ${db(peak)} dBFS, average ${db(Math.sqrt(power / samples.length))} dBFS`)
69
70const data = Buffer.alloc(samples.length * 2)
71samples.forEach((v, i) => data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v)) * 32767), i * 2))
72const header = Buffer.alloc(44)
73header.write('RIFF', 0)
74header.writeUInt32LE(36 + data.length, 4)
75header.write('WAVEfmt ', 8)
76header.writeUInt32LE(16, 16)
77header.writeUInt16LE(1, 20)
78header.writeUInt16LE(2, 22)
79header.writeUInt32LE(RATE, 24)
80header.writeUInt32LE(RATE * 4, 28)
81header.writeUInt16LE(4, 32)
82header.writeUInt16LE(16, 34)
83header.write('data', 36)
84header.writeUInt32LE(data.length, 40)
85writeFileSync(`${OUT}/synth.wav`, Buffer.concat([header, data]))
86
87const 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`])
90if (drawn.status !== 0) throw new Error(`ffmpeg: ${drawn.stderr}`)
91console.log(` ${OUT}/synth.wav\n ${OUT}/synth.png`)