fuzzyworld

nordic-skating / files / src / audio / iceSound.ts

iceSound.ts

Blame: the commit that last changed each line · file · history

9ff4c3fc 2026-10-09 Pavel Safronov1import { 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 */
7const RING_TIME = 0.24
5e8b9530 2026-10-10 Pavel Safronov8/**
9 * Rings that can sound at once. A ring is inaudible after about five ring times, and both
10 * strides and cracks ring.
11 */
12const VOICES = 6
9ff4c3fc 2026-10-09 Pavel Safronov13
14interface 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 Safronov29 * - **The wind.** When there is one: low, loose noise, louder and higher in its gusts.
9ff4c3fc 2026-10-09 Pavel Safronov30 * - **The cracks.** Now and then the ice cracks some way off, and that arrives as the
5e8b9530 2026-10-10 Pavel Safronov31 * 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 Safronov35 *
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 */
39export 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 Safronov47 private readonly snowGain: GainNode
9ff4c3fc 2026-10-09 Pavel Safronov48 private readonly tickGain: GainNode
646446cd 2026-10-09 Pavel Safronov49 private readonly splashGain: GainNode
236f7d03 2026-10-10 Pavel Safronov50 private readonly gust: BiquadFilterNode
51 private readonly windGain: GainNode
9ff4c3fc 2026-10-09 Pavel Safronov52
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 Safronov106
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 Safronov113
8bb08e4a 2026-10-10 Pavel Safronov114 // 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 Safronov122 // 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 Safronov136 }
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 Safronov142 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 Safronov146 this.hum.frequency.setTargetAtTime(singingFrequency(h), when, 0.05)
8bb08e4a 2026-10-10 Pavel Safronov147 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 Safronov150 }
151
646446cd 2026-10-09 Pavel Safronov152 /**
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 Safronov173 /**
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 Safronov178 const { filter, gain } = this.voices[this.next]!
179 this.next = (this.next + 1) % VOICES
180 filter.frequency.cancelScheduledValues(when)
5e8b9530 2026-10-10 Pavel Safronov181 filter.frequency.setValueAtTime(note * from, when)
9ff4c3fc 2026-10-09 Pavel Safronov182 filter.frequency.exponentialRampToValueAtTime(note, when + 0.06)
183 gain.gain.cancelScheduledValues(when)
184 gain.gain.setValueAtTime(0, when)
5e8b9530 2026-10-10 Pavel Safronov185 gain.gain.linearRampToValueAtTime(level, when + 0.004)
9ff4c3fc 2026-10-09 Pavel Safronov186 gain.gain.setTargetAtTime(0, when + 0.004, RING_TIME)
5e8b9530 2026-10-10 Pavel Safronov187 }
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 Safronov192
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 Safronov207 const loud = Math.min(0.8, 0.5 * Math.sqrt(40 / distance))
9ff4c3fc 2026-10-09 Pavel Safronov208 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 Safronov213 // 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 Safronov215 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 Safronov219 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 Safronov222 }
223}