nordic-skating / local-littlejs / files / src / audio / iceSound.ts
Branch local-littlejs · default branch · all branches
iceSound.ts
9ff4c3fc 2026-10-09 Pavel Safronov | 1 | import { chirp, singingFrequency } from '../ice/acoustics.js' |
| 2 | ||
| 3 | /** | |
| 4 | * Seconds for the ring of a strike to fall to 1/e of its loudness: measured from the | |
| 5 | * film (tools/analyse-video.mjs), where it dies at 36 dB/s. | |
| 6 | */ | |
| 7 | const RING_TIME = 0.24 | |
5e8b9530 2026-10-10 Pavel Safronov | 8 | /** |
| 9 | * Rings that can sound at once. A ring is inaudible after about five ring times, and both | |
| 10 | * strides and cracks ring. | |
| 11 | */ | |
| 12 | const VOICES = 6 | |
9ff4c3fc 2026-10-09 Pavel Safronov | 13 | |
| 14 | interface Voice { | |
| 15 | filter: BiquadFilterNode | |
| 16 | gain: GainNode | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * The sound of skating on thin ice, synthesized. Nothing here is a recording: every part | |
| 21 | * is noise or a sine wave, shaped by what the ice does (ice/acoustics.ts). | |
| 22 | * | |
| 23 | * - **The ring.** Each push strikes the ice, and the ice answers at its note and dies | |
| 24 | * away. The note is a narrow band of noise rather than a pure tone, as it is on the | |
| 25 | * lake, where it flutters. It starts a little high and falls onto the note within a | |
| 26 | * few hundredths of a second: the chirp of a strike right under you. | |
| 27 | * - **The hum and the scrape.** Between strikes the gliding blade keeps the note faintly | |
| 28 | * alive, and hisses on the surface. | |
236f7d03 2026-10-10 Pavel Safronov | 29 | * - **The wind.** When there is one: low, loose noise, louder and higher in its gusts. |
9ff4c3fc 2026-10-09 Pavel Safronov | 30 | * - **The cracks.** Now and then the ice cracks some way off, and that arrives as the |
5e8b9530 2026-10-10 Pavel Safronov | 31 | * long falling "pew": high frequencies first, in the order the ice carries them. The |
| 32 | * fall is a sine wave, the one pure tone here, and it is gone as soon as it lands on | |
| 33 | * the note: what rings on after it is the ice, in noise, as after a strike. Left to | |
| 34 | * ring by itself the sine is a clean, struck, high note — a sonar ping, on a lake. | |
9ff4c3fc 2026-10-09 Pavel Safronov | 35 | * |
| 36 | * Every method takes the time at which it should happen, so that the same code plays | |
| 37 | * live and renders offline (tools/sound-check.mjs). | |
| 38 | */ | |
| 39 | export class IceSound { | |
| 40 | private readonly out: GainNode | |
| 41 | private readonly noise: AudioBufferSourceNode | |
| 42 | private readonly voices: Voice[] = [] | |
| 43 | private next = 0 | |
| 44 | private readonly hum: BiquadFilterNode | |
| 45 | private readonly humGain: GainNode | |
| 46 | private readonly scrapeGain: GainNode | |
8bb08e4a 2026-10-10 Pavel Safronov | 47 | private readonly snowGain: GainNode |
9ff4c3fc 2026-10-09 Pavel Safronov | 48 | private readonly tickGain: GainNode |
646446cd 2026-10-09 Pavel Safronov | 49 | private readonly splashGain: GainNode |
236f7d03 2026-10-10 Pavel Safronov | 50 | private readonly gust: BiquadFilterNode |
| 51 | private readonly windGain: GainNode | |
9ff4c3fc 2026-10-09 Pavel Safronov | 52 | |
| 53 | constructor(private readonly ctx: BaseAudioContext, random: () => number = Math.random) { | |
| 54 | // A limiter, in effect: several rings and a crack can land together. | |
| 55 | const limiter = ctx.createDynamicsCompressor() | |
| 56 | limiter.threshold.value = -10 | |
| 57 | limiter.ratio.value = 12 | |
| 58 | limiter.connect(ctx.destination) | |
| 59 | this.out = ctx.createGain() | |
| 60 | this.out.gain.value = 0.7 | |
| 61 | this.out.connect(limiter) | |
| 62 | ||
| 63 | // Four seconds of white noise, looped, is the raw material for everything but cracks. | |
| 64 | const buffer = ctx.createBuffer(1, ctx.sampleRate * 4, ctx.sampleRate) | |
| 65 | const samples = buffer.getChannelData(0) | |
| 66 | for (let i = 0; i < samples.length; i++) samples[i] = random() * 2 - 1 | |
| 67 | this.noise = ctx.createBufferSource() | |
| 68 | this.noise.buffer = buffer | |
| 69 | this.noise.loop = true | |
| 70 | this.noise.start() | |
| 71 | ||
| 72 | for (let i = 0; i < VOICES; i++) { | |
| 73 | const filter = ctx.createBiquadFilter() | |
| 74 | filter.type = 'bandpass' | |
| 75 | // About 17 Hz wide at 740 Hz, which is the width of the note in the film. | |
| 76 | filter.Q.value = 45 | |
| 77 | const gain = ctx.createGain() | |
| 78 | gain.gain.value = 0 | |
| 79 | this.noise.connect(filter).connect(gain).connect(this.out) | |
| 80 | this.voices.push({ filter, gain }) | |
| 81 | } | |
| 82 | ||
| 83 | this.hum = ctx.createBiquadFilter() | |
| 84 | this.hum.type = 'bandpass' | |
| 85 | this.hum.Q.value = 30 | |
| 86 | // Somewhere sensible to start, so that the first note does not swoop up from nowhere. | |
| 87 | this.hum.frequency.value = singingFrequency(0.041) | |
| 88 | this.humGain = ctx.createGain() | |
| 89 | this.humGain.gain.value = 0 | |
| 90 | this.noise.connect(this.hum).connect(this.humGain).connect(this.out) | |
| 91 | ||
| 92 | const scrape = ctx.createBiquadFilter() | |
| 93 | scrape.type = 'bandpass' | |
| 94 | scrape.frequency.value = 2600 | |
| 95 | scrape.Q.value = 0.7 | |
| 96 | this.scrapeGain = ctx.createGain() | |
| 97 | this.scrapeGain.gain.value = 0 | |
| 98 | this.noise.connect(scrape).connect(this.scrapeGain).connect(this.out) | |
| 99 | ||
| 100 | const tick = ctx.createBiquadFilter() | |
| 101 | tick.type = 'highpass' | |
| 102 | tick.frequency.value = 1800 | |
| 103 | this.tickGain = ctx.createGain() | |
| 104 | this.tickGain.gain.value = 0 | |
| 105 | this.noise.connect(tick).connect(this.tickGain).connect(this.out) | |
646446cd 2026-10-09 Pavel Safronov | 106 | |
| 107 | const splash = ctx.createBiquadFilter() | |
| 108 | splash.type = 'lowpass' | |
| 109 | splash.frequency.value = 700 | |
| 110 | this.splashGain = ctx.createGain() | |
| 111 | this.splashGain.gain.value = 0 | |
| 112 | this.noise.connect(splash).connect(this.splashGain).connect(this.out) | |
236f7d03 2026-10-10 Pavel Safronov | 113 | |
8bb08e4a 2026-10-10 Pavel Safronov | 114 | // A blade in snow: no ring and no hiss, only a soft, low shush. |
| 115 | const shush = ctx.createBiquadFilter() | |
| 116 | shush.type = 'lowpass' | |
| 117 | shush.frequency.value = 1100 | |
| 118 | this.snowGain = ctx.createGain() | |
| 119 | this.snowGain.gain.value = 0 | |
| 120 | this.noise.connect(shush).connect(this.snowGain).connect(this.out) | |
| 121 | ||
236f7d03 2026-10-10 Pavel Safronov | 122 | // The wind: the low end of the same noise, loose and wide, rising in pitch as it blows harder. |
| 123 | this.gust = ctx.createBiquadFilter() | |
| 124 | this.gust.type = 'bandpass' | |
| 125 | this.gust.frequency.value = 300 | |
| 126 | this.gust.Q.value = 0.6 | |
| 127 | this.windGain = ctx.createGain() | |
| 128 | this.windGain.gain.value = 0 | |
| 129 | this.noise.connect(this.gust).connect(this.windGain).connect(this.out) | |
| 130 | } | |
| 131 | ||
| 132 | /** The wind over the lake, from 0 for still air to 1 for the worst gust of a blizzard. */ | |
| 133 | wind(strength: number, when = this.ctx.currentTime): void { | |
| 134 | this.gust.frequency.setTargetAtTime(280 + 520 * strength, when, 0.4) | |
| 135 | this.windGain.gain.setTargetAtTime(0.9 * strength ** 1.5, when, 0.4) | |
9ff4c3fc 2026-10-09 Pavel Safronov | 136 | } |
| 137 | ||
| 138 | /** | |
| 139 | * The blade gliding over ice `h` metres thick. `push` is 1 at the moment of a strike | |
| 140 | * and falls to 0 through the glide that follows it. | |
| 141 | */ | |
8bb08e4a 2026-10-10 Pavel Safronov | 142 | glide(h: number, push: number, when = this.ctx.currentTime, snow = 0): void { |
| 143 | // Snow lying on the ice takes the blade off it: `snow` is how much, 0 to 1. The ice | |
| 144 | // under a drift does not sing, and the blade shushes through it. | |
| 145 | const bare = 1 - snow | |
9ff4c3fc 2026-10-09 Pavel Safronov | 146 | this.hum.frequency.setTargetAtTime(singingFrequency(h), when, 0.05) |
8bb08e4a 2026-10-10 Pavel Safronov | 147 | this.humGain.gain.setTargetAtTime((0.15 + 0.5 * push) * bare, when, 0.03) |
| 148 | this.scrapeGain.gain.setTargetAtTime((0.012 + 0.03 * push) * bare, when, 0.03) | |
| 149 | this.snowGain.gain.setTargetAtTime((0.05 + 0.1 * push) * snow, when, 0.05) | |
9ff4c3fc 2026-10-09 Pavel Safronov | 150 | } |
| 151 | ||
646446cd 2026-10-09 Pavel Safronov | 152 | /** |
| 153 | * The ice giving way under the skater: it breaks, the water takes them, and the blade | |
| 154 | * is no longer on the ice to keep it sounding. | |
| 155 | */ | |
| 156 | fall(h: number, when = this.ctx.currentTime): void { | |
| 157 | for (const [after, distance] of [[0, 2], [0.04, 5], [0.11, 9], [0.2, 16]] as const) { | |
| 158 | this.crack(h, distance, when + after, 0) | |
| 159 | } | |
| 160 | this.tickGain.gain.cancelScheduledValues(when) | |
| 161 | this.tickGain.gain.setValueAtTime(0.5, when) | |
| 162 | this.tickGain.gain.setTargetAtTime(0, when, 0.05) | |
| 163 | this.splashGain.gain.cancelScheduledValues(when) | |
| 164 | this.splashGain.gain.setValueAtTime(0, when) | |
| 165 | this.splashGain.gain.linearRampToValueAtTime(1.6, when + 0.12) | |
| 166 | this.splashGain.gain.setTargetAtTime(0, when + 0.12, 0.45) | |
| 167 | this.humGain.gain.cancelScheduledValues(when) | |
| 168 | this.humGain.gain.setTargetAtTime(0, when, 0.05) | |
| 169 | this.scrapeGain.gain.cancelScheduledValues(when) | |
| 170 | this.scrapeGain.gain.setTargetAtTime(0, when, 0.05) | |
| 171 | } | |
| 172 | ||
5e8b9530 2026-10-10 Pavel Safronov | 173 | /** |
| 174 | * The ice ringing at `note` Hz from `when`: a narrow band of noise that starts at | |
| 175 | * `from` times the note, falls onto it within a few hundredths of a second, and dies away. | |
| 176 | */ | |
| 177 | private ring(note: number, when: number, level: number, from: number): void { | |
9ff4c3fc 2026-10-09 Pavel Safronov | 178 | const { filter, gain } = this.voices[this.next]! |
| 179 | this.next = (this.next + 1) % VOICES | |
| 180 | filter.frequency.cancelScheduledValues(when) | |
5e8b9530 2026-10-10 Pavel Safronov | 181 | filter.frequency.setValueAtTime(note * from, when) |
9ff4c3fc 2026-10-09 Pavel Safronov | 182 | filter.frequency.exponentialRampToValueAtTime(note, when + 0.06) |
| 183 | gain.gain.cancelScheduledValues(when) | |
| 184 | gain.gain.setValueAtTime(0, when) | |
5e8b9530 2026-10-10 Pavel Safronov | 185 | gain.gain.linearRampToValueAtTime(level, when + 0.004) |
9ff4c3fc 2026-10-09 Pavel Safronov | 186 | gain.gain.setTargetAtTime(0, when + 0.004, RING_TIME) |
5e8b9530 2026-10-10 Pavel Safronov | 187 | } |
| 188 | ||
| 189 | /** A skate striking ice `h` metres thick. `strength` is about 1. */ | |
| 190 | strike(h: number, when = this.ctx.currentTime, strength = 1): void { | |
| 191 | this.ring(singingFrequency(h), when, 7 * strength, 1.75) | |
9ff4c3fc 2026-10-09 Pavel Safronov | 192 | |
| 193 | this.tickGain.gain.cancelScheduledValues(when) | |
| 194 | this.tickGain.gain.setValueAtTime(0.12 * strength, when) | |
| 195 | this.tickGain.gain.setTargetAtTime(0, when, 0.012) | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * The ice cracking `distance` metres away, heard across ice `h` metres thick. `pan` is | |
| 200 | * where it comes from, -1 (left) to 1 (right). | |
| 201 | */ | |
| 202 | crack(h: number, distance: number, when = this.ctx.currentTime, pan = 0): void { | |
| 203 | const { duration, frequencies } = chirp(h, distance) | |
| 204 | const tone = this.ctx.createOscillator() | |
| 205 | tone.frequency.setValueCurveAtTime(frequencies, when, duration) | |
| 206 | // Sound spreading across a sheet thins as the circle it is on widens. | |
646446cd 2026-10-09 Pavel Safronov | 207 | const loud = Math.min(0.8, 0.5 * Math.sqrt(40 / distance)) |
9ff4c3fc 2026-10-09 Pavel Safronov | 208 | const gain = this.ctx.createGain() |
| 209 | gain.gain.setValueAtTime(0, when) | |
| 210 | gain.gain.linearRampToValueAtTime(loud * 0.25, when + 0.003) | |
| 211 | // The ice gives up more of its sound to the air the nearer the chirp gets to the note. | |
| 212 | gain.gain.exponentialRampToValueAtTime(loud, when + duration) | |
5e8b9530 2026-10-10 Pavel Safronov | 213 | // And there the sine ends, within a few hundredths of a second: see above. |
| 214 | gain.gain.setTargetAtTime(0, when + duration, 0.015) | |
9ff4c3fc 2026-10-09 Pavel Safronov | 215 | const panner = this.ctx.createStereoPanner() |
| 216 | panner.pan.value = pan | |
| 217 | tone.connect(gain).connect(panner).connect(this.out) | |
| 218 | tone.start(when) | |
5e8b9530 2026-10-10 Pavel Safronov | 219 | tone.stop(when + duration + 0.15) |
| 220 | // What rings on is the ice, as it does after a strike, and as loud as the crack was. | |
| 221 | this.ring(singingFrequency(h), when + duration, 5 * loud, 1.05) | |
9ff4c3fc 2026-10-09 Pavel Safronov | 222 | } |
| 223 | } |