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) };
});