nordic-skating / mistral-medium-35-littlejs / files / src / ice / acoustics.ts
Branch mistral-medium-35-littlejs · default branch · all branches
acoustics.ts
| 1 | /** |
| 2 | * Why thin ice sings, as numbers. |
| 3 | * |
| 4 | * A sheet of ice floating on water carries bending ("flexural") waves, and they are |
| 5 | * dispersive: the higher the frequency, the faster it travels. Two things follow, and |
| 6 | * they are the two sounds of skating on black ice. |
| 7 | * |
| 8 | * - A crack or the strike of a skate is every frequency at once. Some way off, the high |
| 9 | * ones arrive first and the low ones after: a falling chirp, the "pew" of a laser. |
| 10 | * - Slow bending waves cannot radiate into the air at all. Sound leaves the ice only at |
| 11 | * frequencies whose bending wave travels at least as fast as sound does in air, and |
| 12 | * most strongly right at the frequency where the two speeds match, where the wave in |
| 13 | * the ice and the wave in the air keep step. That frequency is the note the ice sings, |
| 14 | * and it rises as the ice thins: the pitch is a thickness gauge. |
| 15 | * |
| 16 | * The model and its dispersion relation are from Romeyn, Hanssen, Ruud and Johansen, |
| 17 | * "Sea ice thickness from air-coupled flexural waves", The Cryosphere 15, 2939–2955 |
| 18 | * (2021), https://doi.org/10.5194/tc-15-2939-2021 — who measured this very lake from |
| 19 | * the film this app is modelled on: a note of about 725 Hz, from ice about 4.3 cm thick. |
| 20 | */ |
| 21 | |
| 22 | /** Young's modulus of freshwater ice, Pa. */ |
| 23 | export const YOUNGS_MODULUS = 9e9 |
| 24 | export const POISSON_RATIO = 0.33 |
| 25 | /** kg/m³ */ |
| 26 | export const ICE_DENSITY = 917 |
| 27 | export const WATER_DENSITY = 1000 |
| 28 | /** Speed of sound in air at -5 °C, m/s. */ |
| 29 | export const SOUND_SPEED_AIR = 328 |
| 30 | const GRAVITY = 9.81 |
| 31 | |
| 32 | /** How hard a sheet of ice `h` metres thick is to bend, N·m. */ |
| 33 | export function flexuralRigidity(h: number): number { |
| 34 | return (YOUNGS_MODULUS * h ** 3) / (12 * (1 - POISSON_RATIO ** 2)) |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * Angular frequency, rad/s, of the bending wave of wavenumber `k` rad/m in ice `h` metres |
| 39 | * thick on deep water. The water matters: it has to be moved out of the way, which |
| 40 | * weighs the ice down and slows the wave. |
| 41 | */ |
| 42 | export function angularFrequency(k: number, h: number): number { |
| 43 | const stiffness = (flexuralRigidity(h) * k ** 5) / WATER_DENSITY + GRAVITY * k |
| 44 | return Math.sqrt(stiffness / (k * h * (ICE_DENSITY / WATER_DENSITY) + 1)) |
| 45 | } |
| 46 | |
| 47 | /** Speed of a single crest of that wave, m/s. */ |
| 48 | export function phaseSpeed(k: number, h: number): number { |
| 49 | return angularFrequency(k, h) / k |
| 50 | } |
| 51 | |
| 52 | /** Speed at which that wave carries a sound across the ice, m/s. */ |
| 53 | export function groupSpeed(k: number, h: number): number { |
| 54 | const dk = k * 1e-4 |
| 55 | return (angularFrequency(k + dk, h) - angularFrequency(k - dk, h)) / (2 * dk) |
| 56 | } |
| 57 | |
| 58 | /** Find where an increasing function crosses zero between `lo` and `hi`. */ |
| 59 | function solve(f: (x: number) => number, lo: number, hi: number): number { |
| 60 | for (let i = 0; i < 80; i++) { |
| 61 | const mid = (lo + hi) / 2 |
| 62 | if (f(mid) < 0) lo = mid |
| 63 | else hi = mid |
| 64 | } |
| 65 | return (lo + hi) / 2 |
| 66 | } |
| 67 | |
| 68 | /** Wavenumber, rad/m, of the bending wave of frequency `f` Hz in ice `h` metres thick. */ |
| 69 | export function wavenumber(f: number, h: number): number { |
| 70 | const omega = 2 * Math.PI * f |
| 71 | return solve((k) => angularFrequency(k, h) - omega, 1e-3, 1e4) |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * The note the ice sings, Hz: the frequency whose bending wave moves at the speed of |
| 76 | * sound in air. |
| 77 | */ |
| 78 | export function singingFrequency(h: number): number { |
| 79 | // The wave gets faster as it gets shorter, so the match is where the speed crosses. |
| 80 | const k = solve((k) => phaseSpeed(k, h) - SOUND_SPEED_AIR, 0.5, 1e4) |
| 81 | return (SOUND_SPEED_AIR * k) / (2 * Math.PI) |
| 82 | } |
| 83 | |
| 84 | /** The other way round: how thick, in metres, is ice that sings at `f` Hz. */ |
| 85 | export function thicknessFromFrequency(f: number): number { |
| 86 | // Thicker ice sings lower. |
| 87 | return solve((h) => f - singingFrequency(h), 1e-3, 2) |
| 88 | } |
| 89 | |
| 90 | /** Seconds a sound of `f` Hz takes to cross `distance` metres of ice `h` metres thick. */ |
| 91 | export function travelTime(f: number, h: number, distance: number): number { |
| 92 | return distance / groupSpeed(wavenumber(f, h), h) |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * The chirp heard `distance` metres from a crack in ice `h` metres thick: the frequency |
| 97 | * arriving at each moment, at `points` evenly spaced moments from the arrival of `from` |
| 98 | * Hz until the arrival of the ice's own note, which comes last and is where a chirp ends. |
| 99 | */ |
| 100 | export function chirp(h: number, distance: number, from = 6000, points = 64): { duration: number; frequencies: Float32Array } { |
| 101 | // Just above the note itself, where the fall has all but levelled out. |
| 102 | const lowest = singingFrequency(h) * 1.02 |
| 103 | const first = travelTime(from, h, distance) |
| 104 | const duration = travelTime(lowest, h, distance) - first |
| 105 | const frequencies = new Float32Array(points) |
| 106 | for (let i = 0; i < points; i++) { |
| 107 | const t = first + (duration * i) / (points - 1) |
| 108 | // Higher frequencies arrive sooner, so the arrival time falls as the frequency rises. |
| 109 | frequencies[i] = solve((f) => t - travelTime(f, h, distance), lowest, from) |
| 110 | } |
| 111 | return { duration, frequencies } |
| 112 | } |