Introduction
sndlab is a graphical environment for designing procedural audio in Rust. You write patches — small Rhai scripts that describe a sound — and sndlab evaluates them, plays them through Kira, shows the rendered waveform in a live scope, and logs what’s happening underneath. It’s a Strudel-style tight iteration loop, but the patches you design here can be embedded into shipped Rust binaries with no porting tax: the same crate that plays them in sndlab plays them in your game.
What sndlab is for
- Designing game sound effects. Sonar pings, hull creaks, weapon one-shots — anything a game engine triggers as a procedurally- generated buffer.
- Designing ambient layers. Looping, modulated background sounds that respond to game state (depth, speed, damage).
- A/B-ing combinations. Hear the sonar ping over the ocean rumble layer at the same time, in the same mix you’ll ship.
- AI-collaborative sound design. sndlab exposes its editor buffer over MCP, so an AI assistant can edit the same script you’re looking at and you can hear the result without leaving the loop.
What sndlab is not
- Not a DAW. No timeline, no MIDI, no automation curves. If you want to write a song, this is not the tool.
- Not a livecoding instrument. Strudel is better at performing patterns; sndlab is better at iterating on a single sound until it’s right.
- Not a music-theory tool. No notes, no scales, no chord progressions.
How patches play
Every primitive in the DSL builds a node in a lazy Signal graph.
What happens to that graph depends on the patch’s role:
- One-shot patches (a ping, a hit, a UI click) get rendered to a
finite buffer at eval time, driven by whatever
take/chirp/fade_outduration the graph specifies. The buffer plays through Kira’s low-latency one-shot path. - Ambient patches (ocean rumble, machinery hum) are generated: a fresh runner ticks the graph at audio rate for as long as the ambient stays enabled. There’s no buffer and no loop — the graph just runs.
The DSL is the same either way. sine(440) is sine(440) whether
it ends up in a one-shot or an ambient — only the duration context
differs.
How it’s built
Two crates:
sndlab-core— the embeddable engine: a Rhai interpreter, the patch DSL, and Kira-based playback. Library; no UI.sndlab— the GUI binary: an eframe window hosting a syntax-highlighted code editor, a waveform scope, a log pane, the project model, and the MCP server. Depends onsndlab-core.
When you ship a game, you embed sndlab-core and load .rhai patches
the same way sndlab itself does. There is no “production” version of a
patch separate from the “design” version — the same script runs in
both.
A quick look
A complete patch looks like this:
patch("sonar_ping", "one_shot", || {
let body = sine(330.0, 3.5).env(0.008, 1.4).gain(0.32);
let reverb = [
tap(0.13, 0.55),
tap(0.31, 0.38),
tap(0.58, 0.26),
tap(0.95, 0.17),
];
body.with_taps(reverb)
});
That’s all the design tooling needs to know about. The GUI handles evaluating, playing, error reporting, and (optionally) collaborating with an AI agent over MCP.
Install and run
Prerequisites
- A recent stable Rust toolchain (
rustc --version≥ 1.75). - An audio device the operating system can open.
- A graphical display (X11 or Wayland on Linux, native windowing on macOS and Windows).
- Linux only: ALSA dev headers (
libasound2-devon Debian/Ubuntu,alsa-lib-develon Fedora,alsa-libon Arch). Kira builds against them via cpal. eframe’s default desktop-window features cover X11 and Wayland; if you’re cross-compiling for an embedded target you may need to enable them explicitly.
Build and launch
git clone https://github.com/cmsd2/sndlab
cd sndlab
cargo run -p sndlab --release
A window opens with four panels:
- Toolbar across the top — Eval+Play button, plus a per-patch trigger button for every patch registered by the current script.
- Editor (centre-left) — syntax-highlighted code editor for the
current
.rhaifile. - Scope (right) — live oscilloscope showing the last rendered patch buffer. Updates whenever you eval.
- Log (bottom) — evaluation results, audio playback notifications, warnings, errors.
Hotkeys
| Key | Action |
|---|---|
F5 | Evaluate the buffer. Trigger one-shots / toggle ambients from the patches row below the toolbar. |
| toolbar buttons | Trigger any patch by name |
Quit via the window close button.
Building the docs
cargo install mdbook
mdbook serve book
Then point your browser at http://localhost:3000.
Your first patch
Pending the Rhai engine wiring (task 7). When the engine ships, this chapter walks the reader through writing a 330 Hz sine ping, hearing it, then giving it a reverb tail. Cross-referenced with the sonar ping recipe.
Using sndlab with Claude Code
sndlab runs an MCP server on http://127.0.0.1:7777/mcp that exposes
its editor buffer and engine to any MCP client. The intended client is
Claude Code: the agent reads and
edits the same file you’re looking at; you press F5 to evaluate; you
tell the agent what to tweak. The iteration loop is ~1 second per turn.
Register the server
While sndlab is running, in another terminal:
claude mcp add sndlab http://127.0.0.1:7777/mcp
That’s a one-time setup. Subsequent sndlab launches reuse the same registration as long as the port is free.
Tools exposed
| Tool | Purpose |
|---|---|
get_buffer | Return the current editor buffer (whatever the user is seeing). |
set_buffer | Replace the whole buffer with new content. Use sparingly — prefer apply_edit. |
apply_edit | Find old_string exactly once and replace with new_string. The buffer must contain exactly one match; if it doesn’t, the edit returns an error so you can disambiguate. |
list_patches | List all patches the most recent successful eval registered, with role and duration. |
eval | Re-evaluate the buffer. Errors land in last_error. |
play | Play a named registered patch. The user hears the audio; you don’t. |
last_error | Read the most recent eval/play error, or "no error". |
All tools are synchronous from the MCP side; the actual engine work
runs on the eframe main thread within a single frame (~16 ms) after
the tool returns. eval and play are fire-and-forget — the tool
returns immediately and the result lands in the log + last_error.
A typical iteration
- The user is editing
patches.rhaiin sndlab; you can see them typing because every frame the editor pane updates with their keystrokes (and aget_bufferfrom you reflects the latest). - They say “make the ping a bit lower-pitched.”
- You call
get_bufferto see the current code. - You call
apply_editwithold_string: "sine(330.0,"andnew_string: "sine(280.0,"to drop the pitch. - The next time the user presses F5 (or you call
evalfollowed byplay), they hear the change. - They tell you whether it’s right.
What the agent can’t do
- Hear the audio. Audio plays out of the user’s speakers; the MCP server has no way to capture it. You have to ask them how it sounded.
- Open files. The MCP surface is just the editor buffer — no filesystem access. File loading lands with the project model (task 9).
- Force a save. Even after
set_bufferorapply_edit, the user controls when the file lands on disk.
Troubleshooting
- The status bar at the bottom of sndlab shows the MCP endpoint URL. If it’s missing, the server failed to bind — check the log pane for the error (usually port in use).
- Default port is 7777. If you need to change it, the port is
currently hard-coded in
crates/sndlab/src/app.rs; the next iteration exposes it via a CLI flag.
Patches
A patch is a named, procedurally-generated sound. It is the unit of
work in sndlab. You write a patch by calling the patch function with:
- A name — used for triggering and for cross-references between patches in the same project.
- A role — either
"one_shot"(plays once, dies) or"ambient"(loops continuously until stopped). See Roles. - A body — a Rhai closure that returns a signal expression.
patch("name", "one_shot", || {
sine(440.0, 1.0)
});
The closure runs once at evaluation time. Whatever it returns becomes the signal graph that’s rendered to a buffer when the patch is played. Patches are deterministic — same script, same buffer — which is what makes them safe to bake into a game build.
What’s in a body
Rhai is a real scripting language with variables, conditionals, loops
and arithmetic. The body of a patch is just code; you can compute
parameters, sweep frequencies in a for loop, decide envelope shapes
based on flags. The DSL primitives (sine,
noise, env, …) are the operators
the body uses to describe the sound; everything else around them is
ordinary scripting.
Naming
Patch names are global within a project. Two patches with the same name collide; the second one wins, with a warning in the log. Conventional shape:
- Snake case:
sonar_ping,hull_creak,merchant_screw. - Domain-grouped:
ui_click,weapon_torpedo_launch,ambient_ocean. - Avoid generic names like
testordefault— the more patches you add to a project, the more ambiguous the log gets.
Buffers and sample rate
A buffer is the rendered output of a patch: a contiguous block of audio samples, plus the sample rate they were rendered at. All buffers in sndlab are mono at synthesis time; stereo is applied by the mixer when the buffer plays.
Sample rate
By default sndlab renders at the audio device’s preferred sample rate (typically 48000 Hz on modern hardware, 44100 Hz on older hardware). Patches don’t need to know this — the DSL works in seconds and Hertz, not samples. The engine picks the right number of samples when the buffer is rendered.
If you need to override the rate (e.g. for offline rendering at a
fixed rate for tests), the project manifest exposes a
render_sample_rate field. See The project manifest.
Buffer lengths
Patch length is determined by the patch body. A one-shot is finite — it has a natural end (envelope decays, taps run out). An ambient patch can describe a fixed-length region that the mixer loops, or it can render a long buffer directly. The DSL has no concept of “infinite streams” yet; everything is pre-baked into a buffer at evaluation time.
Mono in, stereo out
The DSL produces a mono signal. The mixer applies panning per-source when the buffer is played (see The mix graph). This keeps the synthesis side simple and lets the same buffer be played at different positions in the field without re-rendering.
Roles: ambient vs one-shot
Every patch has a role that tells the engine what to do with it.
"one_shot"
A finite sound that plays once and ends. Examples: a sonar ping, a UI click, a weapon launch, a depth-charge detonation.
- Triggered explicitly — by a keypress in the TUI, by the
play(name)call, by an MCPplaytool call, or by an event in a hosting application. - The engine renders the patch to a buffer, hands it to Kira, and forgets about it.
- Multiple one-shots can play concurrently; the mixer handles polyphony.
"ambient"
A continuous sound that loops while the project is open or while the hosting application says so. Examples: ocean rumble, machinery hum, periscope drone.
- Started automatically on project load (or on explicit
play_ambientcall). - The engine loops the rendered buffer until told to stop.
- Modulation parameters (e.g. depth → low-pass cutoff) are applied at loop time, not at render time. This is what lets ambient patches respond to game state.
The full modulation API ships with the mix model work (task 10). For now, ambient patches play at a fixed mix level.
Choosing a role
A useful heuristic: if the sound has a natural end (envelope falls to silence, decay tails out), it’s a one-shot. If you’d describe its behaviour as “stays on while X is true,” it’s ambient.
The mix graph
Pending the mix-model work (task 10). When complete, this chapter describes:
- The bus topology: master, ambient bus, one-shot bus.
- Scene-arm semantics: which patches are armed and how a scene-fire keypress triggers them simultaneously.
- Per-patch gain (from the manifest) and the master-fader behaviour.
- The Kira
Trackhierarchy that backs all of this.
DSL overview
The sndlab DSL is a small set of Rhai functions for describing audio
signals. Every primitive — source, transform, or combinator — builds
a node in a single Signal graph. The graph is lazy: nothing is
computed until the engine decides what to do with it.
What the engine does depends on the patch’s role:
- One-shot patches render the graph into a finite buffer at
patch-registration time, driven by whatever bounded duration the
graph supplies (a
take(...), achirp(...), or a sound primitive called with a duration argument). The buffer plays through Kira’s low-latencyStaticSoundDatapath. - Ambient patches keep the graph as a lazy description. At play time a fresh runner is spawned and ticks the graph at audio rate for as long as the ambient stays enabled. There is no looping — ambients are generated, not played back.
The single-graph design means there’s no distinction between
“buffer DSL” and “stream DSL.” sine(440) is the same primitive
whether it ends up in a one-shot or an ambient — only the duration
context differs.
Status
| Primitive | Status | Purpose |
|---|---|---|
patch | shipped | Register a named patch. |
sine | shipped | A sine oscillator. sine(freq) is unbounded; sine(freq, dur) wraps in take. |
chirp | shipped | Linear-FM sweep, bounded by its duration argument. |
noise | shipped | noise(kind) is unbounded; noise(kind, dur) wraps in take. |
env | shipped | Attack + exponential decay applied to a signal. |
take | shipped | Truncate a signal to duration_s. Sources support this implicitly via their two-arg form. |
fade_out | shipped | Cosine-squared fade over the buffer’s last duration_s. Requires a bounded source. |
fade_in | shipped | sin² fade-up at the start. Complementary to fade_out — together they sum to constant power. |
delay | shipped | Prepend silence before the source. Stagger entries in a mix(…). |
tremolo | shipped | Sine LFO amplitude modulation. |
gain | shipped | Linear amplitude scaling. |
bandpass | shipped | Biquad bandpass. |
lowpass | shipped | Biquad lowpass. |
highpass | shipped | Biquad highpass. |
mix | shipped | Sum multiple signals. |
tap | shipped | A delay tap, used by with_taps. (Per-tap exponential decay is honoured in one-shots only; ambient streams use fixed-gain delay copies.) |
grains | shipped | Stochastic damped-sine grain generator — bubbles, drips, rain. |
sample | shipped | Load an audio file (MP3/WAV/Ogg/FLAC) as a Signal. sample(path) plays once; sample_loop(path) wraps. |
Conventions
- Frequencies in Hz. Integers and floats both accepted.
- Durations in seconds.
- Amplitudes linear, 0..1.
Fluent style
sine(330.0).env(0.008, 1.4).gain(0.32).take(3.5)
Equivalent to nesting calls — the engine’s Rhai layer just registers
each function as both a free fn and a method on Signal.
Bounding rules
One-shot patches need a finite duration somewhere in the graph. The
engine walks the tree and uses the longest take / chirp /
bounded fade_out it finds; if nothing’s bounded it caps at a 10 s
safety default and logs a warning. Ambient patches ignore bounded
sub-trees and simply run forever.
patch
Register a named patch with the engine. The script’s only side effect.
Signature
patch(name: string, role: string, signal: Signal)
name— unique identifier within the project. Re-registering the same name overwrites the previous value and emits awarn-level message in the eval summary.role— one of"one_shot"or"ambient". See Roles.signal— aSignalvalue built from the rest of the DSL.
patch has no return value. It runs for its side effect: adding the
signal to the engine’s patch table under name.
Example
patch("sonar_ping", "one_shot",
sine(330.0, 3.5).env(0.008, 1.4).gain(0.32));
After this script evaluates, the host can play("sonar_ping") to hear
the buffer.
Errors
patch: unknown role 'xyz' — expected 'one_shot' or 'ambient'— the role string didn’t match a known role.
Notes
- Eager rendering. The
signalargument is fully rendered to a buffer beforepatchreturns. Long patches make eval slower; very long patches (multi-minute) are better authored as ambient loops. - Insertion order. The order in which patches are registered is
preserved and reported back in
EvalSummary.patches. The TUI uses this for the patch list and for the “play first patch on Ctrl+R” shortcut.
sine
A pure sine wave at a given frequency.
Signature
sine(freq_hz) -> Signal // unbounded; runs forever in an ambient
sine(freq_hz, duration_s) -> Signal // bounded; equivalent to sine(freq_hz).take(duration_s)
Both arguments accept integers or floats. Internally everything is
f32 at 48 kHz; the script is free to use either numeric type.
freq_hz— frequency in Hertz.duration_s— optional. When supplied, the source terminates after this many seconds (one-shots use this to bound their rendered buffer; ambients ignore the bound and run forever).
Amplitude is unit (±1.0). Use .gain(...) to scale.
Example
let pure_a4 = sine(440.0, 1.0); // A above middle C, 1 second
let scaled = sine(440.0, 1.0).gain(0.5); // half amplitude
let chime = sine(880, 0.4).env(0.005, 4.0); // short, fast attack, ringing decay
Notes
- Phase starts at zero. Successive
sinecalls don’t share phase — each is an independent buffer. - Negative or zero
duration_sproduce an empty buffer (length 0) with no error. - Very low frequencies (< ~10 Hz) are valid but you won’t hear them on most playback hardware; they’re useful as control signals for modulating other layers (a use case the current DSL doesn’t expose directly — coming with the modulation work).
chirp
A linear-frequency-modulated sine wave (LFM). Frequency interpolates
linearly from start_hz to end_hz over duration_s seconds.
Signature
chirp(start_hz, end_hz, duration_s) -> Signal
All three arguments accept integers or floats. Internally everything
is f32 at 48 kHz.
start_hz— instantaneous frequency at the start of the buffer.end_hz— instantaneous frequency at the end of the buffer.duration_s— buffer length in seconds.
Amplitude is unit (±1.0). Use .gain(...) to scale.
Example
// Active sonar ping: ~quarter-octave sweep up, 1 second long
chirp(280.0, 400.0, 1.0).env(0.008, 1.4).gain(0.32)
// Whoop alarm: downward sweep an octave wide, fast
chirp(880.0, 440.0, 0.15).env(0.005, 5.0).gain(0.5)
// Constant tone — equivalent to sine(440, 1.0)
chirp(440.0, 440.0, 1.0)
Why this exists
A pure sine is monochromatic: all of its energy is at one frequency.
When you sum delayed copies of a sine (via with_taps),
the result is a stable comb filter:
at certain frequencies the dry and the taps add constructively
(louder); at others they cancel destructively (quieter, audible
nulls). For a pure sine all the energy sits at one of those
frequencies and you hear it as either a sustained boost or — worse —
a sustained dropout.
A chirp covers a band of frequencies over time. Delayed copies of a chirp also cover that band but with a time offset, so the constructive and destructive points sweep through frequency in step with the chirp. The interference is no longer locked to a single audible null; it averages into a reverb-like texture.
For game audio, chirps are the right primitive for anything modelled on real sonar (active pings, whoop alarms, weapon-launch signatures) because real underwater pulses are also chirped — broadband on purpose, for the same reason.
Notes
- The phase at
t = 0is zero, so the buffer starts at sample value0. No click on onset before envelope. - Going downward (
start_hz > end_hz) is fine. The math just works in reverse. - Going through zero (
start_hzandend_hzstraddle 0) is a pathological case: aliasing-style artefacts as the instantaneous frequency dips below 0 Hz. Don’t do that — usesinewith a slow LFO if you actually want zero-crossings of a tone’s frequency. - Logarithmic / exponential chirps (perceptually-linear sweeps used in audio measurement) aren’t supplied yet. They land when a recipe calls for them.
noise
A noise generator. Three flavours.
Signature
noise(kind: string) -> Signal // unbounded; runs forever in an ambient
noise(kind: string, duration_s) -> Signal // bounded
kind— one of:"white"— uniform random in[-1, 1), flat spectrum."pink"— pseudo-pink via a three-band integrator approximation. Close enough to true pink for ambient layers; not for measurement."brown"— integrated white, clamped to±1. Heavy low-frequency content; useful for ocean rumble.
duration_s— length of the resulting buffer, in seconds.
Example
let hiss = noise("white", 0.5).gain(0.1);
let ambient = noise("pink", 8.0).gain(0.3);
let rumble = noise("brown", 12.0).gain(0.6);
Errors
noise: unknown kind 'foo' — expected 'white', 'pink', or 'brown'
Determinism caveat
The current implementation uses a fixed PRNG seed shared across all
noise(...) calls in an evaluation. Two calls in the same script
share state, which means re-evaluating produces identical buffers
but the relative sequences between two noise calls in the same
script are not independent.
This is good enough for game ambience but worth knowing. A future
change will seed per-patch(...) so noise within different patches
is statistically independent, and a seed: int parameter will let
the caller pin it explicitly.
env
Apply an attack ramp and exponential decay envelope to a signal.
Signature
signal.env(attack_s, decay_s) -> Signal
// or equivalently:
env(signal, attack_s, decay_s) -> Signal
attack_s— linear ramp from silence to full at the start of the buffer.0.0means “no ramp” (the buffer starts at full amplitude on sample 0).decay_s— exponential decay time constant. Larger values = longer ring. The envelope falls to1/e(≈ 37%) afterdecay_sseconds and to ~5% after3 × decay_s.
The envelope is applied multiplicatively per-sample; the source buffer’s length is unchanged.
Example
// A quick "click" that decays to inaudible in ~150 ms:
sine(2000.0, 0.2).env(0.001, 30.0)
// A long ringing tone — 4-second time constant means it's still
// at ~6% after 12 seconds. Combine with a finite buffer length.
sine(220.0, 8.0).env(0.01, 4.0).gain(0.4)
// No attack (hard onset, used for percussive transients):
noise("white", 0.05).env(0.0, 100.0).gain(0.5)
Why exponential
Exponential decay matches the way natural resonators (strings, bells, hulls) lose energy and is what listeners associate with “ringing”. Linear decay sounds artificial — it stays loud and then suddenly stops.
Future variants
A separate ADSR envelope (attack/decay/sustain/release) and a data-driven multi-segment envelope will land when patches that need them justify the API surface.
fade_out
Apply a smooth cosine-squared fade to the last duration_s of the
buffer. Composes with env for the common “long ring
that needs a graceful tail” shape.
Signature
signal.fade_out(duration_s) -> Signal
duration_s— length of the fade region, measured from the end of the buffer.
The first len(signal) − duration_s of the buffer is unchanged. Over
the last duration_s the amplitude follows cos²(π·t/2) where
t runs 0 → 1 — smooth at both ends (no audible kink where the fade
begins, exact zero at the end).
When to use it
The single exponential env decays forever; if the buffer is shorter
than the envelope’s natural die-off, the buffer terminates with the
envelope still at audible amplitude and you hear a click on playback.
You have two choices:
- Extend the buffer until the envelope is inaudible — wasteful for long-ring sounds; you’re storing seconds of near-silent samples.
- Use
fade_out— keep the buffer as long as you actually want to hear the sound, and letfade_outmop up whatever amplitude is left at the end.
Example: long ring with graceful tail
patch("ping", "one_shot",
sine(440.0, 2.5)
.env(0.008, 1.2) // body: slow exponential decay
.fade_out(0.4)); // tail: smooth ramp to silence
// over the last 400 ms
The body envelope decays at its natural exponential rate; the fade catches whatever amplitude remains in the last 400 ms and brings it cleanly to zero.
Notes
fade_out(duration_s)truncated to buffer length. Pass aduration_sbigger than the buffer and the entire buffer gets faded.fade_out(0)or negative is a no-op.- The cosine-squared shape is the audio-engineering standard for clean fades. Linear fades have a perceptual “kink” where the slope changes; this one’s smooth all the way through.
fade_in
Smooth amplitude ramp at the start of a source. Complementary to
fade_out.
Signature
signal.fade_in(seconds) -> Signal
fade_in(signal, seconds) -> Signal
seconds— duration of the fade-up ramp at the start of the signal. After the ramp the source is passed through unchanged.
The ramp is a sin² curve over [0, 1]:
env(t) = sin²(π/2 · t)
This is the exact complement of fade_out’s cos² shape. A
fade_in(d) on layer A combined with a fade_out(d) on layer B at
the same offset reconstructs to constant power — useful for
crossfading between two textures without a perceptible level dip.
Example
A torpedo emerging from underneath a bubble wash:
patch("torpedo_emerge", "one_shot",
mix([
sample("samples/bubbles.wav").pitch(0.6).env(0.05, 1.5).gain(2.0),
torpedo_layer.fade_in(0.8).fade_out(1.0), // ramps in over 0.8 s
])
.gain(0.55));
The torpedo rises smoothly out of silence while the bubbles play, then fades out cleanly at the end.
Why decouple fade-in from env?
env(attack, decay) already does a linear attack ramp and pairs it
with an exponential decay. That’s fine when you want both shaped
together — strikes, bell-like tones — but bad when you want a long
graceful entry and either no decay or a controlled tail (fade_out)
shape.
| Want | Use |
|---|---|
| Hammered onset → exponential ring | env(0.005, 2.0) |
| Slow rise → constant body → smooth tail | fade_in(1.0).fade_out(1.0) |
| Crossfade between two layers | fade_in(d) on one, fade_out(d) on the other |
Notes
- Works on bounded and unbounded sources. For an ambient,
fade_inshapes only the opening of the stream and then passes through forever. - Zero or negative duration produces no ramp (pass-through).
- Constant-power crossfade. Pairing
fade_in(d)andfade_out(d)on two layers whose sum you want flat works becausesin²(x) + cos²(x) = 1.
Errors
None at construction.
delay
Prepend silence before the source. Useful for staggering elements in a mix without baking silence into the source.
Signature
signal.delay(seconds) -> Signal
delay(signal, seconds) -> Signal
seconds— how much silence to prepend, in seconds. Zero or negative is allowed and produces no delay.
The returned signal:
- Outputs
seconds × 48 000zero samples first. - Then delegates to the source until it ends (one-shots) or forever (ambients).
- Reports
finite_duration_s() = source_duration + secondsfor the one-shot renderer, so wrappingfade_out(...)around a delayed source still fades the right region.
Example
Stagger the entry of a layer in a mix:
patch("entry_after_pad", "one_shot",
mix([
sine(220.0, 4.0).env(0.05, 0.4).gain(0.2), // starts at t=0
sine(440.0, 4.0).env(0.05, 0.4).gain(0.2) // starts at t=1.5
.delay(1.5),
])
.fade_out(0.8)
.gain(0.6));
The two sines mix together but the upper one only enters 1.5 s in.
Without delay(...) you would have to bake silence into the second
sine’s source, which loses composability — delay keeps every layer
expressible as a fluent chain.
Notes
- Per-layer staggering.
delayis most useful insidemix([…]). At the top level it’s just a leading silent gap, which the host could also achieve by holding off the trigger. - Doesn’t affect pitch or speed. This is timeline offset, not
audio rate scaling. For pitch-/speed-changing wrappers see
pitchandspeed. - Works on ambients. A delayed ambient emits silence for the initial period then runs forever. The host’s stop/fade behaviour is unchanged.
Errors
None at construction. Negative durations are clamped to zero.
tremolo
Periodic amplitude modulation. A low-frequency sine wave is multiplied into the signal, making the loudness pulse over time. Often used to add “life” to a sustained tone — a steady ring becomes a slowly breathing ring.
Compare with vibrato (which would modulate pitch, not amplitude — a different operation that isn’t currently in the DSL).
Signature
signal.tremolo(rate_hz, depth) -> Signal
rate_hz— modulation frequency. Musically useful range is 3–8 Hz. Below 1 Hz feels like volume swelling; above ~15 Hz starts to sound like AM synthesis rather than tremolo.depth— 0 to 1.0is no modulation;1swings the amplitude between 0 and the input’s full level (100 % tremolo). Subtle musical settings sit around 0.3–0.5.
The LFO starts at full amplitude (cos(0) = 1) so the signal’s onset
is not attenuated. The phase reaches the minimum after half a period.
Example
// A 1700 Hz ring with a gentle 4 Hz tremolo wobble.
patch("ping", "one_shot",
sine(1700.0, 2.5)
.env(0.008, 1.2)
.tremolo(4.0, 0.3)
.fade_out(0.4));
// Heavy tremolo as a "stuttering" effect.
patch("stutter", "one_shot",
sine(220.0, 1.0).tremolo(10.0, 0.95));
Composing with env
env shapes the body decay; tremolo lays a wobble on top. They
multiply, so the order doesn’t matter mathematically — .env(...).tremolo(...)
and .tremolo(...).env(...) produce identical output. Read it
whichever way scans better.
Notes
- Tremolo on filtered noise modulates the whole noise band, not the bandpass centre frequency. The result is the entire shaped spectrum pulsing in volume. For a “pitch-wobble” effect on filtered noise you’d need a time-varying filter, which isn’t in the DSL yet.
- Stacking tremolo and fade_out is fine. Tremolo modulates ongoing amplitude; fade_out catches whatever’s left at the end. The combination produces a wobbling tone that decays cleanly to silence.
depthabove 1 clamps to 1. No hard limit onrate_hzbut rates above the Nyquist limit will alias.
gain
Linear amplitude scaling.
Signature
signal.gain(factor) -> Signal
// or equivalently:
gain(signal, factor) -> Signal
factor— linear multiplier.0.0is silence;1.0is the source unchanged;2.0doubles. Floats and integers both work.
Example
sine(440.0, 1.0).gain(0.3) // 30% of full scale
sine(440.0, 1.0).gain(0) // silenced
mix([
sine(220.0, 1.0).gain(0.5),
sine(330.0, 1.0).gain(0.5),
])
Clipping
There is no automatic limiter. If your final signal goes above ±1.0
the buffer will clip when played. Keep individual layers conservative
(typically 0.2–0.4) and use the master fader for level rather
than pushing individual patches loud.
Why no dB
Decibel-aware gain (gain_db(-6)) is an obvious convenience and
likely to land before 1.0. For now, the linear form keeps the surface
minimal — and for the small dynamic range of game sfx, the difference
is minor.
bandpass
A biquad bandpass filter. Pass it a broadband signal (typically noise)
and it carves out a resonant peak at center_hz, attenuating
everything outside the band.
Signature
signal.bandpass(center_hz, q) -> Signal
center_hz— the centre of the passband.q— the resonance / Q factor. Higher means a narrower peak. Roughly: bandwidth ≈center_hz / q. Useful range 0.5 (very broad, about an octave wide) to 50 (very narrow, almost a sine).
The filter uses the constant-skirt-gain bandpass coefficients from the RBJ Audio EQ Cookbook — the standard biquad for this job in audio software.
Why this exists
The DSL’s source primitives — sine, chirp, noise — are either
spectrally pure (sines, chirps) or perfectly flat (noise). Realistic
sounds usually have shaped spectra that are neither: a hum tone at
some specific frequency with side-lobes around it, or a broadband
crackle that rolls off above a certain frequency. You get those
shapes by filtering broadband content rather than by summing pure
tones.
The classic example is a resonant body — a bell, a sonar
transducer housing, a tube. Strike it with broadband energy (a hammer
hit, an electrical impulse) and the body’s resonance carves the
broadband spectrum into one or more peaks. We model that as
noise(...).bandpass(...).
Example: a single-peak ping
patch("hum", "one_shot",
noise("white", 1.0)
.bandpass(1000.0, 12.0) // 1 kHz peak, ~80 Hz wide
.env(0.005, 1.2)
.gain(0.5));
That’s a 1 kHz tonal hum with a noisy texture — the noise inside the passband shows up as “jagged” amplitude variation across the peak in an FFT, distinct from the pinpoint spike a pure sine would produce.
Example: subtractive ping with two peaks
patch("ping", "one_shot",
mix([
noise("white", 1.0).bandpass(1000.0, 12.0).gain(0.5), // 1 kHz fundamental
noise("white", 1.0).bandpass(2050.0, 12.0).gain(0.4), // 2 kHz, octave up
noise("white", 1.0).bandpass(3200.0, 1.5).gain(0.15), // broad shoulder
]).env(0.008, 1.5).gain(0.45));
The first two filters create narrow resonances; the third uses a low Q to scoop out a broader high-frequency shoulder. This is the shape of many real metallic/transducer pings — broadband excitation through a multi-mode resonant body.
Notes
- Transient response. The biquad needs a few sample periods to
settle from cold; the first ~5 ms of the output is a brief
attack-like ramp. Usually masked by
env(...)so it doesn’t matter. - Very high Q can ring. At Q above ~50 the filter is essentially a resonator: it’ll continue ringing after the input ends. Sometimes this is what you want (struck-bell-like sustain); sometimes it’s an artefact. Drop Q if you don’t want it.
- Bandpass is a single tool. Combine multiple
bandpasscalls in parallel viamix(...)to build complex spectral shapes — that’s the workhorse pattern. Sequential bandpasses (one feeding another) don’t usually do what you want. - Out-of-band content goes to roughly silence. The filter rolls off at 6 dB/octave per pole; the biquad gives you 12 dB/octave on each side of the peak. Below the centre by an octave, the level drops about 20 dB.
lowpass
A biquad low-pass filter. Passes content below cutoff_hz and
attenuates content above.
Signature
signal.lowpass(cutoff_hz, q) -> Signal
cutoff_hz— the −3 dB knee of the filter. Content well below this passes essentially unchanged; well above it attenuates at 12 dB/octave.q— resonance at the cutoff knee.0.707is the standard Butterworth value (no audible peak; the cleanest response). Higher values (2–10) create an emphasis right at the cutoff, often used for the “synth lead” tone. Beyond ~10 the filter self-resonates and may ring after the input ends.
The biquad uses the RBJ Audio EQ Cookbook coefficients, the same formulas in nearly every audio plugin.
Examples
// Cut everything above 5 kHz — kills breathy high-frequency noise
// from a noisy source. Butterworth Q for a clean roll-off.
noise("white", 1.0).lowpass(5000.0, 0.707)
// Subtle "muffle" of an existing signal — drops the top end without
// killing presence.
signal.lowpass(8000.0, 0.707)
// Resonant low-pass at 800 Hz — emphasises that frequency while
// rolling off everything above.
signal.lowpass(800.0, 6.0)
When to use it
- Tame a noise source. White noise is bright by default; a lowpass at a few kHz turns it from hiss into rumble.
- Simulate distance / absorption. High frequencies attenuate faster over distance in water and air. A lowpass models that.
- After bandpass synthesis. When you’ve carved a tonal shape from
noise with
bandpass, a lowpass on the sum can tidy up any residual high-frequency content that leaked through.
Notes
- 12 dB/octave roll-off. A biquad is 2nd-order, so the attenuation rate is 12 dB per octave above the cutoff. For sharper cuts, apply multiple lowpasses in series (each adds another 12 dB).
- Transient response. The biquad needs a few sample periods (~3 ms)
to settle from cold; the first samples of the output are a brief
ramp. Usually masked by
env(...). - Don’t use to “remove highs from the spectrum view.” If the FFT is showing energy above the cutoff, you’re seeing residual content after a single biquad. Either run it through two lowpasses or pick a tighter cutoff.
highpass
A biquad high-pass filter. The mirror of lowpass:
passes content above cutoff_hz and attenuates content below.
Signature
signal.highpass(cutoff_hz, q) -> Signal
cutoff_hz— the −3 dB knee.q— resonance at the knee.0.707for Butterworth (no peak). Higher Q emphasises the cutoff frequency.
Examples
// Cut everything below 100 Hz — kills room rumble or DC offset.
signal.highpass(100.0, 0.707)
// Aggressive "thinned out" tone — keeps just the high content.
signal.highpass(2000.0, 0.707)
// Resonant highpass at 4 kHz — the inverse of a resonant lowpass,
// rare in design but useful for whistle-like character.
signal.highpass(4000.0, 6.0)
When to use it
- Remove low-frequency rumble. Sources from real recordings often carry sub-bass content (room hum, mic handling, DC offset). A high- pass at 60–120 Hz removes it without affecting the audible part.
- Brighten a bass-heavy source. When
brownnoise or a deep sine is the source, a highpass leaves only the upper edge — useful as a “sizzle” layer in additive synthesis. - Complement a lowpass. A
signal.highpass(800).lowpass(4000)pair makes a wide bandpass with independent Q at each side.
Notes
The same caveats as lowpass: 12 dB/octave roll-off,
brief transient response, and use multiple in series for sharper
cuts.
mix
Sum an array of signals into a single buffer.
Signature
mix([sig_a, sig_b, ...]) -> Signal
The argument is a Rhai array; every element must be a Signal.
The output length is max(len(sig) for sig in inputs). Shorter
inputs are zero-padded at the end. Samples are summed without any
normalisation — the caller is responsible for keeping the result
below ±1.0 (typically by scaling each layer with .gain(...)).
Example
// Two harmonics summed:
patch("dyad", "one_shot",
mix([
sine(220.0, 2.0).gain(0.3),
sine(330.0, 2.0).gain(0.3),
]));
// A pinged tone with a noise burst layered on top:
mix([
sine(440.0, 0.4).env(0.005, 5.0).gain(0.5),
noise("white", 0.05).env(0.0, 50.0).gain(0.3),
])
Errors
mix: element N is not a Signal— one of the array elements wasn’t aSignal. Common cause: forgetting to wrap a number insine(...)or similar.
Notes
- An empty array produces an empty buffer (
mix([])returns a zero-lengthSignal). - Stereo panning is not exposed by
mix. Per-source panning lives in the mix model and is applied at play time, not at synthesis time.
tap
A single delay tap, used with with_taps to build discrete reflection-style
reverb tails.
Signature
tap(delay_s, gain) -> Tap // default decay (~80 ms 1/e)
tap(delay_s, gain, decay_k) -> Tap // explicit decay
signal.with_taps([tap_a, tap_b, ...]) -> Signal
A Tap is a (delay_s, gain, decay_k) triple. with_taps copies the source
into the buffer at each tap’s offset, scaled by gain, then applies an
independent exponential envelope per tap shaped by decay_k. The output
extends past the source by the longest tap so the tail has somewhere to live.
What decay_k does
decay_k is the per-sample exponential decay rate measured against the tap’s
own onset. The tap envelope at time t after onset is exp(-t × decay_k).
decay_k | 1/e time | Sounds like |
|---|---|---|
12 (default) | ~83 ms | A discrete reflection — a brief echo of the source’s onset. |
6 | ~167 ms | A longer late reflection. |
3 | ~333 ms | A diffused tail element. |
0 | (no decay) | A literal delayed copy of the entire source. Useful for chord-stacking, not for reverb. |
If you want sustained delayed copies of the source — the old behaviour — pass
tap(delay, gain, 0). If you want reflections, the two-argument form does
the right thing.
Why discrete decays, not convolution reverb
A real reverb convolves the source with the room’s impulse response. That’s expensive (FFT-domain or 5000+ taps) and overkill for a game-shaped reverb tail. Six decaying taps render in microseconds and give the “near reflections then late tail” character listeners expect.
A proper convolution reverb will land later when the recipes need it (probably for cathedral / open-ocean sounds). For sonar-room geometry, discrete reflection-style taps are the right tool.
Example
patch("sonar_ping", "one_shot",
sine(330.0, 1.5).env(0.008, 1.4).gain(0.32)
.with_taps([
tap(0.13, 0.7), // close, loud, brief
tap(0.31, 0.5), // mid, slightly quieter
tap(0.58, 0.35), // farther, quieter still
tap(0.95, 0.22), // distant late reflection
]));
That’s the submarine ping: a sine body with four reflections, each later and quieter, each itself brief (default 80 ms decay).
Errors
with_taps: element N is not a Tap— one of the array elements wasn’t built withtap(...).
Notes
- Combining decays: a single signal can have many taps with different
decay_kvalues — short close reflections plus a long diffused tail. - Streaming caveat: in ambient patches the runtime delay-line uses
a fixed per-tap gain (the
decay_kfield is ignored). The full exponential-decay shaping only applies in one-shot patches where the taps are baked into a finite buffer at eval time. Adding per-tap onset envelopes to the streaming delay line is a future change.
grains
A stochastic grain generator. Brief damped sines fire at a Poisson rate, each at a random frequency in a band. Useful for textures made of many small discrete events — bubble streams, rain on a hull, sand pouring, debris.
Signature
grains(rate_hz, freq_lo_hz, freq_hi_hz) -> Signal // default decay
grains(rate_hz, freq_lo_hz, freq_hi_hz, decay_k) -> Signal // explicit decay
rate_hz— expected number of grain onsets per second. At each audio sample we toss a weighted coin: probabilityrate_hz / 48000of firing a new grain. 20–80 sounds like effervescence; 1–5 like a slow drip; 200+ blends into a tonal hash.freq_lo_hz/freq_hi_hz— frequency range. Each grain picks uniformly within. The bounds can be passed in either order; they’re normalised internally.decay_k— per-second exponential decay rate of each grain’s amplitude envelope (same units astap’sdecay_k). The default is80(≈12 ms 1/e — short, popping bubble-like). Drop to20(~50 ms) for longer rings; raise to200for sharp ticks.
The output is unbounded — runs forever in an ambient. Wrap with take
or fade_out if you want a finite version.
Why this models bubbles
A real bubble in water has a resonant frequency determined by its radius (the Minnaert resonance: ~3 kHz·m / radius). Small bubbles ring high (1–3 kHz); larger ones ring low. A bubble cloud is a swarm of such resonances overlapping at random onsets. Synthesising that as a sum of randomly-triggered damped sines is closer to the physics than filtered noise — and much closer to “I hear bubbles” than a continuous bandpass-shaped hiss.
For a torpedo wake or hull-vent texture:
patch("bubble_stream", "ambient",
grains(60.0, 600.0, 2800.0) // 60 bubbles/s, small/medium
.lowpass(4000.0, 0.707) // tame the top end
.gain(0.35));
For sparse drips:
patch("hull_drip", "ambient",
grains(2.0, 800.0, 1500.0, 30.0) // 2 drips/s, longer ring
.gain(0.4));
Parameters at a glance
| Effect you want | rate_hz | decay_k | freq_lo–freq_hi |
|---|---|---|---|
| Fizzy effervescence | 80–200 | 80 | 1500–4000 |
| Coarse bubbling (torpedo wake) | 40–80 | 60 | 500–2500 |
| Big slow blobs | 5–10 | 30 | 100–800 |
| Sharp rain ticks | 20–60 | 200 | 3000–8000 |
| Sand / debris hash | 200–500 | 150 | 2000–6000 |
Composing with other primitives
grains returns a normal Signal, so the full chain is available:
.lowpass(…)/.bandpass(…)— colour the grain band as a whole.gain(…)— set level.tremolo(rate, depth)— add a long-period swell, e.g. wake rising and falling.with_taps([…])— feed into reverb taps for a wet bubbly tail
Layer it inside a mix([…]) alongside noise(…) for the
continuous-wash component and sine(…) for any tonal element of the
texture (motor hum under bubbles, etc.).
Notes
- Determinism. Like
noise, grains use a fixed PRNG seed derived from the call’s parameters. Twograinscalls with identical parameters in one script will share the same sequence; vary one parameter slightly (e.g.grains(60.0, 600.0, 2800.0)vsgrains(60.0, 605.0, 2800.0)) for independent textures. - Backpressure. The runner caps concurrent live grains at 256. Above that the rate effectively saturates. With the default decay this corresponds to ~3 kHz onset rate before clipping kicks in — beyond any sound design need.
- CPU. Each live grain is one phase-step + one sine + one mul.
At 100 Hz onset rate with
decay_k=80, mean concurrency is ~6 grains. Negligible.
Errors
- None at construction. Negative rates are clamped to zero (silent); zero or negative decays default to a small positive value.
sample
Load a decoded audio file (MP3 / WAV / Ogg / FLAC) as a DSL Signal,
then process it with the rest of the chain — filters, envelope,
gain, taps, mix layering.
Signature
sample(path) -> Signal // plays once, ends
sample_loop(path) -> Signal // wraps at end-of-buffer, forever
path— string. Absolute paths are used as-is. Relative paths resolve against the project root (the directory holdingproject.ron), independently of where sndlab was launched from. If the project hasn’t been saved yet there is no root, and relative paths produce a friendly error pointing you to save the project or use an absolute path.
The file is decoded once at patch-registration time (during eval).
Decoded samples are mono (multichannel sources are averaged) and
linearly resampled to the engine’s 48 kHz at playback. Cheap; loading
is amortised across every play.
Examples
A torpedo launch with a sampled mechanical click and an algorithmic whoosh underneath:
patch("torpedo_launch", "one_shot",
mix([
sample("samples/breech_click.wav").gain(0.6),
noise("pink", 0.6).bandpass(1500.0, 1.0)
.env(0.005, 4.0).gain(0.3),
]).fade_out(0.15));
A bubbling wake using a Freesound loop with our filters laid on top:
patch("torpedo_wake", "ambient",
sample_loop("samples/freesound_bubbles_loop.wav")
.lowpass(3500.0, 0.707) // tame the top-end hiss
.gain(0.4));
Composing with other primitives
sample(...) returns a Signal like anything else, so the entire
chain is available:
.pitch(factor)— sample-only tape-speed control.0.5= octave down + double duration;2.0= octave up + half duration;1.0is the original. Composes by multiplication, so.pitch(0.5).pitch(0.5)=0.25(two octaves down). Repitched samples report their adjusted duration tofade_out, so a pitched-down rush still fades over its full new length..lowpass(...)/.highpass(...)/.bandpass(...)— colour the sample.gain(...)— set level.env(attack, decay)— re-shape the amplitude envelope (useful for punching transients onto looped textures).fade_out(duration)— apply a tail fade (one-shot only — needs a finite source).tremolo(rate, depth)— amplitude modulate.with_taps([...])— feed into reverb tapsmix([...])— layer with algorithmic sources
pitch only makes sense on a Sample — applying it to a sine, noise,
chirp, or grains source errors at eval time. (Use sine’s freq_hz
argument if you want to “pitch” a sine.)
When to use which
sample(...) | sample_loop(...) | |
|---|---|---|
| Patch role | one-shot | ambient |
| End of buffer | terminates | wraps to start |
| Use for | impacts, launches, single events | bubble loops, machinery beds, textures |
A sample(...) in an ambient patch will play through once and then
go silent — useful but probably not what you want. A
sample_loop(...) in a one-shot patch hits the 10 s safety cap and
gets cropped. Match the function to the role.
Path resolution
The path resolver works like this:
sample("samples/bubbles.wav")
└─ relative? ────► yes ─► join with project.root
no ─► use the absolute path
If the project has never been saved (no project.ron), there is no
root, and relative paths error. Save the project first
(Project → Save As…) or pass an absolute path.
The reference loader (Load reference… in the toolbar) uses the same
decoder under the hood, so anything sndlab can show on the scope is
something sample(...) can play.
Freesound workflow
- Download a clip from Freesound. Look for a CC0 or CC-BY licence so you can ship it with your game.
- Drop it in your project directory, e.g.
my-project/samples/bubbles.wav. - Reference it from a patch as
sample("samples/bubbles.wav"). - Eval. The first eval after the file lands does the decode; further
evals reuse the cached decode (until you re-eval, at which point
we decode again —
sample()is decode-per-eval, not decode-once- ever).
Errors
sample('foo.wav'): relative path but no project root is set — save the project first, or use an absolute path— see “Path resolution”.sample('/abs/path.wav'): file open: No such file or directory (os error 2)— typo or wrong relative root.sample('foo.weird'): symphonia: unsupported format— only MP3 / WAV / Ogg / FLAC are wired up. Convert withffmpeg.
Notes
- Mono fold. Stereo sources are folded down to mono. If you need
the panning, you can split-load by writing two
.wavchannel files, but the engine is mono-only for now. - Caching. The decoder runs once per
evalpersample(...)call site. Re-eval re-decodes; for big files this can stutter the edit loop. Move heavy samples out of the patch into a separate manifest later if this hurts. - Determinism. Sample playback is deterministic — same file, same output. No PRNG, no warmup.
The project manifest
Pending the project-model work (task 9). When complete, this chapter describes the
project.ronschema:
- Project name and version.
- List of script files (
.rhai) and their roles.- Per-patch overrides (gain, ambient flag).
- Master gain.
- Render sample-rate override for offline use.
Multi-file projects
Pending the project-model work (task 9). When complete, this chapter covers:
- The single-namespace model (all loaded scripts share the patch namespace).
- Switching between open files in the editor.
- Conventions for splitting (e.g.
ambience.rhai,weapons.rhai,ui.rhai).
Sonar ping
The classic submarine “ping”: a metallic ringing tone that decays over a couple of seconds.
patch("sonar_ping", "one_shot",
mix([
noise("white", 3.0).bandpass(1000.0, 200.0).gain(60),
noise("white", 3.0).bandpass(2050.0, 200.0).gain(20),
])
.lowpass(6000.0, 0.4)
.tremolo(4.0, 0.2)
.env(0.008, 1.0)
.fade_out(0.6)
.gain(0.25));
The core trick: high-Q bandpass on white noise
The whole sound is built around one realisation: a very high-Q
bandpass on white noise reads as a tone, not a band of hiss. White
noise has flat spectral content; pushing it through a bandpass(f, Q)
with Q ≈ 200 extracts a sliver of energy around f — bandwidth
roughly f / Q, so ~5 Hz wide at 1 kHz — which the ear hears as a
pure pitch with a tiny amount of chaotic micro-modulation. That last
detail is the point: a real sonar transducer rings with mechanical
imperfections; a pure sine sounds synthetic, a filtered-noise tone
sounds struck.
Two of these ringing tones at 1000 Hz and 2050 Hz stack into
a roughly-harmonic interval (an octave plus a quartertone), which
gives the ping a chord-like body rather than a single-tone whistle.
The lower band gets more energy (gain(60) vs gain(20)) so it
sits forward in the mix; the upper band adds shimmer.
Why these numbers
bandpass(1000.0, 200.0).gain(60). The huge gain compensates for
the resonant filter’s narrow passband — most of the white noise is
discarded by the filter, so we crank the survivor back up to a usable
level. The Q=200 is what gives the tonal character; lower it to 20
and you hear noisy hiss instead of pitch.
Second band at 2050 Hz. Slightly offset from a true octave to avoid mechanical sameness with the lower band. Gain dropped to 20 so the upper band sits underneath the lower one as harmonic colour rather than competing with it.
.lowpass(6000.0, 0.4). Cleans any residual high-frequency hash
the bandpass filters let through. The low Q (0.4) is intentional —
it’s a gentle slope, not a resonant cut.
.tremolo(4.0, 0.2). A 4 Hz amplitude wobble at 20% depth adds a
slight pulse to the tail that reads as “this tone is alive, not a
sample loop” — sonar listeners hear similar modulation from the
transducer’s mechanical hum.
.env(0.008, 1.0). 8 ms attack avoids a hard onset click. Decay
constant 1.0 gives a 1/e time of ~1 s — the tone rings out audibly
over the following 2-3 seconds.
.fade_out(0.6). Smooths the very end of the buffer so it doesn’t
cut abruptly into silence. The env decay is asymptotic; the fade_out
brings it cleanly to zero over the last 600 ms.
.gain(0.25). The summed bandpass-noise gains are large numbers
(60 + 20 = 80 before normalisation). The final gain dials the whole
thing back into the safe -1.0..1.0 range with headroom for the
reverb taps you might add later.
Why not just a chirp?
A linear-FM chirp is the textbook explanation of how real LFM sonar pulses work, but it sounds artificial in a game. You can hear the sweep — the pitch slides over the duration, and the ear immediately labels it as “synthesised effect” rather than “metallic transducer ringing in seawater.” Bandpassed noise has the opposite quality: the pitch is stable, but the micro-structure of the source is chaotic, so it reads as a physical object that’s been struck.
If you do want the swept character — for a wider, more “active”
sonar — chirp(280.0, 400.0, 1.0) is still available; see the
chirp DSL chapter. The two designs cover different
moods: filtered-noise for the iconic submarine ping; chirp for a
modern active-sonar pulse.
Variants
- Brighter / more urgent. Move both bandpass centres up:
bandpass(1400, 200)+bandpass(2800, 200). Reads as a smaller boat, lighter weapon. - Heavier / older sub. Drop both centres:
bandpass(600, 200)+bandpass(1250, 200). Sounds like a WWII-era ASDIC set. - Pure single tone. Drop the upper band entirely. The ping sounds more like a research pinger or a beacon.
- Tighter interval (octave). Set the upper band to exactly twice the lower (1000 → 2000). The harmonic alignment makes the two bands fuse into one tone with extra brightness rather than reading as a chord.
- Longer ring. Halve the env decay constant:
.env(0.008, 0.5)gives a ~2 s 1/e time. Pair with a longerfade_out(1.2).
Test in sndlab
Drop the patch into the editor pane, press F5. The scope’s upper pane should show a slowly-decaying ringing waveform; the lower spectrum pane should show two narrow peaks near 1 kHz and 2 kHz — the two bandpass centres — sitting above a low noise floor. If you see broadband hiss instead of distinct peaks, the bandpass Q is too low.
Hull creak
Stub. The hull creak is a sparse band-passed noise burst with an envelope that ramps slowly up and decays. The recipe writes itself once the DSL has noise + envelope + band-pass filtering.
Ocean rumble
Stub. Heavily low-passed brown noise with a slow LFO modulating its gain. Ambient role; loops continuously and ducks under silent-running in the host application.
Using sndlab-core
sndlab-core is the embeddable engine that sits underneath the TUI.
Any Rust binary can pull it in, load .rhai patches, and play them
through Kira. The TUI is one consumer of this surface; your game is
another.
Pending the engine implementation (task 7). When complete, this chapter walks through:
- Adding
sndlab-coreas a dependency.- Constructing an
Engine.- Loading patches from a string (compile-time
include_str!) or from disk.- Triggering patches in response to game events.
- Listening to the modulation surface for ambient patches.
- Error handling and the silent-fallback story.
Echo Field integration
Echo Field is the submarine
simulation game sndlab was originally built to support. Its audio
architecture (see docs/audio-design.md in that repo) calls for
procedurally synthesised sonar pings, ambient layers, and per-contact
voices — exactly the shape sndlab’s DSL is designed for.
Pending the engine implementation and Echo Field’s audio module migration. The plan:
- Echo Field declares
sndlab-coreas a dependency.- Audio patches live in
crates/app/src/audio/patches/*.rhai(or embedded viainclude_str!for release builds).- At game startup, the audio engine evaluates the bundled scripts and registers patches by name.
- Game events (
SimEvent::TorpedoFired, …) trigger one-shots; the listener-state modulation surface drives ambient parameters.- The TUI sndlab session and the game share the same patch source — designing in sndlab and shipping in the game are the same act.
Contributing
sndlab is a small project. The contribution shape that matters most is keeping the book in lockstep with the code: every new DSL primitive needs a chapter, every new manifest field needs documentation, every new MCP tool needs a description.
See CLAUDE.md in the repository root for the discipline the project follows when AI assistance is involved. Human contributors are asked to follow the same discipline.
Building
cargo build # compile the workspace
cargo run -p sndlab # launch the TUI
cargo test --workspace # run all tests
mdbook serve book # preview this book at http://localhost:3000
Code style
- No emojis in code or docs unless asked.
- Comments explain why — the what should be evident from the code.
- New primitives include both the implementation and the book chapter in the same commit.
License
MIT. By contributing, you agree your contribution is licensed under the same terms.