← Natura·Docs/Modules/Source

creatures/voice.js

502 lines · in natura-src-1.0.0.zip as natura/creatures/voice.js · a module · its entry in the module reference

// voice.js -- the SOUND concern of the creatures framework (the 4th layer, beside
//   species DATA, render DRAWING, behaviour DECISION). Each creature gets a reusable VOICE:
//   a context-agnostic WebAudio synth so any piece drops in a cricket / tree-frog / owl / heron
//   with its call already built and tuned. Stop re-deriving sounds per piece.
//
// SOUND-DESIGN LAW (from library/shelves/craft/making-sound.md, learned the hard way):
//   band-limited TRANSIENT / TEXTURAL sounds only -- sine / triangle / FILTERED NOISE. NEVER a
//   square/saw (harmonics ring to 20 kHz = "phone ringing"), NEVER a sustained pitched oscillator
//   held as a pad (reads as "eerie music"). Every voice returns its nodes so a scheduler can stop them.
//   Continuous beds (crickets, wind) honour opts.dur; one-shots (a hoot, a croak) are self-timed.
//
// DEVICE-FREE VERIFICATION: every voice runs in an OfflineAudioContext identically to a live one, so
//   voice-check.html renders + FFT-spectrograms them with NO speakers. That IS the decisive check.
//
// Load:  <script src="creatures/voice.js"></script>   ->  window.CreatureVoice  (and Creatures.voice
//   if the composer is present). Node/UMD too, for the test/bench.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else { root.CreatureVoice = factory(); if (root.Creatures) root.Creatures.voice = root.CreatureVoice.play; }
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';
  const R  = (a, b) => a + Math.random() * (b - a);
  const RI = (a, b) => Math.round(R(a, b));
  const mtof = m => 440 * Math.pow(2, (m - 69) / 12);
  function nbuf(ctx) {
    if (!ctx.__creatureNoise) {
      const b = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 2), ctx.sampleRate);
      const d = b.getChannelData(0);
      for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
      ctx.__creatureNoise = b;
    }
    return ctx.__creatureNoise;
  }
  // a looped SHARP-PULSE amplitude envelope (a train of grains, one impulse per period, fast exp decay) --
  // drives a gain param to make grainy insect trills (distinct chirp grains, not a smooth sine-AM buzz).
  function pulseEnvBuf(ctx, hz, decay) {
    const len = Math.max(2, Math.floor(ctx.sampleRate / hz)), b = ctx.createBuffer(1, len, ctx.sampleRate), d = b.getChannelData(0);
    const atk = Math.max(3, Math.floor(len * 0.05));   // a short attack ramp so the grain onset is a chirp, not a broadband CLICK
    for (let i = 0; i < len; i++) { const ph = i / len, a = i < atk ? i / atk : 1; d[i] = a * Math.exp(-ph * (decay || 16)); }
    return b;
  }
  const panTo   = (ctx, dest, p) => { const s = ctx.createStereoPanner(); s.pan.value = p == null ? 0 : p; s.connect(dest); return s; };
  const noiseSrc = ctx => { const n = ctx.createBufferSource(); n.buffer = nbuf(ctx); n.loop = true; return n; };
  // a short amplitude envelope on a gain node: silence -> peak(at+atk) -> silence(at+dur)
  function env(ctx, at, atk, dur, peak) {
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, at);
    g.gain.exponentialRampToValueAtTime(peak, at + atk);
    g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
    return g;
  }
  // a glide-capable PHRASE player (notes = [freq, dur, glideTo?, gap?]) -- the engine behind characterful birdsong.
  // ear-verified on the jacaranda leaf; a single osc glides + re-envelopes note to note.
  function phrase(ctx, dest, t, notes, vol, type, pan) {
    const g = ctx.createGain(); g.gain.value = 0; g.connect(panTo(ctx, dest, pan == null ? 0 : pan));
    const o = ctx.createOscillator(); o.type = type || 'sine'; o.connect(g); let tt = t + 0.02;
    notes.forEach(n => {
      const f = n[0], dur = n[1], gl = n[2], gap = (n[3] == null ? 0.02 : n[3]);
      o.frequency.setValueAtTime(f, tt); if (gl) o.frequency.linearRampToValueAtTime(gl, tt + dur);
      g.gain.setValueAtTime(0.0001, tt); g.gain.exponentialRampToValueAtTime(vol, tt + Math.min(0.015, dur * 0.3)); g.gain.exponentialRampToValueAtTime(0.0001, tt + dur);
      tt += dur + gap;
    });
    o.start(t); o.stop(tt + 0.05); return [o];
  }

  // ============================ THE VOICES ============================
  // Each: (ctx, dest, t, o) -> [nodes].  o.pan, o.gain, o.variant optional.
  const VOICES = {

    // OWL -- two soft low drooping sine hoots ("hoo ... hoo"). variants: 'tawny'(default), 'barn'(higher, harsher-ish via a breath)
    owl(ctx, dest, t, o) {
      o = o || {}; const v = o.variant || 'tawny';
      const px = o.pan == null ? (Math.random() < 0.5 ? -0.55 : 0.55) : o.pan;
      const g0 = o.gain || 0.085, nodes = [], out = panTo(ctx, dest, px);
      // one soft hoot: a sine w/ optional glide, vibrato, and a stutter-tremolo
      function hoot(at, dur, f, glide, vibHz, tremHz, vol) {
        const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(f, at); if (glide) osc.frequency.exponentialRampToValueAtTime(glide, at + dur);
        const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, at); g.gain.exponentialRampToValueAtTime(vol, at + 0.06); g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
        if (vibHz) { const vib = ctx.createOscillator(); vib.type = 'sine'; vib.frequency.value = vibHz; const vg = ctx.createGain(); vg.gain.value = f * 0.012; vib.connect(vg); vg.connect(osc.frequency); vib.start(at); vib.stop(at + dur + 0.05); nodes.push(vib); }
        if (tremHz) { const tr = ctx.createGain(); tr.gain.value = 0.6; const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = tremHz; const lg = ctx.createGain(); lg.gain.value = 0.4; lfo.connect(lg); lg.connect(tr.gain); osc.connect(tr); tr.connect(g); lfo.start(at); lfo.stop(at + dur + 0.05); nodes.push(lfo); }
        else osc.connect(g);
        g.connect(out); osc.start(at); osc.stop(at + dur + 0.05); nodes.push(osc);
      }
      if (v === 'barn') {   // a harsh rasping SCREECH (bandpassed noise, rising then falling)
        const nz = noiseSrc(ctx); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1800, t); bp.frequency.exponentialRampToValueAtTime(3200, t + 0.15); bp.frequency.exponentialRampToValueAtTime(1500, t + 0.7); bp.Q.value = 3;
        const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 6000;
        const g = env(ctx, t, 0.04, 0.8, g0 * 2.2); nz.connect(bp); bp.connect(lp); lp.connect(g); g.connect(out); nz.start(t); nz.stop(t + 0.9); nodes.push(nz);   // the filter chain eats ~3.5x, so drive it hard for a piercing screech
      } else if (v === 'kewick') {   // the sharp rising contact call "ke-WICK"
        hoot(t, 0.10, mtof(60), mtof(64), 0, 0, g0 * 0.9); hoot(t + 0.12, 0.16, mtof(67), mtof(72), 0, 0, g0);
      } else {   // TAWNY: the classic "hoooo ... ho, hu-hu-huhooo"
        const base = mtof(RI(55, 59));
        hoot(t, 0.5, base, base * 0.94, 5, 0, g0);                 // the long hoot
        hoot(t + 0.95, 0.12, base * 0.99, base * 0.97, 0, 0, g0 * 0.7);   // a short "ho"
        hoot(t + 1.15, 0.55, base, base * 0.9, 4, 12, g0 * 0.95);  // the stuttering fall "hu-hu-huhooo"
      }
      if (v !== 'barn') {   // a faint breath under the hoot
        const br = noiseSrc(ctx); const blp = ctx.createBiquadFilter(); blp.type = 'lowpass'; blp.frequency.value = 700; const bg = env(ctx, t, 0.1, (v === 'kewick' ? 0.35 : 1.8), g0 * 0.06); br.connect(blp); blp.connect(bg); bg.connect(out); br.start(t); br.stop(t + (v === 'kewick' ? 0.4 : 1.9)); nodes.push(br);
      }
      return nodes;
    },

    // ELEPHANT -- two calls (o.variant). 'trumpet' (default): a bright brassy BLARE, a sharp attack that rises then
    // falls, three glided partials + a raspy bandpassed-air layer. 'rumble': the low infrasonic CONTACT call, felt
    // more than heard -- very low triangle + harmonics with a slow grainy flutter (the rumble's pulsing), swelling
    // and fading. Both band-limited (triangle/sine/filtered-noise, lowpassed) per the sound-design law.
    elephant(ctx, dest, t, o) {
      o = o || {}; const v = o.variant || 'trumpet';
      const px = o.pan == null ? 0 : o.pan, out = panTo(ctx, dest, px), nodes = [];
      if (v === 'rumble') {
        const g0 = o.gain || 0.15, dur = 3.0;
        [[30, 1.0, 'triangle'], [60, 0.5, 'sine'], [92, 0.26, 'sine']].forEach(([f, amp, ty]) => {
          const osc = ctx.createOscillator(); osc.type = ty; osc.frequency.setValueAtTime(f, t); osc.frequency.linearRampToValueAtTime(f * 0.9, t + dur);
          const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(g0 * amp, t + 0.6);
          g.gain.setValueAtTime(g0 * amp, t + dur * 0.62); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
          const tr = ctx.createGain(); tr.gain.value = 1;                         // slow grainy flutter (the rumble's pulsing)
          const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 7.5; const lg = ctx.createGain(); lg.gain.value = 0.3; lfo.connect(lg); lg.connect(tr.gain);
          const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 170;
          osc.connect(tr); tr.connect(g); g.connect(lp); lp.connect(out);
          osc.start(t); osc.stop(t + dur + 0.1); lfo.start(t); lfo.stop(t + dur + 0.1); nodes.push(osc, lfo);
        });
        return nodes;
      }
      // TRUMPET
      const g0 = o.gain || 0.13, dur = 0.95;
      [[1, 1.0], [2, 0.4], [3, 0.16]].forEach(([mult, amp]) => {
        const osc = ctx.createOscillator(); osc.type = mult === 1 ? 'triangle' : 'sine';
        osc.frequency.setValueAtTime(330 * mult, t); osc.frequency.exponentialRampToValueAtTime(780 * mult, t + dur * 0.30); osc.frequency.exponentialRampToValueAtTime(560 * mult, t + dur);
        const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(g0 * amp, t + 0.03);
        g.gain.setValueAtTime(g0 * amp, t + dur * 0.6); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
        const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 4200;
        osc.connect(g); g.connect(lp); lp.connect(out); osc.start(t); osc.stop(t + dur + 0.05); nodes.push(osc);
      });
      const nz = noiseSrc(ctx), bp = ctx.createBiquadFilter(); bp.type = 'bandpass';       // raspy air following the blare
      bp.frequency.setValueAtTime(1400, t); bp.frequency.exponentialRampToValueAtTime(2600, t + dur * 0.3); bp.frequency.exponentialRampToValueAtTime(1200, t + dur); bp.Q.value = 1.2;
      const ng = env(ctx, t, 0.04, dur, g0 * 0.5); nz.connect(bp); bp.connect(ng); ng.connect(out); nz.start(t); nz.stop(t + dur + 0.05); nodes.push(nz);
      return nodes;
    },

    // ZEBRA -- the plains zebra's call is not a whinny: a YELPING BRAY, "kwa-ha-ha-ha", a run of barks that alternate
    // a higher breathy intake-yelp and a lower exhaled honk, the run speeding and falling. Each bark: a band-limited
    // voiced tone (triangle + a sawtooth tamed by a formant bandpass) gliding down, with a rasp of air at its onset.
    zebra(ctx, dest, t, o) {
      o = o || {}; const px = o.pan == null ? 0 : o.pan, out = panTo(ctx, dest, px), nodes = [], g0 = o.gain || 0.12;
      const n = 6; let tt = t;
      for (let i = 0; i < n; i++) {
        const up = i % 2 === 0, f0 = (up ? 640 : 420) * (1 - 0.035 * i), f1 = f0 * (up ? 0.72 : 0.80);
        const dur = up ? 0.15 : 0.21, amp = g0 * (1 - 0.08 * i);
        const osc = ctx.createOscillator(); osc.type = 'sawtooth';
        osc.frequency.setValueAtTime(f0, tt); osc.frequency.exponentialRampToValueAtTime(f1, tt + dur);
        const fm = ctx.createBiquadFilter(); fm.type = 'bandpass'; fm.frequency.value = up ? 1500 : 1050; fm.Q.value = 2.2;
        const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 3600;
        const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, tt); g.gain.exponentialRampToValueAtTime(amp, tt + 0.018);
        g.gain.exponentialRampToValueAtTime(amp * 0.5, tt + dur * 0.7); g.gain.exponentialRampToValueAtTime(0.0001, tt + dur);
        osc.connect(fm); fm.connect(lp); lp.connect(g); g.connect(out); osc.start(tt); osc.stop(tt + dur + 0.03); nodes.push(osc);
        const tri = ctx.createOscillator(); tri.type = 'triangle'; tri.frequency.setValueAtTime(f0 * 0.5, tt); tri.frequency.exponentialRampToValueAtTime(f1 * 0.5, tt + dur);
        const tg = env(ctx, tt, 0.02, dur, amp * 0.6); tri.connect(tg); tg.connect(out); tri.start(tt); tri.stop(tt + dur + 0.03); nodes.push(tri);
        const nz = noiseSrc(ctx), bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = up ? 2400 : 1600; bp.Q.value = 1.4;
        const ng = env(ctx, tt, 0.008, 0.06, amp * (up ? 0.55 : 0.35)); nz.connect(bp); bp.connect(ng); ng.connect(out); nz.start(tt); nz.stop(tt + 0.08); nodes.push(nz);
        tt += dur + (up ? 0.035 : 0.06) * (1 - 0.06 * i);
      }
      return nodes;
    },

    // GIRAFFE -- nearly silent by day; what it has is a HUM, low and steady, heard from giraffes at night (about
    // 92 Hz with a few harmonics, a slow swell and a gentle flutter) -- and a soft SNORT (o.variant 'snort').
    giraffe(ctx, dest, t, o) {
      o = o || {}; const px = o.pan == null ? 0 : o.pan, out = panTo(ctx, dest, px), nodes = [];
      if (o.variant === 'snort') {
        const nz = noiseSrc(ctx), lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 700;
        const ng = env(ctx, t, 0.02, 0.35, o.gain || 0.2); nz.connect(lp); lp.connect(ng); ng.connect(out); nz.start(t); nz.stop(t + 0.4); nodes.push(nz);
        return nodes;
      }
      const g0 = o.gain || 0.16, dur = 2.6;
      [[92, 1.0, 'sine'], [184, 0.42, 'sine'], [276, 0.2, 'triangle'], [368, 0.08, 'sine']].forEach(([f, amp, ty]) => {
        const osc = ctx.createOscillator(); osc.type = ty; osc.frequency.setValueAtTime(f, t); osc.frequency.linearRampToValueAtTime(f * 0.97, t + dur);
        const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(g0 * amp, t + 0.5);
        g.gain.setValueAtTime(g0 * amp, t + dur * 0.7); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
        const tr = ctx.createGain(); tr.gain.value = 1;
        const lfo = ctx.createOscillator(); lfo.frequency.value = 4.2; const lg = ctx.createGain(); lg.gain.value = 0.12; lfo.connect(lg); lg.connect(tr.gain);
        const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900;
        osc.connect(tr); tr.connect(g); g.connect(lp); lp.connect(out); osc.start(t); osc.stop(t + dur + 0.1); lfo.start(t); lfo.stop(t + dur + 0.1); nodes.push(osc, lfo);
      });
      return nodes;
    },

    // LION -- a lion's call is a BOUT, not a noise: a soft moan or two, then full ROARS, then a run of GRUNTS fading
    // away. Each call is a band-limited buzz (a PeriodicWave of rolled-off harmonics, never a raw saw) with a growl
    // an octave down (the subharmonic that makes a roar a roar), roughened by jitter and a noise-driven shimmer, voiced
    // through three formant bandpasses that OPEN and close (the jaw dropping on "aaooo" and closing on "uu"), with
    // breath through the same formants. Energy stays low: a lion's roar carries because it is BELOW 1 kHz.
    // o.variant: 'roar' | 'moan' | 'grunt' | 'bout' (default: a short bout); o.sex 'female' pitches it up
    lion(ctx, dest, t, o) {
      o = o || {}; const px = o.pan == null ? 0 : o.pan, out = panTo(ctx, dest, px), nodes = [];
      const hi = o.sex === 'female' ? 1.3 : 1.0, g0 = o.gain || 0.22;
      if (!ctx.__lionWave) {
        const N = 48, re = new Float32Array(N + 1), im = new Float32Array(N + 1);
        for (let n = 1; n <= N; n++) im[n] = Math.pow(n, -1.25) * (n % 2 ? 1 : 0.8);
        ctx.__lionWave = ctx.createPeriodicWave(re, im);
      }
      const call = (at, dur, fa, fb, fc, amp, open, rough) => {
        const sum = ctx.createGain(); sum.gain.value = 1;
        const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2400; lp.Q.value = 0.5;
        const ge = ctx.createGain(); ge.gain.setValueAtTime(0.0001, at);
        ge.gain.exponentialRampToValueAtTime(amp, at + Math.min(0.18, dur * 0.25));
        ge.gain.setValueAtTime(amp, at + dur * 0.62); ge.gain.exponentialRampToValueAtTime(0.0001, at + dur);
        sum.connect(lp); lp.connect(ge); ge.connect(out);
        // formants: the vocal tract opening then closing ("a-o-u")
        const F = [[380, 700, 430, 5, 1.0], [820, 1250, 760, 6, 0.55], [2150, 2350, 2050, 7, 0.18]];
        const bands = F.map(([f1, f2, f3, q, w]) => {
          const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = q;
          bp.frequency.setValueAtTime(f1, at); bp.frequency.linearRampToValueAtTime(f1 + (f2 - f1) * open, at + dur * 0.4);
          bp.frequency.linearRampToValueAtTime(f3, at + dur);
          const bw = ctx.createGain(); bw.gain.value = w * 3.2; bp.connect(bw); bw.connect(sum); return bp;
        });
        // shimmer: a noise-driven wobble of the source's gain (roughness)
        const sh = ctx.createGain(); sh.gain.value = 1;
        const nz = noiseSrc(ctx), nlp = ctx.createBiquadFilter(); nlp.type = 'lowpass'; nlp.frequency.value = 55;
        const nd = ctx.createGain(); nd.gain.value = 2.1 * rough; nz.connect(nlp); nlp.connect(nd); nd.connect(sh.gain);
        nz.start(at); nz.stop(at + dur + 0.05); nodes.push(nz);
        bands.forEach(bp => sh.connect(bp));
        // the voice and its growl an octave down, pitch rising into the call and falling out, with jitter
        [[1.0, 1.0], [0.5, 0.7 * rough]].forEach(([mult, w]) => {
          const osc = ctx.createOscillator(); osc.setPeriodicWave(ctx.__lionWave);
          osc.frequency.setValueAtTime(fa * mult, at); osc.frequency.linearRampToValueAtTime(fb * mult, at + dur * 0.35);
          osc.frequency.linearRampToValueAtTime(fc * mult, at + dur);
          const jit = ctx.createOscillator(); jit.frequency.value = 23 + 9 * Math.random(); const jg = ctx.createGain();
          jg.gain.value = fb * mult * 0.038 * rough; jit.connect(jg); jg.connect(osc.frequency);
          const og = ctx.createGain(); og.gain.value = w; osc.connect(og); og.connect(sh);
          osc.start(at); osc.stop(at + dur + 0.05); jit.start(at); jit.stop(at + dur + 0.05); nodes.push(osc, jit);
        });
        // breath: the roar's rasp, through the same tract
        const br = noiseSrc(ctx), bg = ctx.createGain(); bg.gain.value = 0.9 * rough; br.connect(bg);
        bands.forEach(bp => bg.connect(bp)); br.start(at); br.stop(at + dur + 0.05); nodes.push(br);
      };
      const moan = (at) => call(at, 1.3, 105 * hi, 150 * hi, 115 * hi, g0 * 0.45, 0.35, 0.5);
      const roar = (at, k) => call(at, 1.9 - 0.15 * k, 150 * hi, 205 * hi, 120 * hi, g0 * (1 - 0.08 * k), 1.0, 1.0);
      const grunt = (at, k) => call(at, 0.42, 125 * hi, 132 * hi, 96 * hi, g0 * 0.85 * Math.pow(0.86, k), 0.55, 0.9);
      const v = o.variant || 'bout';
      if (v === 'roar') roar(t, 0);
      else if (v === 'moan') moan(t);
      else if (v === 'grunt') grunt(t, 0);
      else {
        let tt = t; moan(tt); tt += 1.7;
        for (let k = 0; k < 2; k++) { roar(tt, k); tt += 2.3 - 0.2 * k; }
        for (let k = 0; k < 6; k++) { grunt(tt, k); tt += 0.95 - 0.06 * k; }
      }
      return nodes;
    },

    // SONGBIRDS -- four ear-verified garden species (a chorus with character, not one voice). o.variant picks one.
    blackbird(ctx, dest, t, o) { o = o || {}; return phrase(ctx, dest, t, [[1080,0.17,1170],[970,0.19,910],[1250,0.15,1160],[890,0.24,850]], o.gain || 0.16, 'sine', o.pan == null ? R(-0.8, 0.8) : o.pan); },   // fluty, leisurely falling warble
    robin(ctx, dest, t, o) { o = o || {}; const n = []; let f = 2450; for (let i = 0; i < 10; i++) { n.push([f + (Math.random() - 0.5) * 180, 0.042, f - 110, 0.008]); f -= 88; } return phrase(ctx, dest, t, n, o.gain || 0.10, 'sine', o.pan == null ? R(-0.8, 0.8) : o.pan); },   // high silvery trickling cascade
    chaffinch(ctx, dest, t, o) { o = o || {}; const n = []; let f = 1720, d = 0.11; for (let i = 0; i < 6; i++) { n.push([f, d, f - 45, 0.006]); f -= 105; d *= 0.85; } n.push([1280, 0.17, 1680, 0.02]); return phrase(ctx, dest, t, n, o.gain || 0.14, 'sine', o.pan == null ? R(-0.8, 0.8) : o.pan); },   // accelerating descending run + a flourish
    greattit(ctx, dest, t, o) { o = o || {}; const n = []; for (let i = 0; i < 3; i++) { n.push([1580, 0.12, 1580, 0.02]); n.push([1170, 0.12, 1170, 0.07]); } return phrase(ctx, dest, t, n, o.gain || 0.14, 'sine', o.pan == null ? R(-0.8, 0.8) : o.pan); },   // "tea-cher, tea-cher"
    songbird(ctx, dest, t, o) { o = o || {}; const which = o.variant || ['blackbird', 'robin', 'chaffinch', 'greattit'][Math.floor(Math.random() * 4)]; return VOICES[which](ctx, dest, t, o); },   // a random garden species

    // DOVE / wood-pigeon -- a soft low "coo-COO-coo" (ear-verified on the jacaranda; low triangle syllables, lowpassed)
    dove(ctx, dest, t, o) {
      o = o || {};
      const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900; lp.connect(panTo(ctx, dest, o.pan == null ? R(-0.5, 0.5) : o.pan));
      const syl = [[0,0.20,430,0.06],[0.24,0.34,500,0.08],[0.64,0.18,430,0.06],[0.94,0.16,466,0.06],[1.16,0.32,414,0.07]], nodes = [];
      const vs = o.gain ? o.gain / 0.07 : 1;
      syl.forEach(n => { const off=n[0], dur=n[1], f=n[2], vol=n[3]*vs; const g = ctx.createGain(); g.gain.value = 0.0001; g.connect(lp);
        const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.value = f; osc.connect(g);
        g.gain.setValueAtTime(0.0001, t+off); g.gain.exponentialRampToValueAtTime(vol, t+off+0.05); g.gain.exponentialRampToValueAtTime(0.0001, t+off+dur);
        osc.start(t+off); osc.stop(t+off+dur+0.05); nodes.push(osc); });
      return nodes;
    },
    // DUCK -- a nasal descending "quack quack" series (female mallard), triangle through a nasal formant
    duck(ctx, dest, t, o) {
      o = o || {}; const px = o.pan == null ? R(-0.6, 0.6) : o.pan, nodes = []; const n = RI(3, 5); let vol = o.gain || 0.32;
      for (let i = 0; i < n; i++) { const at = t + i*0.19 + R(-0.01, 0.01), f = R(470, 520);
        const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(f, at); osc.frequency.exponentialRampToValueAtTime(f*0.72, at+0.11);
        const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 920; bp.Q.value = 1.8;   // the nasal formant (near the 2nd harmonic so it carries)
        const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, at); g.gain.exponentialRampToValueAtTime(vol, at+0.012); g.gain.exponentialRampToValueAtTime(0.0001, at+0.13);
        osc.connect(bp); bp.connect(g); g.connect(panTo(ctx, dest, px)); osc.start(at); osc.stop(at+0.16); nodes.push(osc); vol *= 0.88; }
      return nodes;
    },
    // CROW -- a harsh "caw" (bandpassed-noise rasp sweeping down + a low buzzy body); sometimes a double caw
    crow(ctx, dest, t, o) {
      o = o || {}; const px = o.pan == null ? R(-0.6, 0.6) : o.pan, nodes = [], caws = Math.random() < 0.5 ? 2 : 1;
      for (let c = 0; c < caws; c++) { const at = t + c*0.42;
        const nz = noiseSrc(ctx);
        const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, at); bp.frequency.exponentialRampToValueAtTime(900, at+0.28); bp.Q.value = 2.6;
        const g = env(ctx, at, 0.02, 0.3, o.gain || 0.12);
        nz.connect(bp); bp.connect(g); g.connect(panTo(ctx, dest, px)); nz.start(at); nz.stop(at+0.36); nodes.push(nz);
        const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(mtof(RI(48,52)), at); osc.frequency.exponentialRampToValueAtTime(mtof(44), at+0.28);
        const trem = ctx.createGain(); trem.gain.value = 0.55; const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 32; const lg = ctx.createGain(); lg.gain.value = 0.45; lfo.connect(lg); lg.connect(trem.gain);
        const g2 = env(ctx, at, 0.02, 0.28, (o.gain || 0.12)*0.55); osc.connect(trem); trem.connect(g2); g2.connect(panTo(ctx, dest, px)); osc.start(at); osc.stop(at+0.34); lfo.start(at); lfo.stop(at+0.34); nodes.push(osc, lfo); }
      return nodes;
    },

    // HERON -- a hoarse marsh-bird REPERTOIRE, not one squawk. variants:
    //   'grey' (default) the harsh grey-heron flight "fraaank" -- a glottal CRACK onset + a tearing rasp on a
    //     rise-then-fall contour + a buzzy body, sometimes doubled (frank-frank); 'night' a flat abrupt
    //     night-heron "quok" bark (short, low, flatter); 'clatter' a dry bill-clatter (a wooden mandible rattle).
    heron(ctx, dest, t, o) {
      o = o || {};
      const v = o.variant || 'grey';
      const px = o.pan == null ? R(-0.6, 0.6) : o.pan, out = panTo(ctx, dest, px), g0 = o.gain || 0.11, nodes = [];

      if (v === 'clatter') {   // bill-clatter -- a dry wooden rattle of short click grains, rattling then slowing
        const nk = RI(11, 17); let tt = t;
        for (let k = 0; k < nk; k++) {
          const frac = k / (nk - 1), gap = 0.018 + 0.024 * Math.abs(frac - 0.4);   // fastest in the middle, slower at the ends
          const nz = noiseSrc(ctx);
          const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2300 * (1 + R(-0.08, 0.08)); bp.Q.value = 4.5;   // a hollow wooden tock
          const g = env(ctx, tt, 0.004, 0.017, g0 * 1.5 * (0.6 + 0.4 * Math.sin(frac * Math.PI)));   // a little swell across the rattle
          nz.connect(bp); bp.connect(g); g.connect(out); nz.start(tt); nz.stop(tt + 0.03); nodes.push(nz);
          tt += gap;
        }
        return nodes;
      }

      if (v === 'night') {   // night-heron "quok" -- a flat, abrupt bark (short, low, flatter than the fraaank), sometimes doubled
        const barks = Math.random() < 0.35 ? 2 : 1;
        for (let c = 0; c < barks; c++) {
          const at = t + c * 0.28, f = mtof(RI(37, 41));
          const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(f * 1.06, at); osc.frequency.exponentialRampToValueAtTime(f, at + 0.05);
          const trem = ctx.createGain(); trem.gain.value = 0.6; const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 42;
          const lg = ctx.createGain(); lg.gain.value = 0.4; lfo.connect(lg); lg.connect(trem.gain);
          const g = env(ctx, at, 0.008, 0.15, g0); osc.connect(trem); trem.connect(g); g.connect(out); osc.start(at); osc.stop(at + 0.2); lfo.start(at); lfo.stop(at + 0.2); nodes.push(osc, lfo);
          const nz = noiseSrc(ctx); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 780; bp.Q.value = 1.6;
          const ng = env(ctx, at, 0.006, 0.12, g0 * 0.5); nz.connect(bp); bp.connect(ng); ng.connect(out); nz.start(at); nz.stop(at + 0.16); nodes.push(nz);
        }
        return nodes;
      }

      // 'grey' -- the harsh flight "fraaank"
      const blast = (at, vol) => {
        // the glottal CRACK at the very onset (a "kkk" transient)
        const cr = noiseSrc(ctx); const cbp = ctx.createBiquadFilter(); cbp.type = 'bandpass'; cbp.frequency.value = 2000; cbp.Q.value = 1.2;
        const cg = env(ctx, at, 0.002, 0.028, vol * 0.7); cr.connect(cbp); cbp.connect(cg); cg.connect(out); cr.start(at); cr.stop(at + 0.05); nodes.push(cr);
        // the rasp: bandpassed noise on a rise-then-fall contour, with a tearing roughness tremolo
        const nz = noiseSrc(ctx);
        const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1150, at); bp.frequency.linearRampToValueAtTime(1550, at + 0.05); bp.frequency.exponentialRampToValueAtTime(700, at + 0.32); bp.Q.value = 2.4;
        const rough = ctx.createGain(); rough.gain.value = 0.62; const rl = ctx.createOscillator(); rl.type = 'sine'; rl.frequency.value = 52;
        const rlg = ctx.createGain(); rlg.gain.value = 0.38; rl.connect(rlg); rlg.connect(rough.gain);
        const gn = ctx.createGain(); gn.gain.setValueAtTime(0.0001, at); gn.gain.exponentialRampToValueAtTime(vol * 0.9, at + 0.02); gn.gain.exponentialRampToValueAtTime(0.0001, at + 0.34);
        nz.connect(bp); bp.connect(rough); rough.connect(gn); gn.connect(out); nz.start(at); rl.start(at); nz.stop(at + 0.4); rl.stop(at + 0.4); nodes.push(nz, rl);
        // the low buzzy body, tracking the same fall
        const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(mtof(RI(41, 45)), at); osc.frequency.exponentialRampToValueAtTime(mtof(34), at + 0.3);
        const trem = ctx.createGain(); trem.gain.value = 0.55; const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 30; const lg = ctx.createGain(); lg.gain.value = 0.45; lfo.connect(lg); lg.connect(trem.gain);
        const g2 = env(ctx, at, 0.02, 0.32, vol * 0.7); osc.connect(trem); trem.connect(g2); g2.connect(out); osc.start(at); osc.stop(at + 0.4); lfo.start(at); lfo.stop(at + 0.4); nodes.push(osc, lfo);
      };
      blast(t, g0);
      if (Math.random() < 0.35) blast(t + R(0.4, 0.5), g0 * 0.82);   // sometimes a double frank-frank
      return nodes;
    },

    // TREE FROG -- a low tremolo'd "brrrp" croak. variants: 'green'(default), 'bull'(lower/slower), 'peeper'(high clean whistle)
    treefrog(ctx, dest, t, o) {
      o = o || {};
      const v = o.variant || 'green';
      const px = o.pan == null ? R(-0.7, 0.7) : o.pan, out = panTo(ctx, dest, px), g0 = o.gain || 0.34;   // the grain-gate eats level, so drive harder
      if (v === 'peeper') {   // spring peeper -- a clean, high, rising whistle "peep"
        const f = mtof(RI(64, 69)), osc = ctx.createOscillator(); osc.type = 'sine';
        osc.frequency.setValueAtTime(f * 0.97, t); osc.frequency.exponentialRampToValueAtTime(f, t + 0.05);
        const g = env(ctx, t, 0.02, R(0.07, 0.11), g0 * 0.42); osc.connect(g); g.connect(out); osc.start(t); osc.stop(t + 0.2); return [osc];   // (ungated sine -> scale down)
      }
      // GREEN ("gunk", short + plucked) / BULL ("jug-o-rum", low + slow): a RASPY PULSED croak, not a smooth AM.
      // A shared grain-gate (looped sharp-pulse env) chops BOTH a triangle body AND a whisper of bandpassed noise
      // into the same pulse train -- the vocal RASP a real frog has.
      const bull = v === 'bull', f = mtof(bull ? RI(35, 41) : RI(44, 50)), dd = bull ? R(0.34, 0.46) : R(0.15, 0.24), pulseHz = bull ? 26 : 40;
      const pbuf = pulseEnvBuf(ctx, pulseHz, 9);
      const gate = () => { const tr = ctx.createGain(); tr.gain.value = 0.14; const pe = ctx.createBufferSource(); pe.buffer = pbuf; pe.loop = true; const pg = ctx.createGain(); pg.gain.value = 0.9; pe.connect(pg); pg.connect(tr.gain); pe.start(t); pe.stop(t + dd + 0.05); return { tr, pe }; };
      const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(f * 1.06, t); osc.frequency.exponentialRampToValueAtTime(f, t + dd * 0.6);
      const gt1 = gate(), g1 = env(ctx, t, 0.03, dd, g0); osc.connect(gt1.tr); gt1.tr.connect(g1); g1.connect(out);
      const nz = noiseSrc(ctx); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = f * 3; bp.Q.value = 3;
      const gt2 = gate(), g2 = env(ctx, t, 0.03, dd, g0 * 0.28); nz.connect(bp); bp.connect(gt2.tr); gt2.tr.connect(g2); g2.connect(out);
      osc.start(t); osc.stop(t + dd + 0.05); nz.start(t); nz.stop(t + dd + 0.05);
      return [osc, nz, gt1.pe, gt2.pe];
    },

    // CRICKET / insect bed -- bandpassed noise with a fast trill tremolo. A TEXTURE, not a pitched drone.
    // continuous if o.dur, else one short chirp. variants: 'field'(default 4.3k), 'tree'(5.2k), 'katydid'(6.8k slow)
    cricket(ctx, dest, t, o) {
      o = o || {};
      const v = o.variant || 'field';
      const cf = v === 'katydid' ? 6800 : v === 'tree' ? 5200 : 4300;
      const trillHz = v === 'katydid' ? 22 : v === 'field' ? 43 : 33;
      const n = noiseSrc(ctx);
      const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = cf; bp.Q.value = o.Q || 9;
      const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cf + 4000; lp.Q.value = 0.7;   // trim the noise skirt -- a real cricket has little energy above ~9kHz
      // GRAINY trill: a looped sharp-pulse envelope GATES the noise into distinct chirp GRAINS (not a smooth AM buzz)
      const trem = ctx.createGain(); trem.gain.value = 0.06;   // a faint continuous floor under the grains
      const pe = ctx.createBufferSource(); pe.buffer = pulseEnvBuf(ctx, trillHz, v === 'katydid' ? 10 : 16); pe.loop = true;
      const pg = ctx.createGain(); pg.gain.value = 0.95; pe.connect(pg); pg.connect(trem.gain);
      const g = ctx.createGain(); g.gain.value = o.gain == null ? 0.11 : o.gain;   // grains are peaky / low-RMS, so drive harder than the old smooth bed
      // a hair of PITCH WAVER -- a living insect, not a dead metronomic tone (from the-field-goes-quiet)
      const wav = ctx.createOscillator(); wav.type = 'sine'; wav.frequency.value = 0.15 + Math.random() * 0.2;
      const wavAmt = ctx.createGain(); wavAmt.gain.value = cf * 0.004; wav.connect(wavAmt); wavAmt.connect(bp.frequency);
      // a slow PRESENCE SWELL -- the voice waxes + wanes on its own clock, so a bed BREATHES instead of droning
      const pres = ctx.createGain(); pres.gain.value = 0.72;
      const presLfo = ctx.createOscillator(); presLfo.type = 'sine'; presLfo.frequency.value = 0.03 + Math.random() * 0.06;
      const presAmt = ctx.createGain(); presAmt.gain.value = 0.32; presLfo.connect(presAmt); presAmt.connect(pres.gain);
      n.connect(bp); bp.connect(lp); lp.connect(trem); trem.connect(g); g.connect(pres); pres.connect(panTo(ctx, dest, o.pan == null ? -0.28 : o.pan));
      n.start(t); pe.start(t); wav.start(t); presLfo.start(t);
      if (o.dur) { n.stop(t + o.dur); pe.stop(t + o.dur); wav.stop(t + o.dur); presLfo.stop(t + o.dur); }
      return [n, pe, wav, presLfo];
    },

    // FIELD CRICKET -- a discrete CHIRP (a little group of 3 raspy triangle pulses): "chirp ... chirp ... chirp".
    // a one-shot chirper (schedule it repeatedly for a scattered field of them), vs cricket()'s continuous trill bed.
    fieldcricket(ctx, dest, t, o) {
      o = o || {}; const freq = o.freq || (4300 + Math.random() * 700), out = panTo(ctx, dest, o.pan == null ? R(-0.7, 0.7) : o.pan), level = o.gain || 0.09, nodes = [];
      for (let k = 0; k < 3; k++) { const tt = t + k * 0.028;
        const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.value = freq * (1 + (Math.random() - 0.5) * 0.015);
        const g = ctx.createGain(); g.gain.setValueAtTime(0, tt); g.gain.linearRampToValueAtTime(level, tt + 0.004); g.gain.exponentialRampToValueAtTime(0.0005, tt + 0.021);
        osc.connect(g); g.connect(out); osc.start(tt); osc.stop(tt + 0.03); nodes.push(osc); }
      return nodes;
    },

    // WATER -- a soft pitch-dropping "bloop" (a drip / a rising bubble breaking the surface)
    water(ctx, dest, t, o) {
      o = o || {};
      const v = o.variant || ['drip', 'plip', 'plop'][Math.floor(Math.random() * 3)];   // a random small water sound by default
      const px = o.pan == null ? R(-0.6, 0.6) : o.pan, out = panTo(ctx, dest, px), g0 = o.gain || 0.06;
      if (v === 'lap') {   // a soft wash of water lapping -- filtered noise on a gentle swell
        const nz = noiseSrc(ctx); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 480; bp.Q.value = 0.9;
        const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(g0 * 0.9, t + 0.14); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5);
        nz.connect(bp); bp.connect(g); g.connect(out); nz.start(t); nz.stop(t + 0.6); return [nz];
      }
      // DRIP / PLIP (small, high, quick) / PLOP (big, low, + a splash transient): a pitch-dropping sine bloop
      const hi = v === 'plip' ? R(760, 1000) : v === 'plop' ? R(360, 500) : R(520, 760);
      const lo = v === 'plip' ? R(220, 320) : v === 'plop' ? R(90, 140) : R(120, 190);
      const dur = v === 'plip' ? 0.08 : v === 'plop' ? 0.2 : 0.16;
      const osc = ctx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(hi, t); osc.frequency.exponentialRampToValueAtTime(lo, t + dur * 0.7);
      const g = env(ctx, t, 0.006, dur, g0 * (v === 'plop' ? 1.3 : 1)); osc.connect(g); g.connect(out); osc.start(t); osc.stop(t + dur + 0.05);
      const nodes = [osc];
      if (v === 'plop') {   // a tiny splash-noise transient for the bigger disturbance
        const nz = noiseSrc(ctx); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1200; bp.Q.value = 1.5;
        const ng = env(ctx, t, 0.002, 0.05, g0 * 0.5); nz.connect(bp); bp.connect(ng); ng.connect(out); nz.start(t); nz.stop(t + 0.08); nodes.push(nz);
      }
      return nodes;
    },

    // REEDS / WIND -- a breeze moving THROUGH dry sedge, not just wind. A low bandpassed-noise wind bed whose
    // gusts BRIGHTEN the filter (more air = brighter, not only louder) + swell the loudness, PLUS a sparse dry
    // STEM-RUSTLE (pulse-gated higher-noise taps that rise on the gusts -- the stems knocking). continuous bed.
    reeds(ctx, dest, t, o) {
      o = o || {};
      const cf = o.cf || 1700, out = panTo(ctx, dest, o.pan == null ? 0.12 : o.pan), g0 = o.gain == null ? 0.016 : o.gain;
      // one slow GUST signal in ~[-1,1] (two incommensurate LFOs), shared by loudness + brightness + rustle
      const gust = ctx.createGain(); gust.gain.value = 1;
      const l1 = ctx.createOscillator(); l1.type = 'sine'; l1.frequency.value = 0.13; const l1g = ctx.createGain(); l1g.gain.value = 0.6; l1.connect(l1g); l1g.connect(gust);
      const l2 = ctx.createOscillator(); l2.type = 'sine'; l2.frequency.value = 0.071; const l2g = ctx.createGain(); l2g.gain.value = 0.4; l2.connect(l2g); l2g.connect(gust);
      // 1) the wind hiss -- gusts swell loudness AND open the filter
      const n = noiseSrc(ctx);
      const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = cf; bp.Q.value = 0.8;
      const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 5500; lp.Q.value = 0.7;   // wind in reeds is LOW -- trim the hiss
      const g = ctx.createGain(); g.gain.value = g0;
      const ampAmt = ctx.createGain(); ampAmt.gain.value = g0 * 0.5; gust.connect(ampAmt); ampAmt.connect(g.gain);
      const brightAmt = ctx.createGain(); brightAmt.gain.value = cf * 0.5; gust.connect(brightAmt); brightAmt.connect(bp.frequency);
      n.connect(bp); bp.connect(lp); lp.connect(g); g.connect(out);
      // 2) the dry STEM-RUSTLE -- sparse higher-noise taps (pulse-gated) that swell on the gusts (stems knocking)
      const rn = noiseSrc(ctx);
      const rbp = ctx.createBiquadFilter(); rbp.type = 'bandpass'; rbp.frequency.value = 3100; rbp.Q.value = 1.8;
      const rlp = ctx.createBiquadFilter(); rlp.type = 'lowpass'; rlp.frequency.value = 6500; rlp.Q.value = 0.7;
      const grain = ctx.createGain(); grain.gain.value = 0;
      const pe = ctx.createBufferSource(); pe.buffer = pulseEnvBuf(ctx, 8, 20); pe.loop = true; const pg = ctx.createGain(); pg.gain.value = 1; pe.connect(pg); pg.connect(grain.gain);
      const rg = ctx.createGain(); rg.gain.value = g0 * 0.35;
      const rAmt = ctx.createGain(); rAmt.gain.value = g0 * 0.55; gust.connect(rAmt); rAmt.connect(rg.gain);
      rn.connect(rbp); rbp.connect(rlp); rlp.connect(grain); grain.connect(rg); rg.connect(out);
      n.start(t); rn.start(t); l1.start(t); l2.start(t); pe.start(t);
      if (o.dur) { n.stop(t + o.dur); rn.stop(t + o.dur); l1.stop(t + o.dur); l2.stop(t + o.dur); pe.stop(t + o.dur); }
      return [n, rn, l1, l2, pe];
    }
  };

  // dispatch one voice
  function play(ctx, dest, name, t, o) { const v = VOICES[name]; return v ? v(ctx, dest, t, o || {}) : null; }

  // ---- a ready-made SOUNDSCAPE: a scheduler-agnostic marsh-night bed + a random one-shot picker ----
  // beds(ctx,dest,t,{dur}) starts the continuous texture; tick(ctx,dest,now) fires one round of random calls.
  const SCENES = {
    marshNight: {
      beds(ctx, dest, t, o) { o = o || {}; return [].concat(VOICES.cricket(ctx, dest, t, { dur: o.dur }), VOICES.reeds(ctx, dest, t, { dur: o.dur })); },
      tick(ctx, dest, now) {
        if (Math.random() < 0.72) {   // frog CALL-AND-RESPONSE: a marsh is conversational, not isolated croaks
          const p1 = R(-0.82, 0.82); VOICES.treefrog(ctx, dest, now + R(0, 0.4), { pan: p1 });
          if (Math.random() < 0.55) VOICES.treefrog(ctx, dest, now + R(0.6, 1.3), { pan: -p1 * R(0.6, 1) });      // another frog ANSWERS from across the water
          if (Math.random() < 0.20) VOICES.treefrog(ctx, dest, now + R(1.4, 2.2), { pan: R(-0.8, 0.8), variant: 'bull' });   // a deep bullfrog joins the round
        }
        if (Math.random() < 0.13) VOICES.owl(ctx, dest, now + R(0, 0.9), {});
        if (Math.random() < 0.11) {   // the heron: at night the flat "quok" bark dominates; the harsh fraaank is rarer; an occasional bill-clatter
          const r = Math.random(), hv = r < 0.6 ? 'night' : r < 0.9 ? 'grey' : 'clatter';
          VOICES.heron(ctx, dest, now + R(0, 0.8), { variant: hv });
        }
        if (Math.random() < 0.32) VOICES.water(ctx, dest, now + R(0, 1.1), {});
      },
      nextDelay() { return R(1500, 3000); }   // ms between ticks
    },
    dayGarden: {
      beds(ctx, dest, t, o) { o = o || {}; return VOICES.reeds(ctx, dest, t, { dur: o.dur, cf: 2600, gain: 0.010 }); },   // a soft leaf-rustle breeze
      tick(ctx, dest, now) { if (Math.random() < 0.85) VOICES.songbird(ctx, dest, now + R(0, 0.5), {}); },
      nextDelay() { return R(1100, 3300); }
    }
  };

  return { play, VOICES, SCENES, names: () => Object.keys(VOICES), scenes: () => Object.keys(SCENES) };
});