fuzzyworld

nordic-skating / commit

feat(sound): the ice sings under every stride, synthesized from its thickness

9ff4c3fcd873264e6581e91a408df89c0686aff0 · Pavel Safronov · 2026-10-09 00:14:26 -0700 · parents 174b4a1af886

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 1 tally 296 in / 211.6K out / 13.0M cached · usage by commit and session

feat(sound): the ice sings under every stride, synthesized from its thickness

Nothing is a recording: noise and sine waves, shaped by the model. Each
stride strikes the ice, which rings at its note in a narrow band of
noise and dies away as it does in the film; the gliding blade keeps the
note faintly alive and hisses; and every few seconds the ice cracks
40 to 600 m off and the chirp for that distance arrives. Sound starts at
the first tap or key, as browsers require, and stops with the page
hidden.

Strides are long glides of 1.4 to 3.2 s, each a different length. The
film's 0.66 s was tried first and rocked the view from leg to leg
without rest.

npm run sound-check renders twelve seconds offline and draws its
spectrogram into docs/sound/, to look at beside the film's.

Usage: 296 in / 211.6K out / 13.0M cached (session tally)
rtk:   1.8M saved, 25.6% over 2815 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 .gitignore                     |   3 +
 README.md                      |  78 ++++++++++++++++++----
 docs/screenshots/autopilot.png | Bin 312341 -> 322125 bytes
 docs/screenshots/far.png       | Bin 252696 -> 273410 bytes
 docs/screenshots/landscape.png | Bin 435186 -> 441507 bytes
 docs/screenshots/steering.png  | Bin 354089 -> 377348 bytes
 docs/sound/synth.png           | Bin 0 -> 1811379 bytes
 index.html                     |   2 +-
 package.json                   |   4 +-
 src/audio/iceSound.ts          | 147 +++++++++++++++++++++++++++++++++++++++++
 src/main.ts                    |  37 ++++++++++-
 src/skater.ts                  |  21 ++++--
 test/skater.test.ts            |  19 +++++-
 tools/sound-check.mjs          |  91 +++++++++++++++++++++++++
 14 files changed, 380 insertions(+), 22 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3d64db2..546d19a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@ dist/
 # Playwright verification screenshots
 .playwright-mcp/
 
+# What tools/sound-check.mjs renders, beside the spectrogram of it that is kept.
+docs/sound/*.wav
+
 # Reference material for analysis only: National Geographic's video and its description.
 # Copyrighted, never distributed with the app (README, Attribution).
 *.mp4
diff --git a/README.md b/README.md
index 7fd1577..c9fc879 100644
--- a/README.md
+++ b/README.md
@@ -15,18 +15,22 @@ in which Mårten Ajne skates a small lake outside Stockholm.
 
 ## Status
 
-**Step 2 of 6: an endless lake.** The lake runs on for ever between snowy, wooded
-shores, and the skater skates it. There is no sound yet.
+**Step 3 of 6: the sound.** The lake runs on for ever between snowy, wooded shores,
+the skater skates it, and the ice sings under every stride at a pitch set by how thick
+it is there. The thickness cannot be seen yet, and nothing happens on thin ice.
 
 1. ✅ Scaffold: build, tests, deploy to GitLab Pages.
 2. ✅ An endless lake, skated on autopilot, with steering.
-3. Measure the reference video, then synthesize the ice sound from ice thickness and speed.
+3. ✅ Measure the reference video, then synthesize the ice sound from the ice's thickness.
 4. Ice zones that fall out of the physics, and falling through.
 5. Forks in the lake.
 6. Game controller, then multiplayer.
 
 ## Controls
 
+Tap or press a key once to turn the sound on: a browser lets a page make sound only
+after it has been touched. On an iPhone the ring/silent switch also silences it.
+
 Do nothing and the skater follows the middle of the lake, round every bend, for as
 long as you leave it.
 
@@ -53,17 +57,59 @@ holds that line for a second and a half, then eases back to the middle.
 
 ## The sound
 
-The sound will be synthesized in the browser, not played back from a recording. Two
-effects make up the "laser" sound of thin ice, and both follow from how bending waves
-travel through a floating sheet of ice:
+The sound is synthesized in the browser. None of it is a recording.
+
+### The physics
+
+A sheet of ice floating on water carries bending waves, and they are dispersive: the
+higher the frequency, the faster it travels. The two sounds of thin ice follow from that
+(`src/ice/acoustics.ts`).
+
+- **The singing note.** A bending wave slower than sound in air cannot radiate into the
+  air at all. The ice gives off sound most strongly at the one frequency whose bending
+  wave travels exactly at the speed of sound in air, where the wave in the ice and the
+  wave in the air keep step. That frequency is very nearly **30.3 Hz·m divided by the
+  thickness**: 4 cm of ice sings at about 760 Hz, 3 cm at about 1010 Hz, 6 cm at about
+  505 Hz. The pitch is a thickness gauge: the higher it sings, the thinner the ice.
+- **The chirps.** A crack is every frequency at once. From some way off the high ones
+  arrive first and the low ones after, ending on the note: a falling "pew". The further
+  the crack, the longer the fall — about a third of a second from 400 m.
+
+The model is from Romeyn, Hanssen, Ruud and Johansen,
+[Sea ice thickness from air-coupled flexural waves](https://doi.org/10.5194/tc-15-2939-2021),
+*The Cryosphere* 15, 2939–2955 (2021), who took the sound of the very film this app is
+modelled on and read about 725 Hz and 4.3 cm of ice from it. With textbook values for
+freshwater ice and nothing tuned, the model here gives 4.2 cm for 725 Hz.
+
+### What the film measures
+
+`npm run analyse-video` measures a local copy of the film (it is not in this repository):
+
+| | Measured | Used for |
+|---|---|---|
+| The note | median 740 Hz; 664–826 Hz over the middle 90% | Ice 4.1 cm thick, 3.7–4.6 cm; the middle of the lake's range of thickness |
+| Time between skate strikes | median 0.66 s | Not used: at that rate the view rocks from leg to leg without rest. Strides here are long glides of 1.4 to 3.2 s, each a different length |
+| How fast a ring dies | 36 dB a second | The ring's decay: to 1/e in 0.24 s |
+
+Fitting the fall of the film's chirps to the model was tried and did not work: its
+soundtrack is edited and the chirps overlap. The chirps here are the model's, unchecked
+against the film.
+
+### What is synthesized
+
+`src/audio/iceSound.ts`, from noise and sine waves only:
+
+- **The ring**: at each stride, every 1.4 to 3.2 s, a skate strikes the ice, which answers with a narrow band
+  of noise at its note, starting a little high and falling onto it, then dying away.
+- **The hum and the scrape**: the gliding blade keeps the note faintly alive and hisses.
+- **The cracks**: every few seconds the ice cracks somewhere between 40 and 600 m off,
+  and the chirp for that distance and that thickness arrives.
 
-- **Chirps.** Bending waves are dispersive: high frequencies travel faster than low
-  ones. A crack some distance away arrives high-pitch-first, as a falling "pew".
-- **The singing tone.** Ice radiates into the air best at the frequency where its
-  bending wave moves at the speed of sound in air. That frequency rises as the ice
-  thins, so the pitch is a thickness gauge: the higher it sings, the thinner the ice.
+`npm run sound-check` renders twelve seconds of it without playing it and draws its
+spectrogram: six seconds on the film's 4.1 cm, then onto ice thinning to 2.8 cm, with
+three cracks.
 
-The numbers are to be fitted against the reference video in step 3.
+<img src="docs/sound/synth.png" alt="Spectrogram: a bright narrow band near 740 Hz struck every two seconds or so and fading between, three thin lines falling steeply onto it, and the band climbing past 1 kHz in the last six seconds" width="800" />
 
 ## Develop
 
@@ -76,11 +122,14 @@ npm test         # vitest
 npm run build    # typecheck, then build into dist/
 npm run preview  # serve dist/
 npm run screenshots  # build, then photograph the app at phone size into docs/screenshots/
+npm run sound-check  # render the synthesized sound and draw its spectrogram into docs/sound/
+npm run analyse-video  # measure a local copy of the reference film
 ```
 
 `?s=2500` on the URL starts that many metres down the lake.
 
-The screenshots need Playwright's Chromium once: `npx playwright install chromium`.
+The screenshots and the sound check need Playwright's Chromium once:
+`npx playwright install chromium`. The sound check and the analysis need ffmpeg.
 
 ## Deploy
 
@@ -95,6 +144,9 @@ named `public`, and a `public/` already in the repository would collide with it.
 
 - [three.js](https://threejs.org/) — MIT licence.
 - [simplex-noise](https://github.com/jwagner/simplex-noise.js) — MIT licence.
+- The physics of the sound: Romeyn, R., Hanssen, A., Ruud, B. O., and Johansen, T. A.,
+  [Sea ice thickness from air-coupled flexural waves](https://doi.org/10.5194/tc-15-2939-2021),
+  *The Cryosphere* 15, 2939–2955, 2021 (CC BY 4.0).
 - The National Geographic video above is the reference this is modelled on. It is
   copyrighted, and neither it nor any audio taken from it is part of this repository
   or the app: it is measured locally, and the app's sound is synthesized from those
diff --git a/docs/screenshots/autopilot.png b/docs/screenshots/autopilot.png
index 2dd785c..97617b9 100644
Binary files a/docs/screenshots/autopilot.png and b/docs/screenshots/autopilot.png differ
diff --git a/docs/screenshots/far.png b/docs/screenshots/far.png
index d04c85f..bc1e374 100644
Binary files a/docs/screenshots/far.png and b/docs/screenshots/far.png differ
diff --git a/docs/screenshots/landscape.png b/docs/screenshots/landscape.png
index 69cc56c..8c67973 100644
Binary files a/docs/screenshots/landscape.png and b/docs/screenshots/landscape.png differ
diff --git a/docs/screenshots/steering.png b/docs/screenshots/steering.png
index 2884cca..c07e38c 100644
Binary files a/docs/screenshots/steering.png and b/docs/screenshots/steering.png differ
diff --git a/docs/sound/synth.png b/docs/sound/synth.png
new file mode 100644
index 0000000..e89dbec
Binary files /dev/null and b/docs/sound/synth.png differ
diff --git a/index.html b/index.html
index 6804e0e..e6daa03 100644
--- a/index.html
+++ b/index.html
@@ -8,7 +8,7 @@
 </head>
 <body>
 <canvas id="scene"></canvas>
-<p id="hint">Drag left or right to steer — or just watch</p>
+<p id="hint">Tap for sound · drag left or right to steer — or just watch</p>
 <script type="module" src="/src/main.ts"></script>
 </body>
 </html>
diff --git a/package.json b/package.json
index 81b4a1a..b8fac0e 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,9 @@
     "preview": "vite preview",
     "test": "vitest run --passWithNoTests",
     "test:watch": "vitest",
-    "screenshots": "npm run build && node tools/screenshots.mjs"
+    "screenshots": "npm run build && node tools/screenshots.mjs",
+    "analyse-video": "node tools/analyse-video.mjs",
+    "sound-check": "node tools/sound-check.mjs"
   },
   "dependencies": {
     "simplex-noise": "^4.0.3",
diff --git a/src/audio/iceSound.ts b/src/audio/iceSound.ts
new file mode 100644
index 0000000..ef80825
--- /dev/null
+++ b/src/audio/iceSound.ts
@@ -0,0 +1,147 @@
+import { chirp, singingFrequency } from '../ice/acoustics.js'
+
+/**
+ * Seconds for the ring of a strike to fall to 1/e of its loudness: measured from the
+ * film (tools/analyse-video.mjs), where it dies at 36 dB/s.
+ */
+const RING_TIME = 0.24
+/** Rings that can sound at once. A ring is inaudible after about five ring times. */
+const VOICES = 4
+
+interface Voice {
+  filter: BiquadFilterNode
+  gain: GainNode
+}
+
+/**
+ * The sound of skating on thin ice, synthesized. Nothing here is a recording: every part
+ * is noise or a sine wave, shaped by what the ice does (ice/acoustics.ts).
+ *
+ * - **The ring.** Each push strikes the ice, and the ice answers at its note and dies
+ *   away. The note is a narrow band of noise rather than a pure tone, as it is on the
+ *   lake, where it flutters. It starts a little high and falls onto the note within a
+ *   few hundredths of a second: the chirp of a strike right under you.
+ * - **The hum and the scrape.** Between strikes the gliding blade keeps the note faintly
+ *   alive, and hisses on the surface.
+ * - **The cracks.** Now and then the ice cracks some way off, and that arrives as the
+ *   long falling "pew": high frequencies first, in the order the ice carries them.
+ *
+ * Every method takes the time at which it should happen, so that the same code plays
+ * live and renders offline (tools/sound-check.mjs).
+ */
+export class IceSound {
+  private readonly out: GainNode
+  private readonly noise: AudioBufferSourceNode
+  private readonly voices: Voice[] = []
+  private next = 0
+  private readonly hum: BiquadFilterNode
+  private readonly humGain: GainNode
+  private readonly scrapeGain: GainNode
+  private readonly tickGain: GainNode
+
+  constructor(private readonly ctx: BaseAudioContext, random: () => number = Math.random) {
+    // A limiter, in effect: several rings and a crack can land together.
+    const limiter = ctx.createDynamicsCompressor()
+    limiter.threshold.value = -10
+    limiter.ratio.value = 12
+    limiter.connect(ctx.destination)
+    this.out = ctx.createGain()
+    this.out.gain.value = 0.7
+    this.out.connect(limiter)
+
+    // Four seconds of white noise, looped, is the raw material for everything but cracks.
+    const buffer = ctx.createBuffer(1, ctx.sampleRate * 4, ctx.sampleRate)
+    const samples = buffer.getChannelData(0)
+    for (let i = 0; i < samples.length; i++) samples[i] = random() * 2 - 1
+    this.noise = ctx.createBufferSource()
+    this.noise.buffer = buffer
+    this.noise.loop = true
+    this.noise.start()
+
+    for (let i = 0; i < VOICES; i++) {
+      const filter = ctx.createBiquadFilter()
+      filter.type = 'bandpass'
+      // About 17 Hz wide at 740 Hz, which is the width of the note in the film.
+      filter.Q.value = 45
+      const gain = ctx.createGain()
+      gain.gain.value = 0
+      this.noise.connect(filter).connect(gain).connect(this.out)
+      this.voices.push({ filter, gain })
+    }
+
+    this.hum = ctx.createBiquadFilter()
+    this.hum.type = 'bandpass'
+    this.hum.Q.value = 30
+    // Somewhere sensible to start, so that the first note does not swoop up from nowhere.
+    this.hum.frequency.value = singingFrequency(0.041)
+    this.humGain = ctx.createGain()
+    this.humGain.gain.value = 0
+    this.noise.connect(this.hum).connect(this.humGain).connect(this.out)
+
+    const scrape = ctx.createBiquadFilter()
+    scrape.type = 'bandpass'
+    scrape.frequency.value = 2600
+    scrape.Q.value = 0.7
+    this.scrapeGain = ctx.createGain()
+    this.scrapeGain.gain.value = 0
+    this.noise.connect(scrape).connect(this.scrapeGain).connect(this.out)
+
+    const tick = ctx.createBiquadFilter()
+    tick.type = 'highpass'
+    tick.frequency.value = 1800
+    this.tickGain = ctx.createGain()
+    this.tickGain.gain.value = 0
+    this.noise.connect(tick).connect(this.tickGain).connect(this.out)
+  }
+
+  /**
+   * The blade gliding over ice `h` metres thick. `push` is 1 at the moment of a strike
+   * and falls to 0 through the glide that follows it.
+   */
+  glide(h: number, push: number, when = this.ctx.currentTime): void {
+    this.hum.frequency.setTargetAtTime(singingFrequency(h), when, 0.05)
+    this.humGain.gain.setTargetAtTime(0.15 + 0.5 * push, when, 0.03)
+    this.scrapeGain.gain.setTargetAtTime(0.012 + 0.03 * push, when, 0.03)
+  }
+
+  /** A skate striking ice `h` metres thick. `strength` is about 1. */
+  strike(h: number, when = this.ctx.currentTime, strength = 1): void {
+    const note = singingFrequency(h)
+    const { filter, gain } = this.voices[this.next]!
+    this.next = (this.next + 1) % VOICES
+    filter.frequency.cancelScheduledValues(when)
+    filter.frequency.setValueAtTime(note * 1.75, when)
+    filter.frequency.exponentialRampToValueAtTime(note, when + 0.06)
+    gain.gain.cancelScheduledValues(when)
+    gain.gain.setValueAtTime(0, when)
+    gain.gain.linearRampToValueAtTime(7 * strength, when + 0.004)
+    gain.gain.setTargetAtTime(0, when + 0.004, RING_TIME)
+
+    this.tickGain.gain.cancelScheduledValues(when)
+    this.tickGain.gain.setValueAtTime(0.12 * strength, when)
+    this.tickGain.gain.setTargetAtTime(0, when, 0.012)
+  }
+
+  /**
+   * The ice cracking `distance` metres away, heard across ice `h` metres thick. `pan` is
+   * where it comes from, -1 (left) to 1 (right).
+   */
+  crack(h: number, distance: number, when = this.ctx.currentTime, pan = 0): void {
+    const { duration, frequencies } = chirp(h, distance)
+    const tone = this.ctx.createOscillator()
+    tone.frequency.setValueCurveAtTime(frequencies, when, duration)
+    // Sound spreading across a sheet thins as the circle it is on widens.
+    const loud = 0.5 * Math.sqrt(40 / distance)
+    const gain = this.ctx.createGain()
+    gain.gain.setValueAtTime(0, when)
+    gain.gain.linearRampToValueAtTime(loud * 0.25, when + 0.003)
+    // The ice gives up more of its sound to the air the nearer the chirp gets to the note.
+    gain.gain.exponentialRampToValueAtTime(loud, when + duration)
+    gain.gain.setTargetAtTime(0, when + duration, RING_TIME)
+    const panner = this.ctx.createStereoPanner()
+    panner.pan.value = pan
+    tone.connect(gain).connect(panner).connect(this.out)
+    tone.start(when)
+    tone.stop(when + duration + 6 * RING_TIME)
+  }
+}
diff --git a/src/main.ts b/src/main.ts
index 8f21a8e..aed28e8 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,9 +1,10 @@
 import './style.css'
 import * as THREE from 'three'
+import { IceSound } from './audio/iceSound.js'
 import { Input } from './input.js'
 import { Ice } from './render/ice.js'
 import { Shores } from './render/shores.js'
-import { createSkater, skaterX, stepSkater } from './skater.js'
+import { createSkater, skaterX, stepSkater, strideSeconds } from './skater.js'
 import { createLake } from './world/lake.js'
 
 const SKY = 0x9db4c8
@@ -64,13 +65,47 @@ const clock = new THREE.Clock()
 // Whoever is just watching has no use for the hint after a first read of it.
 setTimeout(() => hint.classList.add('gone'), 8000)
 
+// A browser lets a page make sound only once it has been touched, so the sound starts
+// at the first tap or key rather than with the page.
+let audio: AudioContext | null = null
+let sound: IceSound | null = null
+function startSound(): void {
+  if (audio) return void audio.resume()
+  audio = new AudioContext()
+  sound = new IceSound(audio)
+}
+window.addEventListener('pointerdown', startSound)
+window.addEventListener('keydown', startSound)
+// Frames stop while the page is hidden, but sound would not: the hum would drone on.
+document.addEventListener('visibilitychange', () => {
+  if (audio) void (document.hidden ? audio.suspend() : audio.resume())
+})
+/** Seconds until the ice next cracks somewhere off across the lake. */
+let untilCrack = 3
+
 renderer.setAnimationLoop(() => {
   // A tab left in the background comes back with one enormous step; skate it as a short one.
   const dt = Math.min(clock.getDelta(), 0.1)
   const steer = input.steer()
   if (steer !== 0) hint.classList.add('gone')
+  const strides = Math.floor(skater.stride)
   stepSkater(skater, lake, steer, dt)
 
+  if (sound) {
+    const h = lake.thickness(skaterX(skater, lake), skater.s)
+    // One strike of a skate at the start of each stride, no two quite alike.
+    if (Math.floor(skater.stride) !== strides) sound.strike(h, undefined, 0.75 + 0.5 * Math.random())
+    // The push is over half a second into a stride; the rest of it is glide.
+    const sincePush = (skater.stride % 1) * strideSeconds(Math.floor(skater.stride))
+    sound.glide(h, Math.exp(-sincePush / 0.5))
+    untilCrack -= dt
+    if (untilCrack <= 0) {
+      // Mostly near, now and then from far down the lake.
+      sound.crack(h, 40 + 560 * Math.random() ** 2, undefined, Math.random() * 2 - 1)
+      untilCrack = 3 + 12 * Math.random()
+    }
+  }
+
   // Each stride pushes off one foot and then the other, so the body sways across the
   // line of travel once every two strides, leans into the sway, and dips at each push.
   const sway = Math.sin(skater.stride * Math.PI)
diff --git a/src/skater.ts b/src/skater.ts
index 91e1b84..7acd544 100644
--- a/src/skater.ts
+++ b/src/skater.ts
@@ -1,4 +1,4 @@
-import type { Lake } from './world/lake.js'
+import { type Lake, mulberry32 } from './world/lake.js'
 
 /** Cruising speed, m/s: about 25 km/h, an easy pace on good ice. */
 export const SPEED = 7
@@ -10,8 +10,21 @@ export const SHORE_MARGIN = 2
 export const IDLE_BEFORE_RETURN = 1.5
 /** Time constant of that easing, seconds. */
 export const RETURN_TIME = 3
-/** Strides per second: one push of one leg. */
-export const STRIDE_HZ = 0.75
+/**
+ * The shortest and longest a stride lasts, seconds: one push of one leg, one strike of a
+ * skate on the ice, and then the glide on it until the other leg pushes.
+ *
+ * The strikes in the film come every 0.66 s (tools/analyse-video.mjs), but skated at
+ * that rate the view never stops rocking from leg to leg. These are long, unhurried
+ * glides instead, and no two the same length.
+ */
+export const MIN_STRIDE = 1.4
+export const MAX_STRIDE = 3.2
+
+/** How long stride number `index` lasts, seconds: the same every time it is asked. */
+export function strideSeconds(index: number): number {
+  return MIN_STRIDE + (MAX_STRIDE - MIN_STRIDE) * mulberry32(index)()
+}
 
 export interface Skater {
   /** Distance skated along the lake, metres. */
@@ -63,5 +76,5 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number): vo
   k.s += (SPEED * dt) / Math.hypot(1, slope)
   // The view turns into a change of direction over a moment rather than snapping to it.
   k.heading += (Math.atan(slope) - k.heading) * (1 - Math.exp(-dt / 0.25))
-  k.stride += STRIDE_HZ * dt
+  k.stride += dt / strideSeconds(Math.floor(k.stride))
 }
diff --git a/test/skater.test.ts b/test/skater.test.ts
index 49d21a7..566bb06 100644
--- a/test/skater.test.ts
+++ b/test/skater.test.ts
@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import {
-  IDLE_BEFORE_RETURN, LATERAL_SPEED, SHORE_MARGIN, SPEED,
-  createSkater, skaterX, stepSkater,
+  IDLE_BEFORE_RETURN, LATERAL_SPEED, MAX_STRIDE, MIN_STRIDE, SHORE_MARGIN, SPEED,
+  createSkater, skaterX, stepSkater, strideSeconds,
 } from '../src/skater.js'
 import { createLake } from '../src/world/lake.js'
 
@@ -58,6 +58,21 @@ describe('skater', () => {
     expect(Math.abs(k.offset)).toBeLessThan(0.01)
   })
 
+  it('takes long strides of varied length, the same ones every time', () => {
+    const lengths = Array.from({ length: 200 }, (_, n) => strideSeconds(n))
+    for (const [n, seconds] of lengths.entries()) {
+      expect(seconds).toBeGreaterThanOrEqual(MIN_STRIDE)
+      expect(seconds).toBeLessThanOrEqual(MAX_STRIDE)
+      expect(strideSeconds(n)).toBe(seconds)
+    }
+    // Varied: they fill the range rather than sitting at one length.
+    expect(Math.max(...lengths) - Math.min(...lengths)).toBeGreaterThan(0.8 * (MAX_STRIDE - MIN_STRIDE))
+    // And the skater takes them at that pace: the first three strides, to the frame.
+    const k = createSkater()
+    run(k, 0, lengths[0]! + lengths[1]! + lengths[2]! + 2 * DT)
+    expect(Math.floor(k.stride)).toBe(3)
+  })
+
   it('skates the same far down the lake as at its start', () => {
     const k = createSkater(1e7)
     run(k, 0, 10)
diff --git a/tools/sound-check.mjs b/tools/sound-check.mjs
new file mode 100644
index 0000000..f7ea2fd
--- /dev/null
+++ b/tools/sound-check.mjs
@@ -0,0 +1,91 @@
+/**
+ * Render twelve seconds of the synthesized ice sound without playing it, and draw its
+ * spectrogram, so that the sound can be looked at beside the film's and a change to it
+ * can be seen as well as heard.
+ *
+ *   npm run sound-check     writes docs/sound/synth.png and docs/sound/synth.wav
+ *
+ * The skater strides as in the app, on ice 4.1 cm thick as in the film for six seconds,
+ * and then onto ice thinning to 2.8 cm, so the note should hold near 740 Hz and then
+ * climb past 1 kHz. Three cracks, 150, 500 and 60 m away. Needs ffmpeg.
+ */
+import { spawnSync } from 'node:child_process'
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { chromium } from 'playwright'
+import { createServer } from 'vite'
+
+const OUT = 'docs/sound'
+const SECONDS = 12
+const RATE = 44100
+
+mkdirSync(OUT, { recursive: true })
+// The dev server, because it serves the source modules one by one and the page can then
+// import the synthesizer by its path.
+const server = await createServer({ server: { port: 4318, strictPort: true }, logLevel: 'error' })
+await server.listen()
+const browser = await chromium.launch()
+let samples
+try {
+  const page = await browser.newPage()
+  await page.goto(server.resolvedUrls.local[0])
+  samples = await page.evaluate(async ({ SECONDS, RATE }) => {
+    const { IceSound } = await import('/src/audio/iceSound.ts')
+    const { mulberry32 } = await import('/src/world/lake.ts')
+    const { strideSeconds } = await import('/src/skater.ts')
+    const ctx = new OfflineAudioContext(2, SECONDS * RATE, RATE)
+    const random = mulberry32(1)
+    const ice = new IceSound(ctx, random)
+    const thickness = (t) => (t < 6 ? 0.041 : 0.041 - (0.013 * (t - 6)) / 6)
+    const strikes = []
+    for (let t = 0.2, n = 0; t < SECONDS; t += strideSeconds(n++)) {
+      strikes.push(t)
+      ice.strike(thickness(t), t, 0.75 + 0.5 * random())
+    }
+    for (let t = 0; t < SECONDS; t += 0.02) {
+      const since = t - (strikes.findLast((s) => s <= t) ?? -Infinity)
+      ice.glide(thickness(t), Math.exp(-since / 0.5), t)
+    }
+    ice.crack(thickness(2.5), 150, 2.5, -0.5)
+    ice.crack(thickness(4.6), 500, 4.6, 0.6)
+    ice.crack(thickness(9.3), 60, 9.3, 0)
+    const rendered = await ctx.startRendering()
+    const left = rendered.getChannelData(0)
+    const right = rendered.getChannelData(1)
+    return Array.from(left, (v, i) => [v, right[i]]).flat()
+  }, { SECONDS, RATE })
+} finally {
+  await browser.close()
+  await server.close()
+}
+
+let peak = 0
+let power = 0
+for (const v of samples) {
+  peak = Math.max(peak, Math.abs(v))
+  power += v * v
+}
+const db = (x) => (20 * Math.log10(x)).toFixed(1)
+console.log(`  peak ${db(peak)} dBFS, average ${db(Math.sqrt(power / samples.length))} dBFS`)
+
+const data = Buffer.alloc(samples.length * 2)
+samples.forEach((v, i) => data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v)) * 32767), i * 2))
+const header = Buffer.alloc(44)
+header.write('RIFF', 0)
+header.writeUInt32LE(36 + data.length, 4)
+header.write('WAVEfmt ', 8)
+header.writeUInt32LE(16, 16)
+header.writeUInt16LE(1, 20)
+header.writeUInt16LE(2, 22)
+header.writeUInt32LE(RATE, 24)
+header.writeUInt32LE(RATE * 4, 28)
+header.writeUInt16LE(4, 32)
+header.writeUInt16LE(16, 34)
+header.write('data', 36)
+header.writeUInt32LE(data.length, 40)
+writeFileSync(`${OUT}/synth.wav`, Buffer.concat([header, data]))
+
+const drawn = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', `${OUT}/synth.wav`, '-lavfi',
+  'aformat=channel_layouts=mono,showspectrumpic=s=1600x600:legend=1:scale=log:fscale=lin:stop=8000',
+  `${OUT}/synth.png`])
+if (drawn.status !== 0) throw new Error(`ffmpeg: ${drawn.stderr}`)
+console.log(`  ${OUT}/synth.wav\n  ${OUT}/synth.png`)