Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_out duration 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 on sndlab-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-dev on Debian/Ubuntu, alsa-lib-devel on Fedora, alsa-lib on 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 .rhai file.
  • Scope (right) — live oscilloscope showing the last rendered patch buffer. Updates whenever you eval.
  • Log (bottom) — evaluation results, audio playback notifications, warnings, errors.

Hotkeys

KeyAction
F5Evaluate the buffer. Trigger one-shots / toggle ambients from the patches row below the toolbar.
toolbar buttonsTrigger 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

ToolPurpose
get_bufferReturn the current editor buffer (whatever the user is seeing).
set_bufferReplace the whole buffer with new content. Use sparingly — prefer apply_edit.
apply_editFind 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_patchesList all patches the most recent successful eval registered, with role and duration.
evalRe-evaluate the buffer. Errors land in last_error.
playPlay a named registered patch. The user hears the audio; you don’t.
last_errorRead 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

  1. The user is editing patches.rhai in sndlab; you can see them typing because every frame the editor pane updates with their keystrokes (and a get_buffer from you reflects the latest).
  2. They say “make the ping a bit lower-pitched.”
  3. You call get_buffer to see the current code.
  4. You call apply_edit with old_string: "sine(330.0," and new_string: "sine(280.0," to drop the pitch.
  5. The next time the user presses F5 (or you call eval followed by play), they hear the change.
  6. 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_buffer or apply_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 test or default — 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 MCP play tool 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_ambient call).
  • 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 Track hierarchy 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(...), a chirp(...), or a sound primitive called with a duration argument). The buffer plays through Kira’s low-latency StaticSoundData path.
  • 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

PrimitiveStatusPurpose
patchshippedRegister a named patch.
sineshippedA sine oscillator. sine(freq) is unbounded; sine(freq, dur) wraps in take.
chirpshippedLinear-FM sweep, bounded by its duration argument.
noiseshippednoise(kind) is unbounded; noise(kind, dur) wraps in take.
envshippedAttack + exponential decay applied to a signal.
takeshippedTruncate a signal to duration_s. Sources support this implicitly via their two-arg form.
fade_outshippedCosine-squared fade over the buffer’s last duration_s. Requires a bounded source.
fade_inshippedsin² fade-up at the start. Complementary to fade_out — together they sum to constant power.
delayshippedPrepend silence before the source. Stagger entries in a mix(…).
tremoloshippedSine LFO amplitude modulation.
gainshippedLinear amplitude scaling.
bandpassshippedBiquad bandpass.
lowpassshippedBiquad lowpass.
highpassshippedBiquad highpass.
mixshippedSum multiple signals.
tapshippedA delay tap, used by with_taps. (Per-tap exponential decay is honoured in one-shots only; ambient streams use fixed-gain delay copies.)
grainsshippedStochastic damped-sine grain generator — bubbles, drips, rain.
sampleshippedLoad 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 a warn-level message in the eval summary.
  • role — one of "one_shot" or "ambient". See Roles.
  • signal — a Signal value 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 signal argument is fully rendered to a buffer before patch returns. 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 sine calls don’t share phase — each is an independent buffer.
  • Negative or zero duration_s produce 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 = 0 is zero, so the buffer starts at sample value 0. No click on onset before envelope.
  • Going downward (start_hz > end_hz) is fine. The math just works in reverse.
  • Going through zero (start_hz and end_hz straddle 0) is a pathological case: aliasing-style artefacts as the instantaneous frequency dips below 0 Hz. Don’t do that — use sine with 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.0 means “no ramp” (the buffer starts at full amplitude on sample 0).
  • decay_s — exponential decay time constant. Larger values = longer ring. The envelope falls to 1/e (≈ 37%) after decay_s seconds and to ~5% after 3 × 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:

  1. Extend the buffer until the envelope is inaudible — wasteful for long-ring sounds; you’re storing seconds of near-silent samples.
  2. Use fade_out — keep the buffer as long as you actually want to hear the sound, and let fade_out mop 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 a duration_s bigger 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.

WantUse
Hammered onset → exponential ringenv(0.005, 2.0)
Slow rise → constant body → smooth tailfade_in(1.0).fade_out(1.0)
Crossfade between two layersfade_in(d) on one, fade_out(d) on the other

Notes

  • Works on bounded and unbounded sources. For an ambient, fade_in shapes 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) and fade_out(d) on two layers whose sum you want flat works because sin²(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 000 zero samples first.
  • Then delegates to the source until it ends (one-shots) or forever (ambients).
  • Reports finite_duration_s() = source_duration + seconds for the one-shot renderer, so wrapping fade_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. delay is most useful inside mix([…]). 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 pitch and speed.
  • 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. 0 is no modulation; 1 swings 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.
  • depth above 1 clamps to 1. No hard limit on rate_hz but 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.0 is silence; 1.0 is the source unchanged; 2.0 doubles. 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 bandpass calls in parallel via mix(...) 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.707 is 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.707 for 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 brown noise 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 a Signal. Common cause: forgetting to wrap a number in sine(...) or similar.

Notes

  • An empty array produces an empty buffer (mix([]) returns a zero-length Signal).
  • 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_k1/e timeSounds like
12 (default)~83 msA discrete reflection — a brief echo of the source’s onset.
6~167 msA longer late reflection.
3~333 msA 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 with tap(...).

Notes

  • Combining decays: a single signal can have many taps with different decay_k values — 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_k field 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: probability rate_hz / 48000 of 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 as tap’s decay_k). The default is 80 (≈12 ms 1/e — short, popping bubble-like). Drop to 20 (~50 ms) for longer rings; raise to 200 for 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 wantrate_hzdecay_kfreq_lo–freq_hi
Fizzy effervescence80–200801500–4000
Coarse bubbling (torpedo wake)40–8060500–2500
Big slow blobs5–1030100–800
Sharp rain ticks20–602003000–8000
Sand / debris hash200–5001502000–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. Two grains calls with identical parameters in one script will share the same sequence; vary one parameter slightly (e.g. grains(60.0, 600.0, 2800.0) vs grains(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 holding project.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.0 is the original. Composes by multiplication, so .pitch(0.5).pitch(0.5) = 0.25 (two octaves down). Repitched samples report their adjusted duration to fade_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 taps
  • mix([...]) — 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 roleone-shotambient
End of bufferterminateswraps to start
Use forimpacts, launches, single eventsbubble 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

  1. Download a clip from Freesound. Look for a CC0 or CC-BY licence so you can ship it with your game.
  2. Drop it in your project directory, e.g. my-project/samples/bubbles.wav.
  3. Reference it from a patch as sample("samples/bubbles.wav").
  4. 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 with ffmpeg.

Notes

  • Mono fold. Stereo sources are folded down to mono. If you need the panning, you can split-load by writing two .wav channel files, but the engine is mono-only for now.
  • Caching. The decoder runs once per eval per sample(...) 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.ron schema:

  • 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 longer fade_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-core as 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:

  1. Echo Field declares sndlab-core as a dependency.
  2. Audio patches live in crates/app/src/audio/patches/*.rhai (or embedded via include_str! for release builds).
  3. At game startup, the audio engine evaluates the bundled scripts and registers patches by name.
  4. Game events (SimEvent::TorpedoFired, …) trigger one-shots; the listener-state modulation surface drives ambient parameters.
  5. 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.