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()
+    })
+}