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