nordic-skating / commit
fix(sound): a crack no longer ends in a sonar ping
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