Welcome to Glacier Docs
Glacier is a digital audio workstation (DAW) built from scratch in Rust — audio engine, GPU-rendered UI, and sequencer, all hand-written with minimal dependencies. It’s an active, ongoing learning project, so expect the architecture to keep evolving.
This book exists because the project outgrew a single README a while ago.
It’s where the why behind the code lives, not just the what (that part’s
covered by generated API docs via cargo doc).
Getting Started
Documentation is split to mirror the workspace’s own structure:
- glacier-app — the application itself: graphics, audio thread, UI state, and the sequencer/playlist/piano roll windows.
- glacier-dsp — the standalone signal-processing crate: RMS/peak/ZCR analysis, FFT, envelope following.
Download
View the installation guide or the repository to get started!
Installation
Installation
Glacier is a Rust workspace with two crates: glacier-app (the DAW itself)
and glacier-dsp (the signal-processing library it depends on).
Requirements
- Rust (stable toolchain)
- A working audio output device (Glacier uses
cpalfor audio I/O)
Clone
git clone git@github.com:remysedlak/glacier.git
cd glacier
Run
cargo run --release -- --dev
Run this from the repository root, not from inside glacier-app/ — asset
paths (fonts, icons, the default project file) are resolved relative to the
working directory you invoke cargo from, not the crate’s own location.
-
--releaseis recommended over a debug build for actual use; the audio callback and GPU rendering are both real-time-sensitive and a debug build can introduce audible glitches or dropped frames. -
-- --devloads assets/projects/dev.toml instead of the default new-project file. This is useful for testing against a project that already has tracks/patterns/audio blocks in place, rather than starting from empty every run.
Issues
Please open a Github Issue if you have any trouble starting the application or hearing audio.
glacier-app
The Glacier app module contains all of the code for setting up and running the audio and graphics thread.
The architecture involve two decoupled threads communicating events to ring buffers. The app logic is combined on a native desktop application via winit.
sequenceDiagram
participant U as UI Thread
participant RB as ring buffer
participant A as Audio Thread
U->>RB: try_push(AudioCommand)
RB->>A: try_pop(AudioCommand)
Note over U,A: Audio Thread pops on its own callback tick, no direct call
A->>A: is_playing = !is_playing
Graphics & Rendering
The graphics module paints windows and widgets to the screen in the right visual order. This chapter covers the rendering pipeline: how shapes and text get drawn, in what order, and the tools built for common drawing patterns.
Rendering Pipeline
Painter’s Algorithm
Windows and widgets are drawn back-to-front in a single pass. Each one’s vertex/text/icon ranges get recorded so a later render pass can issue draw calls per-window in the correct order:
#![allow(unused)]
fn main() {
struct WindowDrawRange {
pub vert_start: u32,
pub vert_end: u32,
pub char_start: usize,
pub char_end: usize,
pub icon_start: usize,
pub icon_end: usize,
}
}
Most windows only need one RecordedRegion. Playlist and piano roll are the exception — they have scrollable sub-regions that each need their own scissor rect (playlist has static/ruler/header/timeline; piano roll has static/keys/grid). Instead of going through the generic single-region path, they push multiple RecordedRegions directly and continue past the loop’s normal bottom-of-loop record call.
When a window has both icons and multiple sub-regions, only one sub-region should own that window’s icons — attaching the same icon range to every sub-region would draw them more than once. Playlist’s icons conceptually belong to its static region (the one scissored to the whole window), so icon_start/icon_end get captured once, right after the icon-push loop runs, and only the static region’s record(...) call gets the real range. The other sub-regions (ruler, header, timeline) get an empty range at that same offset, so nothing extra gets drawn there.
Icons follow the same recording shape as text. A persistent icon_vertex_buffer holds the quad geometry, and icon_draws: Vec<(u64, &wgpu::BindGroup)> records a byte offset plus bind group per icon — same reason glyphs need this, since every icon has its own texture and needs its own bind group set before drawing. WindowDrawRange carries icon_start/icon_end right alongside char_start/char_end so icons get clipped and z-ordered exactly like everything else in that region.
Icons didn’t always work this way. They used to get collected into one flat list and drawn in a single unscissored pass after every region had already been drawn — which meant they ignored window z-order and scissor rects completely. An icon belonging to a window underneath could paint straight through a window on top of it. The fix was making icons go through the same region-recording path as text. Any new kind of drawable added to this engine needs to go through that same path, or it’ll silently skip z-order and scissoring the same way icons did.
Z-Order
z_order: Vec<usize> stores window indices back-to-front — the last entry is topmost. bring_to_front(z_order, id) moves a window to the front by removing and re-pushing its id. The toolbar sits outside this system entirely and is always drawn last, so it’s always on top of every window.
Text — fontdue
Text is rendered as textured quads, not as a separate draw phase. That’s a deliberate choice, not the obvious default. An earlier attempt used glyphon, which required all text draws to happen after all geometry, unconditionally — its prepare() had to run before the render pass opened and render() inside it. That’s incompatible with painter’s algorithm on overlapping windows, since a lower window’s text would always end up drawn on top of a higher window’s geometry. Fontdue rasterizes glyphs to CPU bitmaps once, uploads them as wgpu::Textures, and treats each glyph as geometry — so text and colored rects can interleave freely in draw order.
#![allow(unused)]
fn main() {
HashMap<String, HashMap<(char, u32), (wgpu::Texture, wgpu::BindGroup, fontdue::Metrics)>>
}
Outer key is font name, inner key is (char, size). build_glyph_cache pre-rasterizes every size in a fixed startup slice — any TextItem size not in that slice silently produces no glyph. Zero-size glyphs get skipped (a zero-dimension texture panics wgpu). Sampling uses FilterMode::Nearest, not Linear, since linear filtering blurs pixel-exact glyph edges.
The fragment shader branches on UV range to decide what it’s drawing:
if in.uv.x < 0.0 { use color } // geometry quad
else if in.uv.x > 1.0 { sample icon rgba } // icon quad
else { sample glyph alpha * color } // glyph quad
Rectangles
Rectangle is pure geometry (x, y, width, height) with three drawing entry points depending on what a shape actually needs:
rect.draw(...)— plain, no border, no hover check. For static backgrounds and dividers.rect.draw_bordered(...)— border, no hover. Rarely called directly; mostly used internally by the builder below.rect.draw_style().interactive(Some(mouse_state)).bordered(Some(style)).draw(...)— the builder, for anything that needs hover, a border, or both. Either chained method can be skipped or passedNone.
The builder exists because “one method per combination of optional behaviors” (draw, draw_bordered, draw_interactive, draw_interactive_bordered, …) scales as 2ⁿ in the number of independent axes. It only became worth collapsing once a real call site (toolbar buttons) needed both axes at the same time.
#![allow(unused)]
fn main() {
pub struct RectangleCtx<'a> {
rectangle: &'a Rectangle, // borrowed, never copied/owned
interactive: Option<&'a MouseState>, // None = skip hover check entirely
border: Option<BorderStyle>, // None = call draw() not draw_bordered()
}
}
.interactive(...) / .bordered(...) take and return Self by value so they can chain. .draw(...) is the one terminal call — it consumes the ctx and returns DrawResponse { hovered, x, y, width, height }. Geometry fields are on the response because most interactive rects (toolbar buttons) get read again afterward for tooltip/icon positioning. Small deliberate cost for this file’s usage pattern, not necessarily the right tradeoff everywhere else.
Static shapes with no hover/border need should just stay on plain .draw(...). Routing a divider through the builder “for consistency” just produces a DrawResponse nobody ever reads.
Gotcha: building and chaining in one expression discards the
Rectangleitself.let btn = Rectangle::new(...).draw_style()...draw(...)bindsbtnto theDrawResponse, not the rectangle —btn.xwon’t compile unless you actually meant to use the response’s own geometry fields. If anything downstream needs the original rectangle (common — a lot of toolbar buttons position a sibling off it, likebpm_down.y = bpm_up.y + 18.0), bind theRectangleseparately first.
Interaction
Users can interact with the desktop application using their mouse and keyboard in many ways. Each frame, OS and device events are processed by winit.
With the mouse, the Glacier app offers affordances and signifiers such as the mouse cursor icon and interactive color hovering.
Everything should be possible on the keyboard through hotkeys in the future.
Input Priority
Certain components may ‘take control’ of the mouse state so it only applies to them — the save modal is the clearest example, blocking every other component’s input for the frame.
flowchart TD
D{show_save_modal?} -->|yes| MM[mouse_state: blanked to NEG_INFINITY, clicks false]
D -->|yes| Modal[modal::draw, gets real_mouse_state]
D -->|no| RM[mouse_state: real, unchanged]
MM --> A[Mini-windows]
RM --> A
MM --> B[Toolbar / Footer / Trays]
RM --> B
A -->|z_order + click_owner masking| Acc[interaction accumulator]
B -->|plain source order, no z_order masking| Acc
Modal --> Acc
Acc --> Final[final InteractionResult]
Click and Hover Ownership
A lot of UI components will overlap during the production of music. If a user has the piano roll active on their screen and they click a note, the Mixer behind the piano roll should not absorb the interaction.
click_owner
#![allow(unused)]
fn main() {
// Determine which open window, if any, owns this frame's click
let click_owner: Option<usize> = if mouse_state.left_clicked && !menu_is_hovered {
self.z_order
.iter()
.rev()
.find(|&&id| self.mini_windows[id].is_open && self.mini_windows[id].is_hovered(mouse_state.x, mouse_state.y))
.copied()
} else {
None
};
}
Computed once per frame after the bring-to-front pass. The topmost hovered open window owns the click.
blocked (hover masking)
#![allow(unused)]
fn main() {
let blocked = self.context_menu.is_some() && menu_is_hovered
|| self.z_order
.iter()
.skip_while(|&&z_id| z_id != id)
.skip(1)
.any(|&above_id| {
self.mini_windows[above_id].is_open
&& self.mini_windows[above_id].is_hovered(mouse_state.x, mouse_state.y)
});
}
z_order is back-to-front — iterating forward from id gives windows drawn on top of it. If any open window above covers the mouse, this window is blocked.
masked_mouse
#![allow(unused)]
fn main() {
let masked_mouse = MouseState {
left_clicked: mouse_state.left_clicked && click_owner == Some(id),
x: if !blocked { mouse_state.x } else { f32::NEG_INFINITY },
y: if !blocked { mouse_state.y } else { f32::NEG_INFINITY },
..*mouse_state
};
}
Every window draw call receives &masked_mouse. MouseState must derive Copy for struct update syntax.
Note: only the mini-window loop computes click_owner/blocked z_order masking. Outside that loop, five different components handle masking differently:
- Toolbar, footer, context menu, track tray, file tree — use the shadowed
mouse_state, which is real unless the save modal is open, in which case it’s fully blanked (x/ytoNEG_INFINITY, click flags tofalse) before any of these run. pattern_tray— masked only against the context menu, viamouse_state.hidden(self.context_menu.is_some() && menu_is_hovered).modal— always receivesreal_mouse_state, fully unmasked, even whileshow_save_modalis true — otherwise the modal couldn’t register clicks on its own buttons while everything else is blind to input.
These five components merge their InteractionResult in plain source order against the running interaction accumulator — no z_order priority between them.
The audio thread
The audio thread’s main job is to satisfy commands from the UI and maintain a low latency audio stream.
Voice Model — Polyphony
VoicePool, not Vec
#![allow(unused)]
fn main() {
pub const MAX_VOICES_PER_TRACK: usize = 64;
pub struct VoicePool {
pub positions: [f32; MAX_VOICES_PER_TRACK],
pub rates: [f32; MAX_VOICES_PER_TRACK],
pub current_volumes: [f32; MAX_VOICES_PER_TRACK],
pub target_volumes: [f32; MAX_VOICES_PER_TRACK],
pub stop_at_frames: [Option<f32>; MAX_VOICES_PER_TRACK],
pub active: [bool; MAX_VOICES_PER_TRACK],
pub age: [u32; MAX_VOICES_PER_TRACK],
active_scratch: Vec<usize>,
voice_age_counter: u32,
}
pub struct Track {
pub data: TrackData,
pub samples: Vec<f32>,
pub mono_samples: Vec<f32>,
pub voice_pool: VoicePool,
pub show_velocity: bool,
pub rms_l: f32,
pub rms_r: f32,
pub peak_hold: f32,
}
}
This used to be Vec<Voice>, where Voice was one bundled struct with all six fields together. Got rewritten into a fixed-size, struct-of-arrays pool — every voice’s position lives in one flat array, every rate in another, etc. The old Voice type doesn’t exist anymore; it got fully replaced, not just wrapped.
Two reasons for the rewrite: capping polyphony at a real number instead of letting Vec<Voice> grow forever, and laying the data out so it’s actually possible to batch it with SIMD later (not done yet, just the groundwork).
Spawning — find a free slot, or steal the oldest
#![allow(unused)]
fn main() {
pub fn spawn(&mut self, position: f32, rate: f32, current_volume: f32, target_volume: f32, stop_at_frame: Option<f32>) -> usize {
let slot = (0..MAX_VOICES_PER_TRACK)
.find(|&i| !self.active[i])
.unwrap_or_else(|| {
(0..MAX_VOICES_PER_TRACK).min_by_key(|&i| self.age[i]).unwrap()
});
// write all five fields into that slot, bump age, mark active
slot
}
}
No more unbounded pushing. If there’s a free slot, use it. If the pool’s full (64 voices already going on one track), kill the oldest one and reuse its slot. age is just a counter that only ever goes up — every spawn stamps the slot with the current counter value, so “oldest” means “lowest age,” not anything to do with pitch or position.
Cleanup — no more retain()
Old version swept dead voices out of a Vec every callback with .retain(). New version doesn’t need to — a dead voice just gets active[i] = false the instant it finishes, and that’s it. No sweep pass. refresh_active_indices() runs once per track per callback, refilling a scratch buffer with whichever indices are currently active, and the mixing loop walks that scratch buffer instead of the raw 64 slots.
#![allow(unused)]
fn main() {
track.voice_pool.refresh_active_indices();
for i in 0..track.voice_pool.active_indices().len() {
let voice = track.voice_pool.active_indices()[i];
mix_voice(track, voice, shutdown_volume, master_volume, sample);
}
}
stop_at_frame and the mono/stereo mess
This one took a while to get right. frame_advance and frame_lookahead both exist specifically because stereo data is interleaved (L0 R0 L1 R1..., 2 slots per frame) and mono data isn’t (S0 S1 S2..., 1 slot per frame). Every number that used to be a hardcoded 2.0 or 3.0 — the position-advance step, the bounds-check lookahead, the frame alignment — had to become channel-aware or mono tracks would read garbage.
#![allow(unused)]
fn main() {
pub fn frame_advance(channels: u16) -> f32 {
if channels == 1 { 1.0 } else { 2.0 }
}
pub fn frame_lookahead(channels: u16) -> usize {
if channels == 1 { 1 } else { 3 }
}
}
mix_voice uses both of these now instead of bare constants:
#![allow(unused)]
fn main() {
let channels = track.data.channels;
let pos = if channels == 1 {
track.voice_pool.positions[voice] as usize
} else {
align_to_frame(track.voice_pool.positions[voice])
};
let lookahead = frame_lookahead(channels);
let stop = track.voice_pool.stop_at_frames[voice].unwrap_or(track.samples.len() as f32);
if pos + lookahead >= stop.min(track.samples.len() as f32) as usize {
track.voice_pool.active[voice] = false;
} else {
// smooth volume, interpolate, mix into sample, advance position
}
}
stop_at itself (computed at trigger time for sample blocks) also needs frame_advance baked in, same reason — it has to be in the same index-space as position for the comparison above to make sense:
#![allow(unused)]
fn main() {
let stop_at = audio_block.length as f32 * frame_advance(track.data.channels) * step_duration;
}
Pattern-triggered notes still pass stop_at_frame: None — they just play out until they run off the end of the actual sample data.
Interpolation — one function, branches internally
interpolate(samples, position, channels) replaced two separate functions (interpolate_mono/interpolate_stereo) that used to get called with a manual if channels == 1 {...} else {...} at every call site. Now it’s one call, branches inside:
#![allow(unused)]
fn main() {
pub fn interpolate(samples: &[f32], position: f32, channels: u16) -> (f32, f32) {
if channels == 1 {
interpolate_mono(samples, position)
} else {
interpolate_stereo(samples, position)
}
}
}
Both interpolate_mono and interpolate_stereo still exist underneath it, they’re just not called directly from audio.rs anymore.
rms_l/rms_r/peak_hold stay on Track, not in the pool
Same as before — these are aggregate signal across all of a track’s currently-playing voices, summed together, not per-voice state. They don’t belong in VoicePool and never did.
Command Loop — how the UI talks to audio
Still the same shape as it’s always been. Every callback drains the command queue before touching any samples:
#![allow(unused)]
fn main() {
while let Some(cmd) = consumer.try_pop() {
handle_command(cmd, &mut engine_state);
}
}
consumer/producer are the two ringbuf halves. UI sends AudioCommands in, audio thread sends UiCommands back out, nothing’s behind a Mutex. The audio thread owns the real EngineState — tracks, patterns, audio_blocks, bpm, all of it — and the UI’s Graphics struct is just a mirror, updated only by whatever UiCommands come back.
Preview playback now carries its own channel count through the whole chain, since preview files aren’t guaranteed to be the same channel layout as whatever track triggered them:
#![allow(unused)]
fn main() {
AudioCommand::PreviewSample(samples, channels) => {
engine_state.preview_samples = samples;
engine_state.preview_channels = channels;
engine_state.preview_position = 0.0;
}
}
Lookups are still always .find() by id, never bracket-indexed — nothing about that changed.
Playback Timing — steps, not wall-clock
samples_per_step(sample_rate, bpm) is still the one unit of time the whole sequencer runs on. sample_counter still drives step-advance once per callback, except the divisor isn’t a hardcoded 2.0 anymore — it uses whatever the actual output device negotiated:
#![allow(unused)]
fn main() {
sample_counter += data.len() as f32 / output_channels as f32;
}
Same deal for the meter-update frame count a bit further down. Both of these used to assume exactly 2 output channels; now they use output_channels, which comes from the real negotiated stream_config.channels. Anything that isn’t stereo (mono gets rejected outright at startup, anything 3+ channels works fine with the extra channels left silent) used to silently desync the playhead from real audio time. Fixed now.
current_step and total_steps both got a .max(1) guard added before any subtraction happens, since an empty or all-zero-length playlist used to be able to underflow 0usize - 1 and panic.
Metering & Spectrum — reporting without stalling audio
Mostly unchanged, but it’s not all crammed into one closure anymore. push_meter_updates and push_spectrum_frame are real, named functions now, living in audio/metering.rs, called from fill_f32 instead of being inlined 20-line blocks in the middle of the callback.
RMS still smooths in squared space with smooth_toward, still takes one .sqrt() at report time. Reporting still throttles to roughly every 1024 frames, same reasoning as before — no point flooding the ring buffer faster than the UI can render a frame.
master_peak gets reset to 0.0 inside push_meter_updates, right after the MasterLevel push. Worth saying explicitly since it’s easy to assume a running max like that just accumulates forever if you’re skimming — it doesn’t, it’s a real reset every window.
Spectrum still buffers the mono mixdown across however many callbacks it takes to hit SPECTRUM_WINDOW (2048 samples), still Hann windows it, runs the FFT, corrects for window attenuation, converts to dB. SPECTRUM_WINDOW moved from a local const inside init() to a module-level const in audio.rs, since push_spectrum_frame needed to see it too now that it’s a separate function.
Shutdown — fading, not cutting
Same idea as before — drop the stream outright and you get a click, so instead is_shutting_down ramps shutdown_volume down from 1.0 over the next several callbacks.
Two real bugs got fixed here. First, the fade used to be a flat -0.0001 per sample, which meant fade time depended on sample rate — ~226ms on a 44.1kHz device, more like 104ms on 96kHz. Now it’s computed from the actual sample rate so fade time is a fixed, device-independent duration:
#![allow(unused)]
fn main() {
let fade_decrement = 1.0 / (FADE_TIME_SECS * engine_state.sample_rate);
}
Second, shutdown_volume never used to get reset after the fade finished — it’d just sit at whatever small negative number it landed on. Harmless as long as shutdown is genuinely terminal, but if anything ever re-triggers shutdown without rebuilding the stream, every sample afterward gets multiplied by a negative number. Fixed:
#![allow(unused)]
fn main() {
if shutdown_volume <= 0.0 {
producer.try_push(UiCommand::ShutdownComplete).ok();
is_shutting_down = false;
shutdown_volume = 1.0;
}
}
Preview playback now fades along with everything else during shutdown too — it used to only scale by master_volume, so a preview playing right as the app closed would cut off hard while the rest of the mix faded smoothly. Now process_preview takes shutdown_volume as well and multiplies it in, same as mix_voice already did.
Save-before-confirm is unchanged — Shutdown still saves and pushes SaveComplete before deciding whether to send ShutdownComplete immediately or wait for the fade to actually finish.
Project & Data Model
A Glacier project is one .toml file on disk plus the runtime state built
from it. This chapter covers what that file actually contains, why the
in-memory model splits into two tiers, and how the two stay in sync across
save/load.
Two Tiers: Persisted vs. Runtime
Every core concept in the song has a persisted shape (serializable,
written to the .toml file) and, for tracks specifically, a separate
runtime shape that wraps it with state that only exists while the app
is running.
#![allow(unused)]
fn main() {
// Persisted — serde-derived, written to disk as-is
struct TrackData {
id: u32,
name: String,
path: String,
is_muted: bool,
channels: u16,
track_volume: f32,
root_note: u8,
}
// Runtime — wraps TrackData, adds state with no meaning at rest
struct Track {
data: TrackData,
samples: Vec<f32>, // decoded WAV data, not stored in the toml
voices: Vec<Voice>, // currently-sounding notes, meaningless when saved
show_velocity: bool, // UI-only toggle
rms_l: f32,
rms_r: f32,
peak_hold: f32,
}
}
Track is never serialized directly — Project only ever stores
Vec<TrackData>. This split exists because samples (a decoded WAV, often
large) and voices/meter state (transient, per-callback) have no business
in a save file; only TrackData describes what the track is, independent
of whatever’s currently playing.
PatternData and AudioBlock, by contrast, don’t need this split — they
have no meaningful “currently playing” runtime-only state, so they’re used
directly, both as the live data the audio thread mutates and as what gets
written to disk.
Project — the file itself
#![allow(unused)]
fn main() {
struct Project {
name: String,
bpm: f32,
master_volume: f32,
audio_blocks: Vec<AudioBlock>,
tracks: Vec<TrackData>,
patterns: Vec<PatternData>,
}
}
This struct’s shape is the .toml schema — #[derive(Serialize, Deserialize)] means whatever fields live here are exactly what gets
written and read. Notice tracks: Vec<TrackData>, not Vec<Track> — this
is the two-tier split above showing up directly in the save format.
Project::default() is what a brand-new project looks like: no tracks, no
blocks, and — deliberately — one empty pattern already present (id: 0, name: "Pattern 1"), so the sequencer/piano roll always has something to
show rather than an empty list on first launch.
AudioBlock & AudioBlockType — the playlist timeline
#![allow(unused)]
fn main() {
enum AudioBlockType {
Sample(usize), // plays a track's raw sample directly
Pattern(usize), // plays a pattern's sequenced notes
Mixing, // reserved for automation — not yet driving playback
}
struct AudioBlock {
id: usize,
track_id: usize,
start_step: u32,
length: u32,
block_type: AudioBlockType,
}
}
An AudioBlock is a placement on the playlist timeline: this content,
starting at this step, lasting this many steps. block_type decides
whether “this content” means “trigger one sample” or “play a pattern’s
notes” — same placement struct, different playback source.
#[serde(tag = "kind", content = "id")] on AudioBlockType is what makes
this readable in the saved .toml rather than an opaque tagged union — it
serializes as e.g. kind = "Sample", id = 3 instead of a positional
tuple.
Note on
track_id’s type:AudioBlock.track_idisusize, whileTrackData.idisu32— matching them requires an explicitas u32/as usizecast at every lookup site (visible inaudio.rs, e.g.t.data.id == *sample_track_id as u32). This isn’t a bug, just an inconsistency worth knowing about if you’re tracing a lookup that isn’t matching — check the cast before assuming the id itself is wrong.
Patterns, Sequences, Notes — the step grid
#![allow(unused)]
fn main() {
struct PatternData {
id: usize,
name: String,
sequences: Vec<Sequence>,
}
struct Sequence {
track_id: u32, // which track this row belongs to
steps: Vec<Note>,
}
struct Note {
velocity: f32, // 0.0 = off, >0.0 = on
pitch: u8, // MIDI note, 60 = middle C
}
}
A pattern is a set of Sequences, one per track that has any notes in
that pattern — tracks with nothing programmed simply have no Sequence
entry at all, rather than a Sequence full of empty Notes. This is a
sparse representation, not a fixed grid: Sequence.steps only grows as far
as the last active note.
This sparsity is actively maintained, not just a save-time optimization —
every note-toggle in audio.rs ends with a trim:
#![allow(unused)]
fn main() {
while seq.steps.last().map(|n| n.velocity == 0.0).unwrap_or(false) {
seq.steps.pop();
}
}
Toggling off the last active step in a row shrinks steps back down
immediately, rather than leaving trailing empty Notes around. Note::DEFAULT
(velocity: 0.0, pitch: 60) is the canonical “empty” value used both to pad
a row up to a new step index and to check against when trimming.
Voice — not part of the saved model at all
#![allow(unused)]
fn main() {
struct Voice {
position: f32,
is_playing: bool,
playback_rate: f32,
current_volume: f32,
target_volume: f32,
stop_at_frame: Option<f32>,
}
}
Worth calling out explicitly: Voice never appears in Project and has no
serde derive. It’s pure runtime — one per currently-sounding note, created
on trigger and dropped once finished (see the audio thread chapter’s Voice
Model section for the full mechanics). A saved project has zero opinion
about what’s currently playing.
Save / Load Flow
Loading: get_project(path) reads and deserializes the .toml into a
Project. get_tracks(project) then converts each TrackData into a full
runtime Track by decoding its WAV file (path_to_vector) and wrapping
with empty runtime state — this is the one place TrackData becomes
Track.
Saving: Project::new(...) does the reverse — given the current
runtime &[Track], it strips each down to just its .data field
(discarding samples/voices/meters) to rebuild a Vec<TrackData>, bundles it
with the current patterns/audio_blocks/bpm/master_volume, and
save_to_toml writes the result. This round-trip (Track → TrackData →
toml → TrackData → Track) is why nothing runtime-only can ever leak into
a save file — the conversion function simply doesn’t have access to it.
Both directions go through the audio thread exclusively (see Command Loop
in the audio thread chapter) — SaveProject/Shutdown handlers build and
write the Project from the audio thread’s own copy of tracks/patterns/
blocks, since that copy is the sole source of truth.
ID Minting & Lookup
This rule applies uniformly across every id-bearing collection in the project — tracks, patterns, and audio blocks alike. It was discovered piecemeal (tracks first, then patterns and blocks turned out to have the identical bug), but the rule itself is one rule, not three.
The Core Distinction: Identity vs. Position
Every .id field (TrackData.id, PatternData.id, AudioBlock.id) is a
stable identity, referenced by other structs across the codebase
(Sequence.track_id, AudioBlockType::Pattern(pattern_id),
AudioBlockType::Sample(track_id)). It is never a position index into its
own Vec — even though, before anything is ever deleted, an id will
coincidentally equal its position. That coincidence is exactly what makes
this bug easy to introduce and hard to notice: everything works until the
first deletion breaks the assumption.
The test that decides which one you’re holding: does this value’s meaning
need to survive being passed to a different thread, or to a later frame?
If yes, it’s an id — resolve it with .find(). If it’s consumed
synchronously, in the same call that produced it, a plain position is fine.
Minting New IDs — Never .len()
#![allow(unused)]
fn main() {
id: collection.iter().map(|x| x.id).max().map(|m| m + 1).unwrap_or(0)
}
.len()-derived ids collide with existing ids the moment anything has ever
been deleted — len() shrinks on delete, but surviving ids don’t renumber
to fill the gap. Scanning for the current max and adding one is the only
minting strategy that survives deletions. This applies at every creation
site: LoadTrack, CreatePattern/DuplicatePattern, CreateAudioBlock
(all in audio.rs, since it’s the sole minting authority — see below).
Display labels are the one exception — format!("Pattern {}", patterns.len() + 1)
is a human-facing count, not an identity, so it has no collision risk and
can stay .len()-based.
Lookup — .find()/.position(), Never Bracket-Indexed by ID
#![allow(unused)]
fn main() {
collection.iter_mut().find(|item| item.id == x) // to mutate
collection.iter().position(|item| item.id == x) // then .remove(pos)
}
collection[x] is only correct when x is a genuine position, computed and
consumed in the same synchronous call — with no round-trip through a
different thread or a later frame in between. The instant a value comes
from an .id field, or has crossed the ring-buffer boundary (UI → audio
thread → confirmation back), it must be resolved by scanning for a matching
id, not indexed directly.
Fixed-slot constants are the legitimate exception —
mini_windows[SEQUENCER_ID] is a real constant array index, not an id
masquerading as one. Likewise, values resolved via .position()/
.enumerate() and used immediately, within the same frame, are fine staying
positional.
Audio Thread Is the Sole Minting/Mutating Authority
UI-side click handlers never mutate tracks/patterns/audio_blocks
directly for create or delete — they only send an AudioCommand. The audio
thread mints the id, mutates its own copy, and reports back with a
confirmation UiCommand, which the UI applies to its own mirrored state.
Confirmation commands are named past-tense on purpose — TrackLoaded, TrackDeleted, TrackUpdated, BpmChanged, PatternUpdated, PatternDeleted, AudioBlockLoaded, AudioBlockDeleted — to make clear they represent something that already happened on the audio thread’s authoritative copy, not a request.
This exists specifically to prevent two independent copies from minting or mutating the same conceptual thing on their own — the failure mode when that happens is silent divergence between the UI’s view and the audio thread’s real state, which surfaces later as a corrupted save or a UI that shows something the audio thread disagrees with.
Delete-Cascade Symmetry
Every delete needs identical cleanup applied on both the audio thread’s
copy and the UI’s mirrored copy. Cleanup logic present on only one side is a
live bug waiting for a save/reload, or any moment the two copies are
compared, to expose it — for example, DeleteTrack must remove orphaned
AudioBlockType::Sample events referencing the deleted track on both
sides; missing it on either side leaves dangling references pointing at a
track that no longer exists.
When adding any new delete path, check both sides of the boundary — the audio thread’s handler and whatever applies its confirmation back on the UI side — for whether cascading cleanup is needed, and whether it’s actually present on both.
glacier-dsp
glacier-dsp
The glacier-dsp crate abstracts away difficult digital signal processing algorithms for the app to call.
Signal Analysis
Functions in this chapter extract information from an existing buffer of samples — loudness, frequency content, zero-crossing behavior. None of them generate or alter audio; they measure it.
Single-Window Measurements
Three windowed measurements share the same shape: take a slice of samples, return one number describing that whole slice.
#![allow(unused)]
fn main() {
pub fn peak_window(samples: &[f32]) -> f32 // max(|x|)
pub fn rms_window(samples: &[f32]) -> f32 // sqrt(mean(x²))
pub fn zcr_window(samples: &[f32]) -> usize // count of sign changes
}
peak_window— the largest absolute sample value in the window. Used for peak-hold meters, where you want the loudest instant, not an average.rms_window— root-mean-square:sqrt(Σx² / n). This is what “loudness” usually means perceptually, since it reflects energy over the window rather than a single spike.zcr_window— counts how many times the signal crosses zero within the window. Rises with pitch/noisiness; a pure low sine has a low ZCR, a cymbal hit has a high one. (Reference)
Time-Series Versions — Hop Over a Longer Buffer
rms, peak, and zcr are the windowed versions applied repeatedly across
a longer buffer, producing a time series instead of one number:
#![allow(unused)]
fn main() {
pub fn rms(samples: &[f32], window_size: usize, hop_size: usize) -> Vec<f32>
pub fn peak(samples: &[f32], window_size: usize, hop_size: usize) -> Vec<f32>
pub fn zcr(samples: &[f32], window_size: usize, hop_size: usize) -> Vec<usize>
}
Each slides a window_size-wide window across samples, advancing by
hop_size each step, and calls the matching _window function on each
slice. hop_size < window_size gives overlapping windows (smoother output,
more compute); hop_size == window_size gives non-overlapping windows.
Envelope Follower — Smoothing a Time Series
#![allow(unused)]
fn main() {
pub fn envelope_follower(rms: &[f32], attack: f32, release: f32) -> Vec<f32>
}
Takes an RMS time series (typically from rms()) and smooths it with
different speeds depending on direction: attack coefficient when the
signal is rising, release when falling. This is the standard envelope-
follower shape from analog dynamics processing — fast attack lets transients
through quickly, slower release avoids the output chattering on every small
dip. Internally this is just repeated calls to smooth_toward (see
Synthesis chapter) with the coefficient chosen per-sample based on direction.
Spectrum Analysis — FFT
SpectrumAnalyzer
#![allow(unused)]
fn main() {
pub struct SpectrumAnalyzer { /* fft: Arc<dyn Fft<f32>>, window_size: usize */ }
impl SpectrumAnalyzer {
pub fn new(window_size: usize) -> Self
pub fn process(&self, samples: &[f32]) -> Vec<f32>
}
}
This is the one stateful type in the crate — constructing it plans the FFT
once (via rustfft’s FftPlanner), so repeated calls to process reuse
that plan rather than re-planning every call. process returns magnitudes
for bins 0 through Nyquist (window_size / 2 + 1 bins) — the upper half
of a real-valued FFT is a mirror image and is discarded, since it carries no
additional information.
Output is raw magnitude, not dB — always pass it through
magnitude_to_db before displaying (see below); raw FFT magnitude scales
with window_size and has no intuitive relationship to perceived loudness.
Windowing Before FFT — Hann Window
#![allow(unused)]
fn main() {
pub fn hann_window(samples: usize) -> Vec<f32>
pub fn window_compensation(window: &[f32]) -> f32
}
Running an FFT directly on a raw slice assumes that slice is exactly one
period of a periodic signal — it almost never is, and the discontinuity at
the slice’s edges leaks energy across frequency bins (“spectral leakage”).
Multiplying the slice by a Hann window tapers both edges toward zero first,
reducing that leakage at the cost of attenuating the signal’s actual
amplitude — window_compensation computes the correction factor
(window.len() / sum(window)) to undo that attenuation, so switching window
size or type doesn’t silently change the apparent loudness of the result.
Converting Magnitude to Something Displayable
#![allow(unused)]
fn main() {
pub fn magnitude_to_db(magnitude: f32, window_size: usize, compensation: f32) -> f32
}
Normalizes a raw magnitude by window_size and the window compensation
factor, then converts to dB (20 * log10(x)), floored at a minimum (1e-6)
to avoid log10(0) producing -inf for silence.
Bin ↔ Frequency Conversion
#![allow(unused)]
fn main() {
pub fn bin_to_freq(bin: usize, sample_rate: f32, window_size: usize) -> f32
pub fn freq_to_bin(freq: f32, sample_rate: f32, window_size: usize) -> usize
pub fn freq_resolution_per_bin(sample_rate: f32, window_size: usize) -> u32
}
An FFT bin index is meaningless without knowing the sample rate and window
size used to produce it — these convert between “bin N” and “this many Hz.”
freq_resolution_per_bin is the Hz-per-bin spacing itself (how much
frequency one bin step represents), useful for deciding window size against
a desired frequency resolution.
Log-Scaled Display Position
#![allow(unused)]
fn main() {
pub fn freq_to_log_position(freq: f32, min_freq: f32, max_freq: f32) -> f32
}
Maps a frequency to a 0.0..1.0 position on a logarithmic scale, not
linear. Pitch perception is logarithmic (each octave doubles frequency but
feels like an equal step), so mapping bins to screen pixels linearly crushes
almost all musically relevant content — bass through midrange — into a thin
sliver near the low end, with treble taking up most of the width for no
perceptual reason. Always use this instead of a raw bin-to-pixel mapping
when laying out a spectrum display.
Short-Time Fourier Transform (STFT)
#![allow(unused)]
fn main() {
pub fn stft(samples: &[f32], window_size: usize, hop_size: usize) -> Vec<Vec<f32>>
}
Applies the Hann-window-then-FFT pipeline repeatedly across a buffer, hopping
by hop_size each time — one magnitude spectrum per hop, giving frequency
content over time rather than a single snapshot. Output is still raw
magnitude per frame; each inner Vec<f32> needs magnitude_to_db before
display, same as a single SpectrumAnalyzer::process call.
Synthesis & Playback Math
Small set of functions that drive timing, pitch, and reading samples during playback. Not oscillators or sound generation, just the math deciding when, how fast, and from where existing samples play.
Timing — Steps and BPM
#![allow(unused)]
fn main() {
pub fn samples_per_step(sample_rate: f32, bpm: f32) -> f32
}
Turns a tempo into a sample count, how many audio frames make up one step. Derivation since the steps-per-beat thing isn’t obvious just from the formula:
- 60 sec/min.
beats_per_second = bpm / 60.0 - 4 steps per beat (1 step = 1/16 note in 4/4).
steps_per_second = beats_per_second * 4.0 samples_per_second / steps_per_secondis samples per step
This is basically the whole concept of “time” for the sequencer. Step advance, playhead position, sample cutoffs, all of it comes back to this number.
Exponential Smoothing
#![allow(unused)]
fn main() {
pub fn smooth_toward(current: f32, target: f32, coeff: f32) -> f32
}
current + (target - current) * coeff. Moves current toward target by some fraction each call. Used in three places that don’t seem related at first — voice volume ramping so notes don’t click on/off, RMS meter smoothing, envelope follower decay. Didn’t want to write the same decay math three times so it’s one function.
Higher coeff reaches target faster but less smooth, lower is smoother but slower. No universal right answer, depends what you’re smoothing. Voice volume uses 0.01, a gentle fade. Go faster and the click comes back, which defeats the whole point.
Side note: decaying toward zero with this can land in denormal float territory, tiny non-zero values the CPU is slow at. That’s what flush_denormal is for.
#![allow(unused)]
fn main() {
pub fn flush_denormal(x: f32) -> f32
}
if x.abs() < 1e-15 { 0.0 } else { x }. Once a value’s quiet enough nobody could ever hear it, snap it to real zero. smooth_toward never actually reaches zero since it’s exponential decay, so without this a value can sit decaying in denormal range basically forever, and every sample touching it runs 10-100x slower than normal. Wrapped around every smooth_toward call in the audio thread that’s decaying to 0.
Pitch → Playback Rate
#![allow(unused)]
fn main() {
pub fn semitones_to_rate(pitch: u8, root_note: u8) -> f32
}
2^((pitch - root_note) / 12). Turns a semitone interval into a speed multiplier. One recorded sample, played at different pitches. root_note is whatever pitch the sample was actually recorded at (default 60, middle C), and triggering some other pitch speeds up or slows down playback by whatever ratio gets you there. Octave up doubles the rate, octave down halves it. This is where voice_pool.rates[slot] actually comes from at trigger time.
Worth knowing this isn’t formant preserving, it’s just speed changes. Big intervals change the sample’s timbre and length too, not just pitch. Fine for drums and one shots, would sound bad pitch shifting a vocal an octave.
Reading Between Samples — Interpolation
Position is a float, not an int. Any time playback_rate isn’t exactly 1.0 (basically always since that’s the whole point of pitch shifting) position lands between two real samples instead of on one. Interpolation guesses the value that would be there.
#![allow(unused)]
fn main() {
pub fn interpolate(samples: &[f32], position: f32, channels: u16) -> (f32, f32)
}
One entry point, branches on channel count inside:
#![allow(unused)]
fn main() {
pub fn interpolate_stereo(samples: &[f32], position: f32) -> (f32, f32)
pub fn interpolate_mono(samples: &[f32], position: f32) -> (f32, f32)
}
Stereo’s interleaved (L0 R0 L1 R1...) so one interpolated pair needs 4 real reads, before and after for L, before and after for R. Mono isn’t interleaved (S0 S1 S2...), only needs 2 reads, and that one value gets copied into both L and R on return. No panning happening here, that’s its own feature, not built yet. This just mirrors mono into both channels for now.
Weighting’s the same idea either way. frac = position - position.floor() is how far between the two samples you are. Earlier sample gets (1 - frac), later gets frac. It’s a cheap stand in for the real answer (sinc reconstruction, which looks at a bunch of surrounding samples instead of just 2), traded accuracy for speed. Good enough here, would be too much aliasing for something mastering grade.
Frame Math — Mono vs Stereo
Three small helpers that only exist because stereo and mono have different frame widths in memory (2 slots vs 1), and that difference touches a bunch of math that used to just assume stereo everywhere.
#![allow(unused)]
fn main() {
pub fn align_to_frame(pos: f32) -> usize
}
Rounds position down to the nearest even index. Only makes sense for stereo since a frame always starts on an even slot. Mono doesn’t need this at all, every index is a valid frame. Callers check channel count first and pick this or a plain floor.
#![allow(unused)]
fn main() {
pub fn frame_advance(channels: u16) -> f32
}
How far position moves per sample, before multiplying by playback_rate. 1.0 mono, 2.0 stereo. Used for advancing a voice and for converting step length into the same units position and stop_at_frame are compared in.
#![allow(unused)]
fn main() {
pub fn frame_lookahead(channels: u16) -> usize
}
How many extra samples past the current position need to actually exist in the buffer so interpolate doesn’t read off the end. 1 for mono (pos, pos+1), 3 for stereo (pos through pos+3).
Limiting — Keeping Output in Range
#![allow(unused)]
fn main() {
pub fn soft_clip(x: f32) -> f32
}
x.tanh(). Guarantees output never goes past ±1.0, which digital audio can’t represent past anyway. Used tanh instead of a hard clamp because a hard clamp flattens the top of the waveform into a sharp corner once it crosses the ceiling, and that corner is literally what clipping sounds like. tanh rounds it off instead, quiet stuff passes through basically untouched, loud stuff gets squashed gradually instead of chopped flat.
Applied once per channel, after everything’s already summed into the final mix. Not per voice, not after every individual addition, since clipping a partial sum and then stacking more on top of it just compounds distortion instead of actually limiting the real total.