fuzzyworld

nordic-skating / commit

fix(sound): a crack no longer ends in a sonar ping

5e8b9530d2f2957618b4c0c5d57004c94a88f912 · Pavel Safronov · 2026-10-09 21:58:26 -0700 · parents 2f7e86b70801

Claude Code usage: this commit ~14.4K out / ~5.5M cached · cost unknown (no per-model trailer) · session 1 tally 710 in / 621.6K out / 64.8M cached · usage by commit and session

fix(sound): a crack no longer ends in a sonar ping

The falling chirp of a crack is a sine wave, and on reaching the ice's
note it rang on for a quarter of a second: a pure tone, struck and left
to ring. On the thinnest ice, where cracks are a few metres off and the
fall lasts milliseconds, the ping was all there was to hear.

The sine now stops as it lands, and what rings on is the ice, in the
same narrow band of noise as after a skate strike. The thinnest ice
cracks about once a second at most, down from several times.

Usage: 710 in / 621.6K out / 64.8M cached (session tally)
rtk:   1.7M saved, 25.3% over 2665 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md             |  10 +++++++---
 docs/sound/synth.png  | Bin 1811379 -> 1810428 bytes
 src/audio/iceSound.ts |  36 ++++++++++++++++++++++++++----------
 src/main.ts           |   4 ++--
 4 files changed, 35 insertions(+), 15 deletions(-)

diff --git a/README.md b/README.md
index e36d32b..1014539 100644
--- a/README.md
+++ b/README.md
@@ -139,7 +139,7 @@ Of the ice a skater can reach, about 30% cracks, 35% sings and 35% is safe.
 
 Impossible ice is a setting, and off to begin with. Off, the thinnest ice is 2.2 cm, the
 thinnest that is real, in patches that are on the point of giving way all the way
-across and crack several times a second. On, those patches go on thinning to 8 mm at
+across and crack every second or so. On, those patches go on thinning to 8 mm at
 their middles and fall silent: about 4% of the ice a skater can reach.
 
 Falling through is written and switched off (`RULES` in `src/skater.ts`). Switched on,
@@ -236,8 +236,12 @@ against the film.
   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. On ice thin enough to
-  crack under the skater they come from a few metres away, and faster the thinner it is.
+  and the chirp for that distance and that thickness arrives. The falling chirp is a
+  sine wave and stops the moment it lands on the note; what rings on after it is the
+  ice, in the same band of noise as after a strike. (Left to ring by itself the sine was
+  a clean, struck, high note, like a sonar ping.) On ice thin enough to crack under the
+  skater the cracks come from a few metres away, and faster the thinner it is, up to
+  one every second or so.
   Impossible ice does not crack at all.
 
 `npm run sound-check` renders twelve seconds of it without playing it and draws its
diff --git a/docs/sound/synth.png b/docs/sound/synth.png
index e89dbec..0fc354d 100644
Binary files a/docs/sound/synth.png and b/docs/sound/synth.png differ
diff --git a/src/audio/iceSound.ts b/src/audio/iceSound.ts
index 5cc9dc1..2c93280 100644
--- a/src/audio/iceSound.ts
+++ b/src/audio/iceSound.ts
@@ -5,8 +5,11 @@ import { chirp, singingFrequency } from '../ice/acoustics.js'
  * 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
+/**
+ * Rings that can sound at once. A ring is inaudible after about five ring times, and both
+ * strides and cracks ring.
+ */
+const VOICES = 6
 
 interface Voice {
   filter: BiquadFilterNode
@@ -24,7 +27,10 @@ interface Voice {
  * - **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.
+ *   long falling "pew": high frequencies first, in the order the ice carries them. The
+ *   fall is a sine wave, the one pure tone here, and it is gone as soon as it lands on
+ *   the note: what rings on after it is the ice, in noise, as after a strike. Left to
+ *   ring by itself the sine is a clean, struck, high note — a sonar ping, on a lake.
  *
  * Every method takes the time at which it should happen, so that the same code plays
  * live and renders offline (tools/sound-check.mjs).
@@ -133,18 +139,25 @@ export class IceSound {
     this.scrapeGain.gain.setTargetAtTime(0, when, 0.05)
   }
 
-  /** 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)
+  /**
+   * The ice ringing at `note` Hz from `when`: a narrow band of noise that starts at
+   * `from` times the note, falls onto it within a few hundredths of a second, and dies away.
+   */
+  private ring(note: number, when: number, level: number, from: number): void {
     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.setValueAtTime(note * from, 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.linearRampToValueAtTime(level, when + 0.004)
     gain.gain.setTargetAtTime(0, when + 0.004, RING_TIME)
+  }
+
+  /** A skate striking ice `h` metres thick. `strength` is about 1. */
+  strike(h: number, when = this.ctx.currentTime, strength = 1): void {
+    this.ring(singingFrequency(h), when, 7 * strength, 1.75)
 
     this.tickGain.gain.cancelScheduledValues(when)
     this.tickGain.gain.setValueAtTime(0.12 * strength, when)
@@ -166,11 +179,14 @@ export class IceSound {
     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)
+    // And there the sine ends, within a few hundredths of a second: see above.
+    gain.gain.setTargetAtTime(0, when + duration, 0.015)
     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)
+    tone.stop(when + duration + 0.15)
+    // What rings on is the ice, as it does after a strike, and as loud as the crack was.
+    this.ring(singingFrequency(h), when + duration, 5 * loud, 1.05)
   }
 }
diff --git a/src/main.ts b/src/main.ts
index 5f927cd..1ed6585 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -291,10 +291,10 @@ renderer.setAnimationLoop(() => {
     // The push is over half a second into a stride; the rest of it is glide.
     sound.glide(h, Math.exp(-sincePush(skater) / 0.5))
     // Ice near its limit cracks under the skater: the thinner, the more often and the
-    // nearer, until at the breaking thickness it is cracking several times a second.
+    // nearer, until at the breaking thickness it is cracking every second or so.
     // Thinner than that it is not real ice, and does not crack at all (world/lake.ts).
     const strain = h < BREAKING_THICKNESS ? 0 : Math.max(0, (CRACKING_THICKNESS - h) / (CRACKING_THICKNESS - BREAKING_THICKNESS))
-    untilCrack -= dt * (1 + 40 * strain ** 2)
+    untilCrack -= dt * (1 + 14 * strain ** 2)
     if (untilCrack <= 0) {
       // On sound ice: mostly near, now and then from far down the lake.
       const distance = strain > 0 ? 2 + 30 * (1 - strain) * Math.random() : 40 + 560 * Math.random() ** 2