v2: the pond speaks

Commit 244dd76 · User Name · 2026-10-20T06:53:07+02:00

Changeset
244dd76cee45e79dcc9f9c13b099d85d58e9d92f
Parents
09d5420596245a214667fcf84131c135e4ced006

View source at this commit

v2: the pond speaks

Wire duck-pond's Rust audio stage into quack. The page keeps deciding what every bird does; a wasm Stage in a worker behind an AudioWorklet synthesizes the calls, dabbling, pecks, bread plops and the water bed, with the original distance, delay, head shading and reverb.

All eight call kinds, peck, dabble, plop/tap and the ambient water/reverb are mapped to the actions that make them (docs/audio.md). Sound unlocks on the first gesture, pauses with the pond, and has a headphone toggle and ?audio=0 escape hatch. The additive Rust API is tracked as audio/duck-pond-stage.patch and rebuilt with npm run audio; the built wasm is committed.

Also record the closed audio gaps in the port and divergence docs, and stop describing the pond as silent.

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 333b811..f8c6a7a 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,11 @@ quack is a port of [celphase's duck-pond](https://github.com/celphase/duck-pond)
 a high-quality duck *audio* synthesizer written in Rust — from sound to sight.
 The ducks live the same lives: the time budget of dabbling, resting and preening,
 the depth bands they drift between, broods trailing their mother, a drake
-escorting his mate. The quacks are silent (no audio yet), but the calling model is
-here: the same call timers, social answers and brood recalls, spoken in posture
-and emitted as `CallEvent`s for the coming audio pass.
+escorting his mate. They also found their voices: the vendored Rust stage runs in
+a worker behind an AudioWorklet, so every call the flock decides on is
+synthesized, placed in the listener's frame, delayed and reverbed in the same
+core as the original pond. Bread, dabbling and the water bed are wired in too;
+[docs/audio.md](docs/audio.md) maps every sound to the action that now makes it.
 
 ## Run
 
@@ -40,9 +42,12 @@ Production builds always write source maps. The fifth title card points at
 | a press that drifts past 12 px | that was a camera drag, not a throw |
 | cursor (fine pointer) | every duck's head follows it across the screen |
 
-Hidden conveniences for whoever opens the console: `space` pauses, `T` runs the
-clock at a quarter speed, `Y` skips a season, and `1`–`4` pin the year at
-spring / summer / autumn / winter.
+Sound follows the first press or key, as browser autoplay rules require; a tap
+to throw bread is usually all it takes. `?audio=0` keeps the pond silent, and
+`?headphones=1` starts in headphone rendering. Hidden conveniences for whoever
+opens the console: `space` pauses (and holds the sound), `T` runs the clock at a
+quarter speed, `H` toggles headphone rendering, `Y` skips a season, and `1`–`4`
+pin the year at spring / summer / autumn / winter.
 
 ## The pond
 
@@ -78,28 +83,35 @@ src/pond.ts         the flock: roster, time budget, families, bread and begging
 src/duck.ts         duck rig and poses
 src/camera.ts       first-person rig: a planted eye, turning and zooming
 src/effects.ts      ripples, splashes, dust and sinking particles
+src/audio.ts        the sound stage: event plumbing, listener frame, worklet
 src/hints.ts        the title cards: controls, credit, source
 src/utils.ts        RNG, value noise, easing, geometry helpers
 vite.config.ts      production build shape: source maps always on
-public/             loading-card stills, generated source zip
+public/             loading-card stills, audio worker/worklet, wasm, generated source zip
 scripts/shot.mjs    puppeteer screenshot harness used while tuning
 scripts/sample.mjs  reads a shot back as a colour column, for tuning
 scripts/source-zip.mjs  packs the source-card fallback from the working tree
 scripts/loader-shot.mjs  re-shoots the card's backdrop and packs it to JPEG
+scripts/build-audio.mjs  rebuilds the wasm stage from the vendored clone
+audio/duck-pond-stage.patch  the additive Rust API this repo's audio needs
 docs/port-notes.md  port decisions, tuned constants, debug recipes
+docs/audio.md       sound by sound: what the flock does and the core hears
 docs/deploy.md      build, deploy, rollback, loading-card re-shoots
 ```
 
 Two folders on the side, deliberately kept out of this repo's history:
 
 - `duck-pond/` — the vendored Rust clone and the study notes behind the port.
+  `scripts/build-audio.mjs` rebuilds `public/quack_audio.wasm` from it; the
+  small stage API added there is kept in `audio/duck-pond-stage.patch`.
 - `temp/` — scratch space (screenshots and such), git-ignored.
 
 ## Credits
 
 - **[duck-pond](https://github.com/celphase/duck-pond)** by celphase (MIT):
   the behavior model, time budgets, family rules and speeds are ported from
-  its `scene.rs`.
+  its `scene.rs`, and the Rust synthesizer, propagation and reverb run
+  unchanged in `public/quack_audio.wasm`.
 - **[three.js](https://threejs.org)** (MIT): everything you see.
 
 ## License
diff --git a/audio/duck-pond-stage.patch b/audio/duck-pond-stage.patch
new file mode 100644
index 0000000..3494733
--- /dev/null
+++ b/audio/duck-pond-stage.patch
@@ -0,0 +1,376 @@
+diff --git a/src/scene.rs b/src/scene.rs
+index e31a388..50db785 100644
+--- a/src/scene.rs
++++ b/src/scene.rs
+@@ -1292,6 +1292,243 @@ impl Scene {
+     }
+ }
+ 
++// --- stage: the audio core without a second flock --------------------------
++//
++// quack's pond is the authority on who is where and what they are doing. The
++// browser wrapper therefore does not want another Scene with its own ducks and
++// its own calls: it wants the synthesis, propagation and spatialisation of
++// `Scene`, fed by sounds the TypeScript bird has already decided on. `Stage` is
++// that half of the pond. Its coordinates are the listener's frame - +y straight
++// ahead, +x to the right, metres - the same frame `spatialize` has always used.
++
++/// One duck as the audio core needs to know it: a voice and a place.
++struct StageDuck {
++    voice: (f64, f64),
++    pos: Vec2,
++}
++
++/// The sound half of the pond, driven from outside. One instance lives in the
++/// browser's audio worker.
++pub struct Stage {
++    sr: f64,
++    synth: Synth,
++    rng: StdRng,
++    ducks: Vec<StageDuck>,
++    voices: Vec<Voice>,
++    water: Water,
++    reverb: Reverb,
++    bubble_timer: f64,
++    frame: u64,
++    mix: Vec<[f64; 2]>,
++    send: Vec<f64>,
++    headphones: bool,
++}
++
++impl Stage {
++    /// Number of duck slots the browser can drive. It mirrors quack's fixed
++    /// flock; the wrapper fills in the voices and positions it knows.
++    pub const DUCKS: usize = 12;
++
++    pub fn new(sr: f64, mut rng: StdRng, headphones: bool) -> Self {
++        let synth = Synth::new(sr, StdRng::seed_from_u64(rng.random()));
++        let water = Water::new(sr, &mut rng);
++        let ducks = (0..Self::DUCKS)
++            .map(|i| StageDuck { voice: (430.0, 2300.0), pos: Vec2::new(0.0, 10.0 + i as f64) })
++            .collect();
++        Stage {
++            sr,
++            synth,
++            rng,
++            ducks,
++            voices: Vec::new(),
++            water,
++            reverb: Reverb::new(sr),
++            bubble_timer: 0.0,
++            frame: 0,
++            mix: Vec::new(),
++            send: Vec::new(),
++            headphones,
++        }
++    }
++
++    /// Set the call voice of duck `i`: hen `(f0, f2)`, drake `(rhaeb, _)`,
++    /// duckling `(peep top, _)`.
++    pub fn set_voice(&mut self, i: usize, voice: (f64, f64)) {
++        if let Some(d) = self.ducks.get_mut(i) {
++            d.voice = voice;
++        }
++    }
++
++    /// Place duck `i` in the listener's frame.
++    pub fn set_pos(&mut self, i: usize, pos: Vec2) {
++        if let Some(d) = self.ducks.get_mut(i) {
++            d.pos = pos;
++        }
++    }
++
++    pub fn set_headphones(&mut self, headphones: bool) {
++        self.headphones = headphones;
++    }
++
++    /// A call decided by quack's own calling model. `kind` is the CallKind
++    /// order in `src/duck.ts`: quack, series, decrescendo, chatter, brood,
++    /// rhaeb, peeps, contact. `duration` is the event's natural length, used
++    /// only by the note generators that do not fix their own spacing.
++    pub fn call(&mut self, i: usize, kind: u32, notes: usize, duration: f64) {
++        let Some(duck) = self.ducks.get(i) else { return };
++        let (f0, f1) = duck.voice;
++        let (samples, level) = match kind {
++            0 => {
++                // a single quack: the event carries the note's own 0.2-0.24 s
++                let dur = (duration - 0.03).clamp(0.2, 0.24);
++                let rasp = self.rng.random_range(0.4..0.6);
++                (self.synth.quack(dur, f0, f1, rasp), QUACK)
++            }
++            1 => (self.synth.series(notes.clamp(2, 3), f0, f1), SERIES),
++            2 => (self.synth.decrescendo(notes.clamp(5, 7), f0, f1), DECRESCENDO),
++            3 => {
++                let seconds = (duration - 0.2).clamp(1.5, 3.5);
++                (self.synth.chatter(seconds, f0, f1), CHATTER)
++            }
++            4 => (self.synth.brood_call(notes.clamp(3, 10), f0, f1), BROOD_CALL),
++            5 => {
++                // the drake's notes are spaced to fill the event; his voice is f1
++                let calls = notes.clamp(1, 3);
++                let gap = ((duration - 0.2) / calls as f64).clamp(0.9, 1.4);
++                (self.synth.drake(calls, gap, f0), RHAEB)
++            }
++            6 => {
++                let seconds = (duration - 0.2).clamp(1.0, 2.5);
++                (self.synth.peeping(seconds, f0), PEEPS)
++            }
++            7 => (self.synth.contact(notes.clamp(2, 5), f0), CONTACT),
++            _ => return,
++        };
++        self.emit(Source::Duck(i), samples, level);
++    }
++
++    /// A spell of dabbling on the water, from the duck's current place.
++    pub fn dabble(&mut self, i: usize, seconds: f64) {
++        if self.ducks.get(i).is_none() {
++            return;
++        }
++        let samples = self.synth.dabbling(seconds.clamp(0.5, 7.0));
++        self.emit(Source::Duck(i), samples, DABBLING);
++    }
++
++    /// A bite of bread: bill clacks, a splash on open water, and a gulp.
++    pub fn peck(&mut self, i: usize, on_water: bool, duckling: bool) {
++        if self.ducks.get(i).is_none() {
++            return;
++        }
++        let samples = self.synth.peck(on_water);
++        self.emit(Source::Duck(i), samples, if duckling { DUCKLING_PECK } else { PECK });
++    }
++
++    /// A small fixed sound at a point in the listener's frame: kind 0 is a
++    /// water plop, kind 1 a tap on the bank or the ice. `gain` scales the
++    /// rust level, for a bread crust bigger or smaller than a pea.
++    pub fn fixed(&mut self, kind: u32, pos: Vec2, gain: f64) {
++        let (samples, level) = match kind {
++            0 => (self.synth.bubble(), PEA_PLOP),
++            1 => (self.synth.tap(), PEA_TAP),
++            _ => return,
++        };
++        // the Rust pea arrives at 0.6-1.0 of its nominal level
++        let arrival = self.rng.random_range(0.6..1.0);
++        self.emit(Source::Fixed(pos), samples, level * arrival * gain.clamp(0.0, 8.0));
++    }
++
++    /// Advance the stage by `out.len()` frames and render them.
++    pub fn render(&mut self, out: &mut [[f32; 2]]) {
++        let n = out.len();
++        self.ambient(n as f64 / self.sr);
++
++        let Stage { sr, ducks, voices, mix, send, headphones, .. } = self;
++        mix.clear();
++        mix.resize(n, [0.0; 2]);
++        send.clear();
++        send.resize(n, 0.0);
++        let shade = 1.0 - (-2.0 * SPEED_OF_SOUND / HEAD_RADIUS / *sr).exp();
++        for v in voices.iter_mut() {
++            let pos = match v.source {
++                Source::Duck(i) => ducks[i].pos,
++                Source::Fixed(p) => p,
++            };
++            let to = spatialize(pos, v.level, *sr, *headphones);
++            let from = v.gains.unwrap_or(to);
++            v.gains = Some(to);
++            for k in 0..n {
++                if v.delay > 0 {
++                    v.delay -= 1;
++                    continue;
++                }
++                let t = k as f64 / n as f64;
++                let lerp = |a: f64, b: f64| a + (b - a) * t;
++                let lowpass = lerp(from.lowpass, to.lowpass);
++                let x = v.samples.get(v.at).copied().unwrap_or(0.0);
++                v.lowpassed += lowpass * (x - v.lowpassed);
++                send[k] += v.lowpassed * lerp(from.send, to.send);
++                for (c, ear) in v.ears.iter_mut().enumerate() {
++                    let (a, b) = (from.ears[c], to.ears[c]);
++                    let heard = if *headphones {
++                        let x = sample_at(&v.samples, v.at as f64 - lerp(a.delay, b.delay));
++                        ear.lowpassed += lowpass * (x - ear.lowpassed);
++                        ear.shaded += shade * (ear.lowpassed - ear.shaded);
++                        let bright = lerp(a.bright, b.bright);
++                        bright * ear.lowpassed + (1.0 - bright) * ear.shaded
++                    } else {
++                        v.lowpassed
++                    };
++                    mix[k][c] += heard * lerp(a.gain, b.gain);
++                }
++                v.at += 1;
++            }
++        }
++        voices.retain(|v| {
++            let lag = v.gains.map_or(0.0, |g| g.ears[0].delay.max(g.ears[1].delay));
++            (v.at as f64) < v.samples.len() as f64 + lag + 1.0
++        });
++
++        for k in 0..n {
++            let wet = self.reverb.tick(self.send[k]);
++            let water = self.water.tick(&mut self.rng);
++            let fade_in = smoothstep((self.frame + k as u64) as f64 / (0.5 * self.sr));
++            for c in 0..2 {
++                let x = (self.mix[k][c] + wet[c] + water[c]) * MASTER * fade_in;
++                out[k][c] = soft_limit(x) as f32;
++            }
++        }
++        self.frame += n as u64;
++    }
++
++    /// Drips and bubbles, mostly near the listener, as in the live pond. They
++    /// are fixed in the listener's frame: the camera only turns in place, so a
++    /// short bubble does not have time to drift.
++    fn ambient(&mut self, dt: f64) {
++        self.bubble_timer -= dt;
++        if self.bubble_timer > 0.0 {
++            return;
++        }
++        self.bubble_timer = self.rng.random_range(0.15..0.7);
++        let band = if self.rng.random_bool(0.7) { (3.0, 8.0) } else { (3.0, 46.0) };
++        let bearing: f64 = self.rng.random_range(-1.2..1.2);
++        let pos = Vec2::new(bearing.sin(), bearing.cos()) * self.rng.random_range(band.0..band.1);
++        let level = self.rng.random_range(0.04..0.1);
++        let samples = self.synth.bubble();
++        self.emit(Source::Fixed(pos), samples, level);
++    }
++
++    fn emit(&mut self, source: Source, samples: Vec<f64>, level: f64) {
++        let pos = match source {
++            Source::Duck(i) => self.ducks[i].pos,
++            Source::Fixed(p) => p,
++        };
++        let delay = (pos.len() / SPEED_OF_SOUND * self.sr) as usize;
++        self.voices.push(Voice { samples, at: 0, delay, source, level, lowpassed: 0.0, ears: Default::default(), gains: None });
++    }
++}
++
+ /// Three hens (two with ducklings), a drake escorting his mate, and a lone drake.
+ fn flock(rng: &mut StdRng) -> Vec<Duck> {
+     let mut ducks = Vec::new();
+diff --git a/web/src/lib.rs b/web/src/lib.rs
+index f30e0bb..1dd318c 100644
+--- a/web/src/lib.rs
++++ b/web/src/lib.rs
+@@ -8,7 +8,7 @@
+ use std::cell::RefCell;
+ use std::fmt::Write;
+ 
+-use duck_pond::scene::{Command, Kind, Scene};
++use duck_pond::scene::{Command, Kind, Scene, Stage, Vec2};
+ use rand::SeedableRng;
+ use rand::rngs::StdRng;
+ 
+@@ -23,14 +23,26 @@ struct Pond {
+     snapshot: String,
+ }
+ 
++struct StagePond {
++    stage: Stage,
++    /// The last block rendered, left channel then right.
++    audio: Vec<f32>,
++    frames: Vec<[f32; 2]>,
++}
++
+ thread_local! {
+     static POND: RefCell<Option<Pond>> = const { RefCell::new(None) };
++    static STAGE: RefCell<Option<StagePond>> = const { RefCell::new(None) };
+ }
+ 
+ fn with_pond<T>(f: impl FnOnce(&mut Pond) -> T) -> T {
+     POND.with_borrow_mut(|pond| f(pond.as_mut().expect("pond_new has not been called")))
+ }
+ 
++fn with_stage<T>(f: impl FnOnce(&mut StagePond) -> T) -> T {
++    STAGE.with_borrow_mut(|pond| f(pond.as_mut().expect("quack_audio_new has not been called")))
++}
++
+ /// Frames in each block `pond_render` makes.
+ #[unsafe(no_mangle)]
+ pub extern "C" fn pond_block() -> u32 {
+@@ -111,3 +123,87 @@ pub extern "C" fn pond_snapshot() -> u32 {
+ pub extern "C" fn pond_snapshot_ptr() -> *const u8 {
+     with_pond(|pond| pond.snapshot.as_ptr())
+ }
++
++// --- the audio stage quack drives ------------------------------------------
++//
++// quack already owns the flock and its calling model. These exports take the
++// sounds it decides on, plus the ducks' places in the listener's frame, and
++// return blocks from the same synthesizer, propagation and reverb as the pond
++// above. They are deliberately separate from the pond_* exports so the page
++// can use this module as an instrument rather than a second simulation.
++
++/// Frames in each block `quack_audio_render` makes.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_block() -> u32 {
++    BLOCK as u32
++}
++
++/// How many duck slots the stage has.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_ducks() -> u32 {
++    Stage::DUCKS as u32
++}
++
++/// Start the stage at `sample_rate` (at least 16 kHz), with the seed split into
++/// two halves as JS numbers cannot hold it.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_new(sample_rate: f64, seed_hi: u32, seed_lo: u32, headphones: u32) {
++    let seed = (seed_hi as u64) << 32 | seed_lo as u64;
++    let stage = Stage::new(sample_rate.max(16000.0), StdRng::seed_from_u64(seed), headphones != 0);
++    let audio = vec![0.0; 2 * BLOCK];
++    let frames = vec![[0.0; 2]; BLOCK];
++    STAGE.set(Some(StagePond { stage, audio, frames }));
++}
++
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_set_voice(i: u32, first: f64, second: f64) {
++    with_stage(|pond| pond.stage.set_voice(i as usize, (first, second)));
++}
++
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_set_pos(i: u32, x: f64, y: f64) {
++    with_stage(|pond| pond.stage.set_pos(i as usize, Vec2::new(x, y)));
++}
++
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_set_headphones(headphones: u32) {
++    with_stage(|pond| pond.stage.set_headphones(headphones != 0));
++}
++
++/// A call from quack's own model: kind in `CallKind` order, notes, duration.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_call(i: u32, kind: u32, notes: u32, duration: f64) {
++    with_stage(|pond| pond.stage.call(i as usize, kind, notes as usize, duration));
++}
++
++/// A spell of dabbling on the water.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_dabble(i: u32, seconds: f64) {
++    with_stage(|pond| pond.stage.dabble(i as usize, seconds));
++}
++
++/// One bite of bread; `duckling` only changes the level.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_peck(i: u32, on_water: u32, duckling: u32) {
++    with_stage(|pond| pond.stage.peck(i as usize, on_water != 0, duckling != 0));
++}
++
++/// A fixed sound at a point: kind 0 water plop, kind 1 bank/ice tap.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_fixed(kind: u32, x: f64, y: f64, gain: f64) {
++    with_stage(|pond| pond.stage.fixed(kind, Vec2::new(x, y), gain));
++}
++
++/// Render the next block and return where it is: `quack_audio_block()` frames
++/// for the left channel, then as many for the right.
++#[unsafe(no_mangle)]
++pub extern "C" fn quack_audio_render() -> *const f32 {
++    with_stage(|pond| {
++        pond.stage.render(&mut pond.frames);
++        let (left, right) = pond.audio.split_at_mut(BLOCK);
++        for (k, [l, r]) in pond.frames.iter().enumerate() {
++            (left[k], right[k]) = (*l, *r);
++        }
++        pond.audio.as_ptr()
++    })
++}
diff --git a/docs/audio.md b/docs/audio.md
new file mode 100644
index 0000000..71f211d
--- /dev/null
+++ b/docs/audio.md
@@ -0,0 +1,101 @@
+# The sound of quack
+
+quack's flock is the authority on behavior: `src/pond.ts` decides who calls,
+who answers, who dabbles and who gets the bread. The sound is the vendored
+[duck-pond](https://github.com/celphase/duck-pond) Rust core, compiled to
+`public/quack_audio.wasm`, running in a Web Worker and played through an
+AudioWorklet. It is the same synthesis, distance attenuation, speed-of-sound
+delay, head shading and Freeverb as the original pond; only the birds are ours.
+
+The bridge lives in `src/audio.ts`; the browser half is
+`public/quack-audio-worker.js` and `public/quack-audio-worklet.js`.
+
+## What makes which sound
+
+`duck-pond/sounds.md` is the exhaustive sound reference. This is the mapping
+from those sounds to what quack's ducks actually do.
+
+| Rust sound | Trigger in quack | Where |
+| --- | --- | --- |
+| `quack` | a hen's single call | `Pond.onCall` → `AudioCore.call` |
+| `quack quack` | a hen's 2–3 note series, the social answer chain's start | same |
+| `QUACK quack quack` | a hen's 5–7 note decrescendo, also social | same |
+| `wak-wak-wak` | a hen feeding (or a dabble lottery) chats to herself | same |
+| `kwek-kwek` | a hen gathering her brood, or calling lost young back | same |
+| `rhaeb` | a drake's 1–3 mutters, sooner when his mate is out of reach | same |
+| `peep peep` | a duckling that has lost its mother, distress whistles | same |
+| `pip-pip` | a duckling keeping in touch with the brood | same |
+| dabbling | an adult starts a spell of dabbling on the water | `Duck.onDabble` |
+| peck | a bill lands on a piece of bread; splash only when afloat | `Pond.onPeck` |
+| pea plop | a thrown piece lands on open water | `Pond.onBreadLand` |
+| pea tap | a thrown piece lands on the bank or the winter lid | `Pond.onBreadLand` |
+| drip / bubble | random points around the listener, 3–46 m out | `Stage::ambient` |
+| water bed | continuous, mixed without spatialisation | `Stage::render` |
+| reverb | continuous, fed by every voice | `Stage::render` |
+
+Two notes on the mapping:
+
+- The Rust sound list has no entry for flight, begging, upending, drinking or
+  preening, so quack's new activities are silent except where they trip an
+  existing sound. A duck's bill on the ice makes the same pecks as a bill on
+  water, minus the splash; a dabble ashore (grazing) is silent.
+- Peas became bread. The plop and tap keep the pea synthesis, scaled by
+  `AUDIO.bread` because a crust is much bigger than a pea and has to read
+  across the pond.
+
+## How a call reaches the speaker
+
+1. `Pond.call` builds a `CallEvent` — `kind`, `notes`, `duration` — and shows
+   the head/bill gesture. The call model (timers, lottery, answers, brood
+   recall) is quack's; the sound is not.
+2. `AudioCore.call` converts the duck's world position into the listener's
+   frame: `+y` straight ahead, `+x` to the right, metres, from the camera's
+   planted eye. It sends the event with that place.
+3. The worker sets the duck's position in the Rust `Stage` and calls
+   `quack_audio_call`, which picks the note generator from the `CallKind` and
+   the duck's voice:
+   - `quack`: the event's duration minus the synth's envelope tail;
+   - `series`, `decrescendo`, `brood`, `contact`: the note count;
+   - `chatter`, `peeps`: the event's duration minus the 0.2 s tail;
+   - `rhaeb`: the note count and the gap that fills the event.
+4. The stage emits a `Voice` from the duck's place, with the Rust level for
+   that sound, and the block render applies delay, attenuation, air/head
+   filtering, panning and reverb.
+5. Rendered blocks (512 frames, planar stereo) go to the worklet, which keeps
+   `AUDIO.queue` blocks (8 — about 85 ms) ahead of playback. A call that
+   arrives while the queue is full is picked up by the next block rendered, so
+   the sound trails the gesture by roughly that queue.
+
+## Controls and plumbing
+
+- Sound starts on the first press or key, as browser autoplay rules require.
+- `?audio=0` builds no audio core at all; the pond is silent and `Pond.onCall`
+  is left free for probes. `?headphones=1` (or `H`) switches the stage to the
+  spherical-head render.
+- `space` suspends the AudioContext with the simulation; resuming continues
+  the same queued blocks. `H` toggles headphones; the choice is remembered in
+  `localStorage`.
+- `AUDIO` in `src/config.ts` holds the queue depth, position-sync period,
+  output gain and the bread plop/tap gains. Everything else is the original
+  Rust constant.
+- The stage exports: `quack_audio_new`, `quack_audio_set_voice`,
+  `quack_audio_set_pos`, `quack_audio_set_headphones`, `quack_audio_call`,
+  `quack_audio_dabble`, `quack_audio_peck`, `quack_audio_fixed`,
+  `quack_audio_render`, `quack_audio_block`, `quack_audio_ducks`.
+
+## Rebuilding the wasm
+
+The vendored clone is kept out of this repo's history. The small API the
+browser needs — `Stage` in `src/scene.rs` and the `quack_audio_*` exports in
+`web/src/lib.rs` — is stored as a patch:
+
+```sh
+git -C duck-pond apply audio/duck-pond-stage.patch
+npm run audio            # cargo build --release --target wasm32-unknown-unknown
+                         # then copies to public/quack_audio.wasm
+```
+
+`npm run audio` checks that the patch has been applied and tells you if not.
+`public/quack_audio.wasm` is committed, so a clone without the Rust clone can
+still build and deploy the page. The stage is duck-pond's MIT-licensed code;
+see the credit in the README.
diff --git a/docs/behavior-divergence.md b/docs/behavior-divergence.md
index 7f3f481..c499d5e 100644
--- a/docs/behavior-divergence.md
+++ b/docs/behavior-divergence.md
@@ -14,16 +14,19 @@ pond, `duck-pond/src/scene.rs`), asking three questions:
 file is the *balance sheet* — what came across, what did not, what was added,
 and what changed for a reason.
 
-Both projects are read-only for this document; nothing was modified.
+The comparison treats both projects as read-only. quack's only change to the
+Rust tree is the additive audio `Stage` described below; all other claims are
+about the original.
 
-**Update.** The calling model has since been ported as behaviour: the nine
+**Update.** The calling model was ported as behaviour first: the nine
 intervals, sleeping suppression, hen lottery, brood calls and one-hop answer
-chain now live in `src/pond.ts`, tuned by `CALLS` in `src/config.ts`. The
-synthesis, propagation and spatialization are still absent, so calls are silent
-head/bill gestures plus `CallEvent`s on `Pond.onCall` for the audio core to
-come. Sections 2.1–2.4 and 4.4 have been rewritten to record what closed; the
-remaining audio gap is §2.5 and the dropped appendix. Line numbers in those
-rewritten sections are from the tree before the call port.
+chain live in `src/pond.ts`, tuned by `CALLS` in `src/config.ts`. The audio
+stack has since followed (v2): the vendored Rust synthesis, propagation and
+spatialization run from `src/audio.ts`, so the same `CallEvent`s that opened a
+silent head/bill gesture now also play. `docs/audio.md` maps every Rust sound
+to the quack action that makes it. Sections 2.1–2.5 and 4.4 have been rewritten
+to record what closed; the dropped appendix keeps the sound-by-sound list. Line
+numbers in those rewritten sections are from the tree before the call port.
 
 **Conventions.** quack's own files are always written with their `src/` or
 `docs/` prefix (`src/pond.ts:611`). The Rust originals are quoted by their
@@ -42,7 +45,7 @@ too in its comments, and the Rust *docs* use "behavior" in the README.
 | --- | --- | --- |
 | Activities | 6: `Swim Dabble Rest Preen Stray Feed` | 8: `swim dabble rest preen stray feed` **+ `beg` `fly`** |
 | Sub-actions (poses) | none — a duck is a letter in a map | 12: `sleep stretch look idle preen peck dabble upend drink chirp beg` (+ `startle`, dead) |
-| Calling model | 9 inter-call intervals, a 4-way call lottery, a social flag, a one-hop answer chain | **ported** — the same timing, lottery and chain, silent gestures and `CallEvent`s for the missing synth |
+| Calling model | 9 inter-call intervals, a 4-way call lottery, a social flag, a one-hop answer chain | **ported** — the same timing, lottery and chain; the same Rust voices, now wired to the stage |
 | Food | 12–20 peas per throw, one peck each | one crust per throw, 2–3 bites, plus begging |
 | Water | one medium, deep enough to swim | three: swim (0.3 m), wade (0.05 m), walk the ice lid (0.5 frozen) |
 | Year | none — a timeless breeding pond | four seasons, 28 min a year, ice from every edge |
@@ -53,23 +56,24 @@ too in its comments, and the Rust *docs* use "behavior" in the README.
 
 The **decision structure came across essentially intact**. Every transition
 weight, every bout length, every family rule and every speed constant is
-present in the TS with the same value. The timing of the *vocal* half is now
-present too; only its sound is missing. What is new is a whole visual and
-seasonal layer.
+present in the TS with the same value. The vocal half is present too, in
+timing *and* sound: a call is both a head/bill gesture and a `CallEvent` on
+`Pond.onCall`, and `AudioCore` sends that event to the Rust stage. What is new
+is a whole visual and seasonal layer.
 
 ---
 
 ## 2. Part A — The calling model and remaining gaps
 
-### 2.1 The calling model — ported, silent for now
+### 2.1 The calling model — ported, and speaking
 
 The Rust's `Scene::call()` (`scene.rs:1189-1292`) is roughly a third of the
-whole simulation. **Its timing half is now ported** into `src/pond.ts`
+whole simulation. **Its timing half is ported** into `src/pond.ts`
 (`tickCalls`, `callInterval`, `call`, `showCall`), tuned by `CALLS` in
-`src/config.ts`. What remains absent is the synthesis, propagation and
-spatialization: quack turns each call into a silent head/bill gesture and emits
-a `CallEvent` — `{ kind, label, notes, duration, social, answering }` — on
-`Pond.onCall`, with the caller attached.
+`src/config.ts`. Its sound half is the Rust itself: the wasm `Stage` adds a
+manual layer to `scene.rs` (kept as `audio/duck-pond-stage.patch`), and
+`AudioCore` in `src/audio.ts` feeds it each `CallEvent` — `{ kind, label,
+notes, duration, social, answering }` — with the caller and its voice.
 
 The ported pieces:
 
@@ -85,12 +89,12 @@ The ported pieces:
 | The hen's brood call and its 3.7 notes/s rate | `scene.rs:1229-1233`, `synth.rs:518-530` | **ported** — `CALLS.broodNotes`, `CallEvent.duration` |
 
 `CallEvent.duration` uses the same note-count formulas as `synth.rs`, so the
-future audio core can start a call at the right moment and fill in the same
-envelope. The old decision from `docs/port-notes.md` still holds for the sound
-itself — *"deliberately not ported for v1: the whole audio stack"* — but the
-timing, social chain and brood recall are behaviour, and they are now in the
-pond. The mirror rule (§3.12) is no longer the only source of the pond's
-lively-and-drowsy stretches.
+stage can start a call at the right moment and fill in the same envelope — the
+`kind`/`notes`/`duration` triple is exactly the call's `synth.rs` recipe. The
+gesture and the sound are offset only by the worker queue (`AUDIO.queue`, about
+85 ms). The timing, social chain and brood recall are behaviour, and they are
+in the pond; the voices are the vendored Rust. The mirror rule (§3.12) is no
+longer the only source of the pond's lively-and-drowsy stretches.
 
 ### 2.2 `gathering` — now read by the brood call
 
@@ -100,10 +104,10 @@ read by the brood branch of `call()`. A gathering hen gives the 3–6-note
 *stray* duckling back to her. The flag is cleared when the call fires, so one
 gathering makes one call.
 
-The visible gesture is the same silent `chirp` head/bill lift used by every
-call. It deliberately does not borrow the `beg` pose: `beg` is an activity
-pose, and starting it from the call would interrupt whatever the hen was
-really doing.
+The visible gesture is the same `chirp` head/bill lift used by every call; the
+sound is the stage's brood call beside it. It deliberately does not borrow the
+`beg` pose: `beg` is an activity pose, and starting it from the call would
+interrupt whatever the hen was really doing.
 
 ### 2.3 Two duckling states — both present
 
@@ -135,13 +139,24 @@ feed-time `cue` remains as a safety net; the call is now the normal way home.
 The Rust schedules a **sound** for dabbling — a 2–7 s patter of tiny splashes
 every 3–15 s while an adult feeds on the water (`scene.rs:884-886`,
 `950-960`) — and a clack-and-swallow when a duck eats a pea
-(`scene.rs:897-903`). These are audio, so they cannot port directly.
-
-But the Rust's dabbling rule carries a behavioural rule that *is* portable and
-was not carried: **only a duck on the water dabbles audibly; a duck grazing
-ashore is silent** (`scene.rs:884`). The TS reproduces the *visual* half
-correctly — a duck on the bank pecks at the grass rather than dabbling
-(`src/duck.ts:892-897`) — so this one is covered, but by a different mechanism.
+(`scene.rs:897-903`). These are now wired rather than dropped:
+
+- `Duck.startAction('dabble')` emits `onDabble(d, seconds)`; `Pond` forwards it
+  to `AudioCore.dabble`, which calls the Rust `dabbling` generator at the
+  duck's place. Only an adult on water starts a `dabble` action; grazing on the
+  bank selects `peck`/`look`/`idle` instead, so the Rust's "ashore is silent"
+  rule survives by construction (`src/duck.ts:925-935`).
+- A bill that lands on bread fires `Duck.onPeck`; `Pond.peck` forwards it only
+  when the duck is actually eating (`d.eating > 0`), with `onWater` and a
+  duckling flag. Grazing pecks are silent. The Rust `peck` generator makes the
+  clacks, the splash/lid difference and the gulp.
+- The pea plop/tap is the bread landing: `Pond.onBreadLand` sends the Rust
+  `bubble` or `tap` at the landing point, with `AUDIO.bread` gain. The Rust's
+  floating-pea ring has no audio counterpart; only the landing does.
+
+The behavioural rule is still reproduced: a duck on the bank pecks at the grass
+rather than dabbling (`src/duck.ts:925-935`), and its pecks on bank food are
+the dry `peck`, not a splash.
 
 ### 2.6 The Rust's "restore" bug — and the port's fix
 
@@ -221,8 +236,8 @@ frame-for-frame.
 ## 3. Part B — Creative freedom
 
 Everything in this part is behaviour quack has that duck-pond never had. Some
-of it is invention; some of it is the visual layer doing work the Rust left
-to the audio layer.
+of it is invention; some of it is the visual layer doing work in a 3-D scene
+that the Rust did in 2-D and in sound.
 
 ### 3.1 Two new activities
 
@@ -401,9 +416,9 @@ test at `src/duck.ts:1260`. But `startAction('startle'` appears **nowhere**.
 It is dead code inherited from the bird rig the port came from.
 
 It is still unwired. The new `CallEvent`s give it an obvious trigger later — a
-nearby answer could startle a duck once there is a loudness model — but without
-audio there is no "loud" yet, so the pose stays dormant rather than firing at
-random.
+nearby answer could startle a duck once there is an attention model for the
+mix — but the stage only exposes levels, not a "this was loud to *this* duck"
+signal, so the pose stays dormant rather than firing at random.
 
 ---
 
@@ -543,7 +558,7 @@ There is no behavioural difference between spring, summer and autumn.
 | 17 | a flight landing | a splash | a puff of snow | `src/pond.ts:463-468` |
 | 18 | a nibble | a ripple | dust, and no water stirred | `src/pond.ts:1085-1086` |
 | 19 | bread landing | a splash | dust | `src/pond.ts:1531-1536` |
-| 20 | bread on the lid | floats, bobs, rings | grounded, still, silent | `src/pond.ts:1542-1563` |
+| 20 | bread on the lid | floats, bobs, rings | grounded, still, dry tap | `src/pond.ts:1542-1563` |
 
 ### 5.2 The lead
 
@@ -712,15 +727,21 @@ coupling, because the Rust has no winter and no flight to couple.
 | one RNG stream | a master plus one per duck | per-duck jitter and poses |
 | `StandardNormal` for the water | value noise for the terrain | the terrain, not the audio |
 
+### Audio (v2)
+
+| Rust | quack | note |
+| --- | --- | --- |
+| the synthesis, propagation and spatialization | the same Rust `Stage`, running as wasm in a worker | `src/audio.ts`; §2.1 and `docs/audio.md` |
+| the dabbling splash rhythm | `Duck.onDabble` → `quack_audio_dabble` | `src/duck.ts:925-935` |
+| the bill clack and swallow | `Pond.onPeck` → `quack_audio_peck`, dry or afloat | bread replaces peas |
+| the pea plop and bank tap | `Pond.onBreadLand` → `quack_audio_fixed` | the bread's splash and dust have a voice now |
+| the water bed, drips, reverb | the same Rust render, driven by `Stage` | `Stage::ambient` and `Stage::render` |
+
 ### Dropped
 
 | Rust | quack | note |
 | --- | --- | --- |
-| the synthesis, propagation and spatialization | silent `CallEvent`s on `Pond.onCall` | §2.1; the behaviour half is ported, the sound is the next pass |
-| the dabbling splash rhythm | — | the visual dabble does the work; a later audio hook can follow the calls |
-| the pea plop and bank tap | the bread's splash and dust | the effects table |
 | `PERSONAL_SPACE` from the listener | — | the camera replaced the listener's radius |
-| the water bed, drips, reverb | — | audio, out of scope |
 
 ---
 
diff --git a/docs/deploy.md b/docs/deploy.md
index e631507..742c120 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -12,6 +12,9 @@ index.html          entry point, loading card and title-card styles
 assets/index-*.js   the bundled pond (content-hashed by Vite)
 assets/index-*.js.map  its source map (production builds always write one)
 pond-loader-*.jpg   the loading card's opening-pond stills (from public/)
+quack_audio.wasm    the Rust audio stage (built from duck-pond, see docs/audio.md)
+quack-audio-worker.js   renders the stage off the main thread
+quack-audio-worklet.js  plays the rendered blocks
 quack-source.zip    the fifth title card's source download (generated)
 ```
 
@@ -57,10 +60,14 @@ and a version ID. The URL does not change between deployments.
   it when you want newer runtime behaviour; the date above is the first
   deployment's.
 - The JS bundle and its source map are content-hashed by Vite, so `index.html`
-  always names the right build. The two loading JPEGs and `quack-source.zip`
-  keep fixed names; the zip is regenerated by every `npm run build`. Rename any
-  of them (and update `index.html` or `src/hints.ts`) if you need to defeat a
-  long cache.
+  always names the right build. The two loading JPEGs, the audio
+  worker/worklet/wasm and `quack-source.zip` keep fixed names; the zip is
+  regenerated by every `npm run build`. Rename any of them (and update
+  `index.html`, `src/audio.ts` / `public/quack-audio-worker.js`, or
+  `src/hints.ts`) if you need to defeat a long cache.
+- Rebuild the audio stage only when the Rust bridge changes: `npm run audio`
+  (see `docs/audio.md`). Normal front-end deploys use the committed
+  `public/quack_audio.wasm`.
 
 ## Rolling back
 
diff --git a/docs/port-notes.md b/docs/port-notes.md
index b2dd3d0..afb9736 100644
--- a/docs/port-notes.md
+++ b/docs/port-notes.md
@@ -25,13 +25,22 @@ and the Rust has neither.
 The research citations behind the upstream constants live in
 `duck-pond/ATTRIBUTION.md`.
 
-Deliberately not ported for v1: the whole audio stack (synthesis, propagation,
-spatialization). The calling model itself is now ported as behaviour: the
-intervals, lottery, one-hop answer chain and brood recall run in `src/pond.ts`,
-tuned by `CALLS`. A call is a silent head/bill gesture, and every call also
-leaves a `CallEvent` on `Pond.onCall` for the audio core that will follow; a
-lost duckling's "peep peep" and a content duckling's "pip-pip" are separate
-call kinds now, not a random chirp.
+Ported in v2: the audio stack, unchanged from the Rust, driving the flock quack
+already has. `Stage` in the vendored `duck-pond/src/scene.rs` (kept as
+`audio/duck-pond-stage.patch`, built by `scripts/build-audio.mjs` into
+`public/quack_audio.wasm`) adds a manual sound layer: quack supplies the ducks'
+voices, their places in the listener's frame, and the sound kinds; the Rust
+answers with the same synthesis, distance attenuation, speed-of-sound delay,
+head shading and reverb as the live pond. It runs in a worker behind an
+AudioWorklet, wired in `src/audio.ts`; `docs/audio.md` maps every sound to the
+action that makes it.
+
+The calling model itself was ported as behaviour in v1: the intervals, lottery,
+one-hop answer chain and brood recall run in `src/pond.ts`, tuned by `CALLS`. A
+call is still a head/bill gesture in the rig (and a `CallEvent` on
+`Pond.onCall`), but the event now also plays, through `AudioCore.call`; a lost
+duckling's "peep peep" and a content duckling's "pip-pip" are separate call
+kinds, not a random chirp.
 
 ## The flock
 
@@ -56,8 +65,7 @@ mate out of reach, dabbling, everything else), a resting duck skips 80–90 % of
 them, and a hen's series or decrescendo can set one hop of answers going.
 Ducklings peep when lost and pip-pip otherwise; a hen gathering her brood or
 answering lost young gives the `kwek-kwek` brood call and cues stray ducklings
-back. The audio synth is still absent — see the note above and
-`behavior-divergence.md` §2.1.
+back. Each is heard as the matching Rust call — see `docs/audio.md`.
 
 ## Pond geometry
 
@@ -613,8 +621,10 @@ and the source card.
 
 - **Night**: the seasons already carry the sun across the year; a proper
   day/night cycle within a season is the next step.
-- **Audio**: the calling model is now ported and emits `CallEvent`s on
-  `Pond.onCall`; the next pass is the synth (the Rust's `synth.rs`), propagation
-  and spatialization. The dabbling splash rhythm and bill clack can hang off
-  the same event layer when it is built; the visual `chirp`/`beg` gestures stay
-  as the fallback for muted playback.
+- **Audio**: synthesis, propagation and spatialization are wired (v2) — the
+  Rust `Stage` behind `src/audio.ts`. What is still ours to do is the finer
+  matching: a call's gesture and its sound are offset by the worker queue
+  (`AUDIO.queue`), the dabbling ripple rhythm is a single Rust patter rather
+  than visual ripples, and bread lands as a pea plop scaled up. An attention
+  model for the mix (who deserves the foreground) is the open question, not the
+  synth.
diff --git a/package-lock.json b/package-lock.json
index a749bab..667f509 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "quack",
-  "version": "0.1.0",
+  "version": "2.0.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "quack",
-      "version": "0.1.0",
+      "version": "2.0.0",
       "license": "MIT",
       "dependencies": {
         "three": "^0.186.1"
diff --git a/package.json b/package.json
index 0447754..9b18ff0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "quack",
-  "version": "0.1.0",
+  "version": "2.0.0",
   "private": true,
   "type": "module",
   "description": "duck pond",
@@ -9,7 +9,8 @@
     "dev": "node scripts/source-zip.mjs && vite",
     "build": "node scripts/source-zip.mjs && vite build",
     "preview": "vite preview",
-    "source": "node scripts/source-zip.mjs"
+    "source": "node scripts/source-zip.mjs",
+    "audio": "node scripts/build-audio.mjs"
   },
   "dependencies": {
     "three": "^0.186.1"
diff --git a/public/quack-audio-worker.js b/public/quack-audio-worker.js
new file mode 100644
index 0000000..6508d3f
--- /dev/null
+++ b/public/quack-audio-worker.js
@@ -0,0 +1,132 @@
+// Renders the Rust sound stage off the main thread. The page knows where every
+// duck is in the listener's frame; it sends those places and the sounds the
+// flock has already decided on. This worker keeps a few blocks ahead of the
+// worklet, exactly as duck-pond's own player does.
+//
+// The stage is fed live: a call that arrives while the queue is full is picked
+// up by the next block rendered, so it is heard a queue-length after the
+// visual gesture. Eight blocks is about 85 ms at 48 kHz.
+
+let pond = null;
+let memory = null;
+let block = 512;
+let feed = null;
+let queue = 8;
+let bootGrace = 2.5;
+let queued = 0;
+let started = false;
+const pending = [];
+
+onmessage = async ({ data }) => {
+	switch (data.type) {
+		case 'start':
+			await start(data);
+			break;
+		case 'sync':
+			if (pond) sync(data.positions);
+			else remember(data);
+			break;
+		case 'call':
+		case 'dabble':
+		case 'peck':
+		case 'fixed':
+			if (pond) apply(data);
+			else remember(data);
+			break;
+		case 'headphones':
+			if (pond) pond.quack_audio_set_headphones(data.on ? 1 : 0);
+			break;
+		default:
+			break;
+	}
+};
+
+async function start(data) {
+	if (started) return;
+	started = true;
+	try {
+		const url = new URL('quack_audio.wasm', self.location.href);
+		const response = await fetch(url);
+		if (!response.ok) throw new Error(`couldn't load quack_audio.wasm (${response.status})`);
+		const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {});
+		pond = instance.exports;
+		memory = pond.memory;
+		block = pond.quack_audio_block();
+		queue = Math.max(2, data.queue || queue);
+		bootGrace = data.bootGrace || bootGrace;
+
+		pond.quack_audio_new(data.sampleRate, data.seed[0], data.seed[1], data.headphones ? 1 : 0);
+		const slots = Math.min(pond.quack_audio_ducks(), data.roster?.length ?? 0);
+		for (let i = 0; i < slots; i++) {
+			const voice = data.roster[i];
+			pond.quack_audio_set_voice(i, voice[0], voice[1]);
+		}
+		sync(data.positions);
+
+		feed = data.feed;
+		feed.onmessage = () => {
+			queued--;
+			pump();
+		};
+		postMessage({ type: 'ready', block, queue });
+
+		// sounds made while the module was loading are still worth hearing,
+		// unless they have already gone stale
+		const now = performance.now();
+		for (const event of pending) {
+			if (now - event.at <= bootGrace * 1000) apply(event);
+		}
+		pending.length = 0;
+		pump();
+	} catch (error) {
+		postMessage({ type: 'error', message: String(error?.message ?? error) });
+	}
+}
+
+function remember(data) {
+	pending.push({ ...data, at: performance.now() });
+}
+
+function apply(data) {
+	switch (data.type) {
+		case 'sync':
+			sync(data.positions);
+			break;
+		case 'call':
+			pond.quack_audio_set_pos(data.duck, data.x, data.y);
+			pond.quack_audio_call(data.duck, data.kind, data.notes, data.duration);
+			break;
+		case 'dabble':
+			pond.quack_audio_set_pos(data.duck, data.x, data.y);
+			pond.quack_audio_dabble(data.duck, data.seconds);
+			break;
+		case 'peck':
+			pond.quack_audio_set_pos(data.duck, data.x, data.y);
+			pond.quack_audio_peck(data.duck, data.onWater ? 1 : 0, data.duckling ? 1 : 0);
+			break;
+		case 'fixed':
+			pond.quack_audio_fixed(data.kind, data.x, data.y, data.gain);
+			break;
+		default:
+			break;
+	}
+}
+
+function sync(positions) {
+	if (!pond || !positions) return;
+	const slots = Math.min(pond.quack_audio_ducks(), positions.length);
+	for (let i = 0; i < slots; i++) {
+		pond.quack_audio_set_pos(i, positions[i][0], positions[i][1]);
+	}
+}
+
+function pump() {
+	while (pond && feed && queued < queue) {
+		// rendering may grow the module's memory, replacing its buffer, so
+		// look for the memory only afterwards and copy the block out
+		const ptr = pond.quack_audio_render();
+		const audio = new Float32Array(memory.buffer, ptr, 2 * block).slice();
+		feed.postMessage({ audio }, [audio.buffer]);
+		queued++;
+	}
+}
diff --git a/public/quack-audio-worklet.js b/public/quack-audio-worklet.js
new file mode 100644
index 0000000..bfa2641
--- /dev/null
+++ b/public/quack-audio-worklet.js
@@ -0,0 +1,42 @@
+// Plays the blocks the audio worker renders. The page sends the worker's feed
+// port across; every block taken off the queue is acknowledged so another one
+// can be rendered. Blocks are planar: the left channel first, then the right.
+
+class QuackPlayer extends AudioWorkletProcessor {
+	constructor() {
+		super();
+		this.blocks = [];
+		this.current = null;
+		this.pos = 0;
+		this.feed = null;
+		this.port.onmessage = ({ data }) => {
+			if (data?.type !== 'feed') return;
+			this.feed = data.feed;
+			this.feed.onmessage = ({ data: block }) => this.blocks.push(block);
+		};
+	}
+
+	process(_inputs, outputs) {
+		const [left, right] = outputs[0];
+		for (let i = 0; i < left.length; i++) {
+			if (!this.current || this.pos >= this.current.audio.length / 2) {
+				this.current = this.blocks.shift() ?? null;
+				this.pos = 0;
+				if (!this.current) {
+					// the worker fell behind (or is just starting)
+					left.fill(0, i);
+					right.fill(0, i);
+					break;
+				}
+				this.feed?.postMessage(0);
+			}
+			const frames = this.current.audio.length / 2;
+			left[i] = this.current.audio[this.pos];
+			right[i] = this.current.audio[frames + this.pos];
+			this.pos++;
+		}
+		return true;
+	}
+}
+
+registerProcessor('quack-audio-player', QuackPlayer);
diff --git a/public/quack_audio.wasm b/public/quack_audio.wasm
new file mode 100644
index 0000000..a390ced
Binary files /dev/null and b/public/quack_audio.wasm differ
diff --git a/scripts/build-audio.mjs b/scripts/build-audio.mjs
new file mode 100644
index 0000000..d153c2c
--- /dev/null
+++ b/scripts/build-audio.mjs
@@ -0,0 +1,40 @@
+// Rebuild public/quack_audio.wasm from the vendored Rust clone. The clone is
+// git-ignored; the small stage API the browser needs is kept as a patch, so a
+// fresh clone needs it applied first:
+//
+//   git -C duck-pond apply audio/duck-pond-stage.patch
+//   npm run audio
+//
+// The wasm itself is committed, so this is only needed when the Rust source or
+// the bridge changes.
+import { execFileSync } from 'node:child_process';
+import { copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const duck = join(root, 'duck-pond');
+const wasm = join(duck, 'target', 'wasm32-unknown-unknown', 'release', 'duck_pond_web.wasm');
+const out = join(root, 'public', 'quack_audio.wasm');
+const patch = join(root, 'audio', 'duck-pond-stage.patch');
+
+if (!existsSync(join(duck, 'Cargo.toml'))) {
+	console.error(`no duck-pond clone at ${duck}`);
+	console.error('clone https://github.com/celphase/duck-pond there first');
+	process.exit(1);
+}
+const source = readFileSync(join(duck, 'src', 'scene.rs'), 'utf8');
+const bridge = readFileSync(join(duck, 'web', 'src', 'lib.rs'), 'utf8');
+if (!source.includes('pub struct Stage') || !bridge.includes('quack_audio_new')) {
+	console.error('the clone does not have the quack stage API yet; apply the patch first:');
+	console.error(`  git -C "${duck}" apply "${patch}"`);
+	process.exit(1);
+}
+
+const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
+execFileSync(cargo, ['build', '--release', '--target', 'wasm32-unknown-unknown', '-p', 'duck-pond-web'], {
+	cwd: duck,
+	stdio: 'inherit',
+});
+copyFileSync(wasm, out);
+console.log(`built public/quack_audio.wasm (${(statSync(out).size / 1024).toFixed(0)} KiB)`);
diff --git a/scripts/source-zip.mjs b/scripts/source-zip.mjs
index 934e77b..3f7e3be 100644
--- a/scripts/source-zip.mjs
+++ b/scripts/source-zip.mjs
@@ -35,7 +35,7 @@ const files = {};
 /** Add one file or directory, skipping generated output and local-only files. */
 function add(path) {
 	const base = basename(path);
-	if (skipDirs.has(base) || skipFiles.has(base) || base.startsWith('.env')) return;
+	if (skipDirs.has(base) || skipFiles.has(base) || base.startsWith('.env') || base.startsWith('deploy-')) return;
 	const name = relative(root, path).split(sep).join('/');
 	if (statSync(path).isDirectory()) {
 		for (const child of readdirSync(path)) add(join(path, child));
diff --git a/src/audio.ts b/src/audio.ts
new file mode 100644
index 0000000..2554b2f
--- /dev/null
+++ b/src/audio.ts
@@ -0,0 +1,331 @@
+// The sound of the pond. quack decides what every duck does and where it is;
+// this side hands those decisions to the vendored Rust audio core, running in
+// a worker and playing through an AudioWorklet. The core does the synthesis,
+// distance, delay, head shading and reverb; this module only translates the
+// flock's world into the listener's frame and keeps the two clocks apart.
+import * as THREE from 'three';
+import { AUDIO } from './config';
+import type { CallEvent, CallKind, Duck } from './duck';
+import type { Pond } from './pond';
+
+/** CallKind order as the wasm stage expects it (`scene.rs`'s numeric switch). */
+const CALLS: Record<CallKind, number> = {
+	quack: 0,
+	series: 1,
+	decrescendo: 2,
+	chatter: 3,
+	brood: 4,
+	rhaeb: 5,
+	peeps: 6,
+	contact: 7,
+};
+
+const WORKER = 'quack-audio-worker.js';
+const WORKLET = 'quack-audio-worklet.js';
+const HEADPHONES_KEY = 'quack:headphones';
+
+type Listener = { x: number; y: number };
+type RosterEntry = { name: string; voice: [number, number] };
+
+export class AudioCore {
+	readonly pond: Pond;
+	readonly roster: RosterEntry[];
+	/** Set once the worklet and worker are both running. */
+	ready = false;
+	/** The last thing that stopped the audio core, or null. */
+	problem: string | null = null;
+	headphones: boolean;
+
+	private camera: THREE.PerspectiveCamera;
+	private seed: number;
+	private ctx: AudioContext | null = null;
+	private node: AudioWorkletNode | null = null;
+	private worker: Worker | null = null;
+	private gain: GainNode | null = null;
+	private analyser: AnalyserNode | null = null;
+	private starting: Promise<void> | null = null;
+	private index = new Map<string, number>();
+	private lastSync = -Infinity;
+	private paused = false;
+	private _eye = new THREE.Vector3();
+	private _fwd = new THREE.Vector3();
+	private _sample = new Float32Array(1024);
+
+	constructor(pond: Pond, camera: THREE.PerspectiveCamera, seed: number) {
+		this.pond = pond;
+		this.camera = camera;
+		this.seed = seed >>> 0;
+		this.roster = pond.ducks.map((d, i) => {
+			this.index.set(d.name, i);
+			return { name: d.name, voice: [d.voice[0], d.voice[1]] };
+		});
+		this.headphones = this.preferredHeadphones();
+		pond.onCall = (d, call) => this.call(d, call);
+		pond.onDabble = (d, seconds) => this.dabble(d, seconds);
+		pond.onPeck = (d, onWater) => this.peck(d, onWater);
+		pond.onBreadLand = (x, z, onWater) => this.breadLand(x, z, onWater);
+	}
+
+	/**
+	 * Open the audio device and start rendering. Must be called from a user
+	 * gesture; the page calls it on the first press or key. Repeating it
+	 * resumes a context the browser suspended.
+	 */
+	async start(): Promise<void> {
+		if (this.ctx) {
+			if (this.ctx.state === 'suspended' && !this.paused) await this.ctx.resume().catch(() => {});
+			return;
+		}
+		if (this.starting) return this.starting;
+		this.starting = this.open().catch((error: unknown) => {
+			this.problem = error instanceof Error ? error.message : String(error);
+			console.warn('quack: audio did not start:', this.problem);
+			this.abandon();
+			this.starting = null;
+		});
+		return this.starting;
+	}
+
+	/** Put the page back where it was if the device or worklet refuses. */
+	private abandon(): void {
+		this.worker?.terminate();
+		this.node?.disconnect();
+		this.gain?.disconnect();
+		this.analyser?.disconnect();
+		void this.ctx?.close().catch(() => {});
+		this.worker = null;
+		this.node = null;
+		this.gain = null;
+		this.analyser = null;
+		this.ctx = null;
+		this.ready = false;
+	}
+
+	private async open(): Promise<void> {
+		if (!window.isSecureContext) throw new Error('audio needs a secure page');
+		const Ctor = window.AudioContext;
+		if (!Ctor || typeof AudioWorkletNode === 'undefined') throw new Error('this browser has no Web Audio worklet');
+		this.problem = null;
+
+		// play even with an iPhone's ringer switched off
+		const session = (navigator as Navigator & { audioSession?: { type: string } }).audioSession;
+		if (session) session.type = 'playback';
+
+		// created and resumed straight away, while the gesture still counts
+		const ctx = new Ctor({ latencyHint: 'playback' });
+		this.ctx = ctx;
+		await ctx.resume();
+
+		await ctx.audioWorklet.addModule(this.asset(WORKLET));
+		const node = new AudioWorkletNode(ctx, 'quack-audio-player', {
+			numberOfInputs: 0,
+			numberOfOutputs: 1,
+			outputChannelCount: [2],
+		});
+		this.node = node;
+
+		const gain = ctx.createGain();
+		gain.gain.value = AUDIO.volume;
+		const analyser = ctx.createAnalyser();
+		analyser.fftSize = 1024;
+		node.connect(gain);
+		gain.connect(analyser);
+		gain.connect(ctx.destination);
+		this.gain = gain;
+		this.analyser = analyser;
+
+		const worker = new Worker(this.asset(WORKER));
+		this.worker = worker;
+		worker.onmessage = ({ data }) => {
+			if (data?.type === 'ready') this.ready = true;
+			else if (data?.type === 'error') {
+				this.problem = String(data.message ?? 'audio core failed');
+				console.warn('quack: audio core:', this.problem);
+			}
+		};
+		worker.onerror = (event) => {
+			this.problem = event.message || 'audio worker failed';
+			console.warn('quack: audio worker:', this.problem);
+		};
+
+		// the worklet takes its blocks from one port, the worker renders into
+		// the other; one channel, both ends already transferred
+		const feed = new MessageChannel();
+		node.port.postMessage({ type: 'feed', feed: feed.port1 }, [feed.port1]);
+
+		const hi = Math.floor(this.seed / 2 ** 32) >>> 0;
+		const lo = this.seed >>> 0;
+		const positions = this.positions();
+		worker.postMessage(
+			{
+				type: 'start',
+				sampleRate: ctx.sampleRate,
+				seed: [hi, lo],
+				headphones: this.headphones,
+				queue: AUDIO.queue,
+				bootGrace: AUDIO.bootGrace,
+				roster: this.roster.map((r) => r.voice),
+				positions,
+				feed: feed.port2,
+			},
+			[feed.port2],
+		);
+		if (this.paused) await ctx.suspend().catch(() => {});
+		else if (ctx.state === 'suspended') await ctx.resume().catch(() => {});
+	}
+
+	/** Say where every bird is in the listener's frame, a few times a second. */
+	sync(camera = this.camera): void {
+		if (!this.worker) return;
+		const now = performance.now();
+		if (now - this.lastSync < AUDIO.sync * 1000) return;
+		this.lastSync = now;
+		this.camera = camera;
+		this.worker.postMessage({ type: 'sync', positions: this.positions() });
+	}
+
+	call(d: Duck, call: CallEvent): void {
+		if (!this.worker) return;
+		const at = this.listener(d.pos.x, d.pos.z);
+		this.worker.postMessage({
+			type: 'call',
+			duck: this.duckIndex(d),
+			kind: CALLS[call.kind],
+			notes: call.notes,
+			duration: call.duration,
+			x: at.x,
+			y: at.y,
+		});
+	}
+
+	dabble(d: Duck, seconds: number): void {
+		if (!this.worker) return;
+		const at = this.listener(d.pos.x, d.pos.z);
+		this.worker.postMessage({
+			type: 'dabble',
+			duck: this.duckIndex(d),
+			seconds,
+			x: at.x,
+			y: at.y,
+		});
+	}
+
+	peck(d: Duck, onWater: boolean): void {
+		if (!this.worker) return;
+		const at = this.listener(d.pos.x, d.pos.z);
+		this.worker.postMessage({
+			type: 'peck',
+			duck: this.duckIndex(d),
+			onWater,
+			duckling: d.kind === 'duckling',
+			x: at.x,
+			y: at.y,
+		});
+	}
+
+	breadLand(x: number, z: number, onWater: boolean): void {
+		if (!this.worker) return;
+		const at = this.listener(x, z);
+		this.worker.postMessage({
+			type: 'fixed',
+			kind: onWater ? 0 : 1,
+			gain: onWater ? AUDIO.bread.plopGain : AUDIO.bread.tapGain,
+			x: at.x,
+			y: at.y,
+		});
+	}
+
+	/** Hold the pond, hold its sound; the clock keys call this as they toggle. */
+	setPaused(paused: boolean): void {
+		this.paused = paused;
+		if (!this.ctx) return;
+		if (paused && this.ctx.state === 'running') void this.ctx.suspend().catch(() => {});
+		else if (!paused && this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {});
+	}
+
+	toggleHeadphones(): boolean {
+		this.setHeadphones(!this.headphones);
+		return this.headphones;
+	}
+
+	setHeadphones(on: boolean): void {
+		this.headphones = on;
+		try {
+			localStorage.setItem(HEADPHONES_KEY, on ? '1' : '0');
+		} catch {
+			// private mode: the choice just does not persist
+		}
+		this.worker?.postMessage({ type: 'headphones', on });
+	}
+
+	setVolume(volume: number): void {
+		if (this.gain) this.gain.gain.setTargetAtTime(Math.max(0, Math.min(1, volume)), this.ctx?.currentTime ?? 0, 0.02);
+	}
+
+	/** RMS of the last 1024 samples, for probes and the console. */
+	rms(): number {
+		if (!this.analyser) return 0;
+		this.analyser.getFloatTimeDomainData(this._sample);
+		let sum = 0;
+		for (const x of this._sample) sum += x * x;
+		return Math.sqrt(sum / this._sample.length);
+	}
+
+	get state(): string {
+		return this.ctx?.state ?? 'off';
+	}
+
+	private preferredHeadphones(): boolean {
+		const query = new URLSearchParams(window.location.search).get('headphones');
+		if (query === '1') return true;
+		if (query === '0') return false;
+		try {
+			const saved = localStorage.getItem(HEADPHONES_KEY);
+			if (saved !== null) return saved === '1';
+		} catch {
+			// private mode
+		}
+		return AUDIO.headphones;
+	}
+
+	private duckIndex(d: Duck): number {
+		return this.index.get(d.name) ?? -1;
+	}
+
+	/**
+	 * A world point in the ear's frame: +x right, +y straight ahead, metres.
+	 * The camera is planted and only turns, so the horizontal gaze is the
+	 * listener's facing; the pond's y is the distance toward it.
+	 */
+	private listener(x: number, z: number): Listener {
+		const eye = this.camera.getWorldPosition(this._eye);
+		const dir = this.camera.getWorldDirection(this._fwd);
+		let fx = dir.x;
+		let fz = dir.z;
+		const len = Math.hypot(fx, fz);
+		if (len < 1e-6) {
+			fx = 0;
+			fz = -1;
+		} else {
+			fx /= len;
+			fz /= len;
+		}
+		const dx = x - eye.x;
+		const dz = z - eye.z;
+		return {
+			x: dx * -fz + dz * fx,
+			y: dx * fx + dz * fz,
+		};
+	}
+
+	private positions(): Array<[number, number]> {
+		return this.pond.ducks.map((d) => {
+			const at = this.listener(d.pos.x, d.pos.z);
+			return [at.x, at.y];
+		});
+	}
+
+	private asset(name: string): string {
+		return new URL(name, document.baseURI).href;
+	}
+}
diff --git a/src/config.ts b/src/config.ts
index 634e434..3d78f0e 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -247,8 +247,8 @@ export const FLOCK = {
 /**
  * The calling model: when a duck calls, who answers, and how quickly a lost
  * duckling or a brood call brings the next one forward. Ported from
- * duck-pond's `scene.rs`; the synth and propagation are still absent, so a
- * call is a silent head/bill gesture and a `CallEvent` for the audio core.
+ * duck-pond's `scene.rs`. Each call is also a head/bill gesture and a
+ * `CallEvent`; `src/audio.ts` sends that event to the Rust stage.
  */
 export const CALLS = {
 	/** seconds until a newly built duck's first call */
@@ -285,6 +285,30 @@ export const CALLS = {
 	},
 } as const;
 
+// ---------------------------------------------------------------- audio
+
+/**
+ * The Rust sound stage. The synthesis, propagation and reverb are the
+ * vendored duck-pond core; this group only holds the browser-side plumbing:
+ * how far ahead the worker renders, how often the camera places the flock,
+ * and the output gain. A call is heard only after the first gesture, as
+ * browser autoplay rules require; `?audio=0` disables the stage entirely.
+ */
+export const AUDIO = {
+	/** blocks (512 frames) of audio rendered ahead of playback: ~85 ms at 48k */
+	queue: 8,
+	/** seconds between updates of the ducks' places in the listener's frame */
+	sync: 0.05,
+	/** output gain, after the Rust master */
+	volume: 0.8,
+	/** render for headphones by default; H toggles it on the page */
+	headphones: false,
+	/** while the wasm boots, keep sounds this young instead of dropping them */
+	bootGrace: 2.5,
+	/** a bread crust is a bigger plop than a pea; it must still read at 15 m */
+	bread: { plopGain: 4, tapGain: 3 },
+} as const;
+
 // ---------------------------------------------------------------- shore
 
 /**
diff --git a/src/duck.ts b/src/duck.ts
index 793d550..8a18eb0 100644
--- a/src/duck.ts
+++ b/src/duck.ts
@@ -19,9 +19,9 @@ export type Band = 'near' | 'mid' | 'far';
 export type CallKind = 'quack' | 'series' | 'decrescendo' | 'chatter' | 'rhaeb' | 'peeps' | 'contact' | 'brood';
 
 /**
- * One call, at the moment it starts. The silent head/bill gesture is the
- * visual stand-in for the missing synth; the event carries what the audio
- * core will need to make the same call later.
+ * One call, at the moment it starts. The head/bill gesture is its visual half;
+ * the event carries what the Rust audio stage needs to synthesize the same
+ * call: its kind, its note count and its natural length.
  */
 export interface CallEvent {
 	kind: CallKind;
@@ -610,7 +610,7 @@ interface WingPose {
 export interface DuckDef {
 	name: string;
 	kind: DuckKind;
-	/** Hen: (f0, f2) of her quack; drake: (rhaeb, -); duckling: (peep top, -). Kept for a later pass at voices. */
+	/** Hen: (f0, f2) of her quack; drake: (rhaeb, -); duckling: (peep top, -). The audio stage gets these at start. */
 	voice: [number, number];
 	pos: { x: number; z: number };
 }
@@ -739,7 +739,9 @@ export class Duck {
 	wakeTimer = 0;
 	gestureTimer = 0;
 	onPeck: ((d: Duck) => void) | null = null;
-	/** emitted when a call starts, for effects and the future audio core */
+	/** emitted when a spell of dabbling begins, for the audio core */
+	onDabble: ((d: Duck, seconds: number) => void) | null = null;
+	/** emitted when a call starts, for effects and the audio core */
 	onCall: ((d: Duck, call: CallEvent) => void) | null = null;
 	private _peckHit = false;
 	private _upendPhase = 0;
@@ -904,6 +906,7 @@ export class Duck {
 		this.tailPose = null;
 		if (name === 'preen') this._preenSide = this.rng.sign();
 		if (name === 'sleep') this._sleepSide = this.rng.sign();
+		if (name === 'dabble') this.onDabble?.(this, duration);
 	}
 
 	/** What this duck does next, given the activity it is on. */
diff --git a/src/main.ts b/src/main.ts
index 76bda9d..b6fed49 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,6 +6,7 @@ import { Seasons } from './seasons';
 import { Effects } from './effects';
 import { CameraRig } from './camera';
 import { Pond } from './pond';
+import { AudioCore } from './audio';
 import { showHints } from './hints';
 import { APP, BREAD, CAMERA, HINTS, INPUT, LOADER } from './config';
 
@@ -34,6 +35,7 @@ let seasons: Seasons | null = null;
 let effects: Effects | null = null;
 let rig: CameraRig | null = null;
 let pond: Pond | null = null;
+let audio: AudioCore | null = null;
 
 let time = 0;
 let prevStamp = 0;
@@ -81,6 +83,18 @@ function boot(): void {
 	rig = new CameraRig(camera, renderer.domElement, world);
 	setupPointers(renderer.domElement);
 
+	// Browsers will not let a pond speak before a gesture. The first press or
+	// key opens the audio device; after that the stage is always there, and a
+	// call that arrives before the wasm has loaded is kept for a moment.
+	if (params.get('audio') !== '0') {
+		audio = new AudioCore(pond, camera, seed);
+		const unlock = (): void => {
+			void audio?.start();
+		};
+		window.addEventListener('pointerdown', unlock, { capture: true });
+		window.addEventListener('keydown', unlock, { capture: true });
+	}
+
 	window.addEventListener('resize', onResize);
 	window.addEventListener('keydown', onKey);
 	onResize();
@@ -90,7 +104,7 @@ function boot(): void {
 	renderer.setAnimationLoop(frame);
 	app.started = true;
 	window.__quackStarted = true;
-	(window as unknown as Record<string, unknown>).quack = { app, world, seasons, effects, pond, rig, camera, renderer, scene };
+	(window as unknown as Record<string, unknown>).quack = { app, world, seasons, effects, pond, rig, audio, camera, renderer, scene };
 }
 
 function frame(stamp: number): void {
@@ -114,6 +128,9 @@ function simulate(raw: number, exact = false): void {
 	// the camera keeps the wall clock even when the world is held: a drag
 	// while paused should still answer the finger
 	if (rig) rig.update(raw);
+	// the listener turns in place, so the flock's bearing changes even when
+	// the pond is paused; keep the audio worker's frame in step with it
+	if (audio && camera) audio.sync(camera);
 
 	if (renderer && scene && camera) renderer.render(scene, camera);
 }
@@ -133,10 +150,14 @@ function onKey(event: KeyboardEvent): void {
 		case 'Space':
 			event.preventDefault();
 			app.paused = !app.paused;
+			audio?.setPaused(app.paused);
 			break;
 		case 'KeyT':
 			app.timeScale = app.timeScale === 1 ? APP.slowMotion : 1;
 			break;
+		case 'KeyH':
+			audio?.toggleHeadphones();
+			break;
 		case 'KeyY':
 		case 'KeyZ': // a German keyboard swaps Y and Z, so accept the physical key either way
 			if (seasons.held) seasons.resume();
diff --git a/src/pond.ts b/src/pond.ts
index 0b43b20..d299b2f 100644
--- a/src/pond.ts
+++ b/src/pond.ts
@@ -48,10 +48,16 @@ export class Pond {
 	group: THREE.Group;
 	/**
 	 * Every call the flock makes, as it starts. The behaviour is complete
-	 * without a listener; an audio core can hang its synth and propagation
-	 * here and read the caller from the duck. Null until something listens.
+	 * without a listener; `AudioCore` hangs the Rust synth and propagation
+	 * here and reads the caller from the duck. Null until something listens.
 	 */
 	onCall: ((d: Duck, call: CallEvent) => void) | null = null;
+	/** A bill has come down on bread, with whether the duck is afloat. */
+	onPeck: ((d: Duck, onWater: boolean) => void) | null = null;
+	/** A spell of dabbling on the water has begun. */
+	onDabble: ((d: Duck, seconds: number) => void) | null = null;
+	/** A thrown piece has landed; true when it met open water. */
+	onBreadLand: ((x: number, z: number, onWater: boolean) => void) | null = null;
 
 	private scene: THREE.Scene;
 	/** The render camera: the cursor gaze projects through it. Set by the host. */
@@ -90,6 +96,7 @@ export class Pond {
 		const add = (kind: DuckKind, name: string, voice: [number, number], pos: { x: number; z: number }): Duck => {
 			const d = new Duck({ name, kind, voice, pos }, this.world, this.effects, this.rng);
 			d.onPeck = (duck) => this.peck(duck);
+			d.onDabble = (duck, seconds) => this.onDabble?.(duck, seconds);
 			d.onCall = (duck, call) => this.onCall?.(duck, call);
 			this.ducks.push(d);
 			this.group.add(d.root);
@@ -924,8 +931,8 @@ export class Pond {
 	/**
 	 * Duck `d` calls, and may set others answering. The Rust's intervals,
 	 * sleeping duck, hen lottery, brood calls and one-hop answer chain are
-	 * ported here; the synth is not, so the call opens the bill and the
-	 * event goes to `onCall` for whatever audio core comes next.
+	 * ported here; the call opens the bill and the event goes to `onCall`,
+	 * where `src/audio.ts` hands it to the Rust stage.
 	 */
 	private call(d: Duck): void {
 		const young = this.young(d);
@@ -1270,9 +1277,10 @@ export class Pond {
 
 	/** A bill has come down on the food: a scatter of crumbs. */
 	private peck(d: Duck): void {
-		if (d.eating <= 0) return; // grazing pecks leave no crumbs
+		if (d.eating <= 0) return; // grazing pecks leave no crumbs or sound
 		const tip = d.beakTip(this._v1);
 		this.effects.spawnDust(tip, 2);
+		this.onPeck?.(d, d.floating);
 	}
 
 	// --------------------------------------------------------- private space
@@ -1716,12 +1724,14 @@ export class Pond {
 					this._v1.set(b.x, b.y, b.z);
 					// a splash only where the crust meets open water: on the
 					// lid it lands in a puff of dust, like a stone
-					if (this.world.depthAt(b.x, b.z) > WORLD.water.wade && !this.world.solidAt(b.x, b.z)) {
+					const onWater = this.world.depthAt(b.x, b.z) > WORLD.water.wade && !this.world.solidAt(b.x, b.z);
+					if (onWater) {
 						this.effects.ripple(this._v1, 1.1, 0.8);
 						this.effects.spawnDroplets(this._v1, 8, 0.4);
 					} else {
 						this.effects.spawnDust(this._v1, 4);
 					}
+					this.onBreadLand?.(b.x, b.z, onWater);
 				}
 			} else {
 				const depth = this.world.depthAt(b.x, b.z);