nordic-skating / mistral-medium-35-littlejs / files / src / audio / iceSound.ts
Branch mistral-medium-35-littlejs · default branch · all branches
iceSound.ts
| 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 |
| 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 |
| 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. |
| 29 | * - **The wind.** When there is one: low, loose noise, louder and higher in its gusts. |
| 30 | * - **The cracks.** Now and then the ice cracks some way off, and that arrives as the |
| 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. |
| 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 |
| 47 | private readonly snowGain: GainNode |
| 48 | private readonly tickGain: GainNode |
| 49 | private readonly splashGain: GainNode |
| 50 | private readonly gust: BiquadFilterNode |
| 51 | private readonly windGain: GainNode |
| 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) |
| 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) |
| 113 | |
| 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 | |
| 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) |
| 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 | */ |
| 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 |
| 146 | this.hum.frequency.setTargetAtTime(singingFrequency(h), when, 0.05) |
| 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) |
| 150 | } |
| 151 | |
| 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 | |
| 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 { |
| 178 | const { filter, gain } = this.voices[this.next]! |
| 179 | this.next = (this.next + 1) % VOICES |
| 180 | filter.frequency.cancelScheduledValues(when) |
| 181 | filter.frequency.setValueAtTime(note * from, when) |
| 182 | filter.frequency.exponentialRampToValueAtTime(note, when + 0.06) |
| 183 | gain.gain.cancelScheduledValues(when) |
| 184 | gain.gain.setValueAtTime(0, when) |
| 185 | gain.gain.linearRampToValueAtTime(level, when + 0.004) |
| 186 | gain.gain.setTargetAtTime(0, when + 0.004, RING_TIME) |
| 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) |
| 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. |
| 207 | const loud = Math.min(0.8, 0.5 * Math.sqrt(40 / distance)) |
| 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) |
| 213 | // And there the sine ends, within a few hundredths of a second: see above. |
| 214 | gain.gain.setTargetAtTime(0, when + duration, 0.015) |
| 215 | const panner = this.ctx.createStereoPanner() |
| 216 | panner.pan.value = pan |
| 217 | tone.connect(gain).connect(panner).connect(this.out) |
| 218 | tone.start(when) |
| 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) |
| 222 | } |
| 223 | } |