primitives::io
Where audio leaves and enters the Superb DAW SDK.
Purpose
primitives::io is the last stop in the engine: it depends on every
other engine module and is generic over any
superb::primitives::graph::BlockProcessor — an AudioGraph or a superb::primitives::mixer::Mixer
both qualify. It owns the block loop that turns a prepared processor plus a
Transport into rendered audio: driving the transport, splitting each
block at loop points, delivering queued MIDI, running the processor per
segment, and recording DSP load. This is the layer that drives offline
bounces, WAV files, and (behind the devices feature) real cpal output
streams and (also behind devices) input/capture streams. Behind the
files feature it also owns the file layer: symphonia decoding, rubato
resampling and bounce/export helpers.
Key types
Engine<P: BlockProcessor>— owns a processor and aTransport.new/prepare/render(the allocation-free block loop),transport_mut,processor_mut,midi_mut(this block'sMidiEventBuffer),set_midi_input(attaches an SPSCConsumer<TimestampedMessage>),set_midi_timing/midi_timing,set_midi_block_start_us,frames_rendered/stream_clock_us(MIDI placement, below),load(sharedLoadMeter),stats,into_parts.MidiTiming—Stream(default: timestamps → frames viaFrameMapper) orBlockStart(everything at frame 0).EngineStats—blocks,segments,midi_droppedcounters fromrender.Engine::live→(EngineHandle, AudioThread), withEngineCommand— a running engine split in two:EngineHandleis the control side (transport commands over a queue,publishof prepared graphs, software MIDI, position and state atomics,reclaimof retired graphs) andAudioThreadis the audio side (tickrenders one block,tick_timedone that starts at aBlockStarton the device clock; withdevices,run_on_devicemoves it into an output callback and returns aLiveOutput, whosestophands the thread back; a device that does not open hands it back with the error).EngineHandle::positionis the target of a seek from the moment it is sent. When the transport starts rolling under a device clock the audio thread publishes aRollStart(position and playback instant) thatEngineHandle::roll_startreads, and a recording lines its first frame up with it. This is what thetransportflow drives.LiveInput— captured audio on its way into a running engine:Engine::set_live_input(orEngineCommand::LiveInput(Some(..))on a live engine,Noneto detach; a replaced or detached input comes back throughreclaim).EngineCommand::TempoMapreplaces the transport's tempo map the same way, the old map coming back throughreclaim.EngineCommand::CaptureMidi(on)stamps incoming MIDI with the transport position while it records and queues it asCapturedMidi(MIDI_CAPTURE_CAPACITYdeep) forEngineHandle::captured_midito drain. Before each block the engine pulls the queued blocks into a ring four blocks deep and hands the frames to every node asProcessContext::input;LiveInputNodeplays chosen channels of it on a strip. Overruns drop the oldest frames, underruns are silence, both counted.capture::{capture_queue, CaptureProducer, CaptureConsumer, CaptureBlock}— a recycle queue of pre-allocated planar blocks for recording; always compiled, fed bydevice::run_captureor any producer.offline::render/offline::render_prepared— drive anEngineas fast as possible into a returnedPlanarBuffer(bounce / test rendering).wav::{Writer, write, write_to, read, read_from, repair, WavFormat}— RIFF/WAVE: aWriterthat streams audio to a file block by block, keeps the header current at everyflushand syncs, finishes it (RF64 past 4 GiB) atfinish, and counts frames, sealed frames and disk errors in atomics another thread can poll; one-shotwrite; a reader that accepts a file a crash left unfinished, up to its last whole frame;repairto finish such a header in place. 16-bit and 24-bit PCM and 32-bit float.device(featuredevices) —output_devices/input_devices,OutputConfig/InputConfig,OutputStream(open,open_timed) /InputStream(open,open_timed,open_to_queue),run_engine/run_capture, built oncpal0.18 (Apache-2.0).files(featurefiles) —AudioFile { buffer, sample_rate, bits_per_sample }from WAV, AIFF, FLAC, ALAC, MP3, AAC (MP4/M4A), Ogg Vorbis, CAF and Matroska;decode/decode_from/decode_bytes,decode_at_rate,resample,resampled_frames,bounce,bounce_wav,export_wav. See "File I/O" below.superb::primitives::io::FILES_FEATUREreports whether the build has it.
Usage
use superb::primitives::SampleRate;
use superb::primitives::graph::nodes::SineNode;
use superb::primitives::graph::AudioGraph;
use superb::primitives::io::{offline, Engine};
use superb::primitives::transport::Transport;
let mut graph = AudioGraph::new();
let sine = graph.add_node(Box::new(SineNode::new(440.0, 0.25, 2)));
graph.set_output(sine).unwrap();
let mut engine = Engine::new(graph, Transport::new(SampleRate::HZ_48K, 120.0));
engine.transport_mut().play();
let audio = offline::render(&mut engine, SampleRate::HZ_48K, 256, 4_800).unwrap();
assert_eq!(audio.channels(), 2);
assert_eq!(audio.frames(), 4_800);
assert!((audio.peak() - 0.25).abs() < 1e-3);examples/sine_through_graph (cargo run --example sine_through_graph) builds on
this: a Mixer with one track feeds offline::render, and the result is
written with wav::write. examples/play_sine_device
(cargo run --example play_sine_device --features devices) instead hands the
engine to device::run_engine and plays it on the default output.
File I/O (feature files)
cargo test --features files # decode / resample / bounce tests and crate wiring
cargo run --example decode_bounce # example: bounce → decode → resample → exportfiles is off by default, so cargo test (no extra features) never compiles the codec
stack. examples/decode_bounce enables superb/files, and Cargo feature
unification therefore turns files on for
cargo run --example decode_bounce --features files. Plain tests still need
--features files. Pure Rust: no hardware, ALSA or ffmpeg at test time.
Licence boundary (MPL-2.0)
The decoder is symphonia 0.6, which is MPL-2.0 — a file-level copyleft. Superb keeps it contained:
src/primitives/io/files/decode.rsis the only source file in the crate that imports symphonia. Its header says so, as do the headers offiles/mod.rs,resample.rsandbounce.rs.- Everything
decode.rsexports is expressed in Superb types (PlanarBuffer,SampleRate,SuperbError::Message), so no other file names an MPL type. - The default-feature test
mpl_dependency_is_confined_to_decode_rsscans every.rsfile undersrc/,tests/andexamples/and fails if any other file mentions the crate outside a comment. It runs on everycargo test. - symphonia is built with
default-features = falseand onlywav,flacandpcm, so no patent-encumbered or extra-licence codecs are pulled in. Transitive crates (symphonia-core,-format-riff,-bundle-flac,-codec-pcm,-metadata,-common) are MPL-2.0 too and only reachable fromdecode.rs.
Resampling uses rubato 4 (MIT OR
Apache-2.0; 5.x needs rustc 1.87, above the crate's 1.86 floor) and
its audioadapter buffers (MIT OR Apache-2.0).
Decoding
use superb::primitives::SampleRate;
use superb::primitives::io::files::{decode, decode_at_rate, decode_bytes};
let file = decode("take.flac")?; // container/codec probed from content
println!("{} ch, {} frames @ {}", file.channels(), file.frames(), file.sample_rate);
let at_48k = decode_at_rate("take.flac", SampleRate::HZ_48K)?; // decode + resample if needed
let in_memory = decode_bytes(std::fs::read("take.wav")?, Some("wav"))?;decode(path)opens the file (seekable, so trailing metadata is read),decode_bytes(Vec<u8>, hint)decodes from memory,decode_from(reader)consumes anyRead + Send + Rebuildstream without seeking.- Formats with the default
filesbuild: WAV (PCM 8/16/24/32-bit, float) and FLAC. More containers/codecs are a matter of adding symphonia features inCargo.toml— nothing else changes. - The first audio track is decoded in full into a planar
f32buffer in file channel order; samples are converted from the codec's native format by symphonia (i16→f32is/32768, matchingwav::read). - Robustness rules: bytes that are not a recognised container fail with
SuperbError::Message; a stream truncated after at least one decoded packet returns the frames decoded so far; a corrupt packet is skipped; a channel-count change mid-stream is an error; none of these panic. - Sample rate comes from the codec parameters (fallback: the first decoded
buffer) and must satisfy
SampleRate::new.
Resampling
use superb::primitives::SampleRate;
use superb::primitives::io::files::{resample, resampled_frames};
let converted = resample(&file.buffer, file.sample_rate, SampleRate::CD)?;
assert_eq!(converted.frames(), resampled_frames(file.frames(), file.sample_rate, SampleRate::CD));- Band-limited FFT resampler (
rubato::Fft, fixed 1024-frame input chunks). Superb drives the chunk loop itself: it feeds the whole clip, pads the tail with silence, and trims the resampler's start-up delay so output sample 0 aligns with input sample 0 (residual sub-sample phase < 0.1 rad at 1 kHz in the tests). - Frame count rule:
ceil(frames * to / from), exposed asresampled_framesso callers can pre-compute clip lengths. Equal rates return a copy without touching rubato; zero frames in → zero frames out. - Allocates and is CPU-heavy: call it from a worker thread, never the
audio callback.
AudioFile::resampled_tois the same operation on a decoded file.
Bounce / export
use superb::primitives::io::files::{bounce, bounce_wav, export_wav};
use superb::primitives::io::wav::WavFormat;
// render 10 s at 48 kHz in 256-frame blocks straight to disk; returns the buffer too
let rendered = bounce_wav(&mut engine, SampleRate::HZ_48K, 256, 480_000, "mix.wav", WavFormat::Float32)?;
// or keep it in memory as an AudioFile and write later (possibly resampled)
let take = bounce(&mut engine, SampleRate::HZ_48K, 256, 480_000)?;
export_wav("mix_cd.wav", &take.resampled_to(SampleRate::CD)?, WavFormat::Pcm16)?;bounce is offline::render with the sample rate attached; bounce_wav
adds a wav::write. Both prepare the engine at the given rate/block size;
call transport_mut().play() first if the transport should move.
bounce_wav returns the rendered PlanarBuffer so callers can meter it
without reading the file back. Output is always WAV (PCM16 or float32);
encoding to FLAC or other formats is not provided.
Sample-accurate external MIDI
Engine::render drains the Consumer<TimestampedMessage> attached with
set_midi_input into the block's MidiEventBuffer. Each message's
timestamp_us becomes a block-relative frame through a
superb::primitives::midi::timing::FrameMapper the engine owns:
use superb::primitives::buffers::PlanarBuffer;
use superb::primitives::SampleRate;
use superb::primitives::graph::nodes::MonoSynthNode;
use superb::primitives::graph::AudioGraph;
use superb::primitives::io::{Engine, MidiTiming};
use superb::primitives::midi::{MidiChannel, MidiMessage, TimestampedMessage};
use superb::primitives::transport::Transport;
let mut graph = AudioGraph::new();
let synth = graph.add_node(Box::new(MonoSynthNode::new(1)));
graph.set_output(synth).unwrap();
let mut engine = Engine::new(graph, Transport::new(SampleRate::HZ_48K, 120.0));
engine.prepare(SampleRate::HZ_48K, 480).unwrap(); // 480 frames = 10 ms
assert_eq!(engine.midi_timing(), MidiTiming::Stream);
let (mut tx, rx) = superb::primitives::realtime::spsc::channel::<TimestampedMessage>(16);
engine.set_midi_input(Some(rx));
// Stamp on the engine's stream clock: block 0 spans 0..10_000 µs.
let ch = MidiChannel::new(0).unwrap();
let at = engine.stream_clock_us() + 5_000; // frame 240
tx.push(TimestampedMessage { timestamp_us: at, message: MidiMessage::NoteOn { channel: ch, note: 69, velocity: 127 } }).unwrap();
let mut out = PlanarBuffer::new(1, 480);
engine.render(&mut out).unwrap();
assert!(out.channel(0)[..240].iter().all(|&s| s == 0.0));
assert!(out.channel(0)[240..].iter().any(|&s| s != 0.0));
assert_eq!(engine.stream_clock_us(), 10_000); // next block's startHow the block start is chosen, per render:
- If
set_midi_block_start_us(Some(us))was called since the last render, that value is the block start. It is one-shot: consumed by that render and cleared byprepare.Nonecancels a pending override. - Otherwise the stream clock is used:
frames_rendered * 1_000_000 / sample_rate, rounded up so thatus_to_framesinverts it exactly.frames_renderedis reset byprepareand advanced by everyrenderthat reached the processor (also on processor error, since the stream moved on).stream_clock_us()returns the next block's start so offline producers can stamp messages.
Then FrameMapper::set_block(block_start_us, frames) runs and each message
is placed at mapper.frame_for(timestamp_us) (MidiTiming::Stream) or at
0 (MidiTiming::BlockStart). Timestamps before the
block clamp to frame 0 and timestamps after it clamp to the last frame, so
nothing is ever lost or deferred to a later block. After the drain the buffer
is sort_by_framed, so explicit midi_mut().push(MidiEvent { frame, .. })
still works alongside the queue. Nothing here allocates.
cpal wiring (feature devices). device::build's data callback reads
OutputCallbackInfo::timestamp().callback and .playback (both
StreamInstants), converts them to microseconds and passes them to the
render closure as a BlockStart; OutputStream::open_timed exposes that
(block, start) closure publicly and open wraps it for callers that
don't care. run_engine calls
engine.set_midi_block_start_us(Some(start.callback_us)) before every
engine.render; AudioThread::tick_timed does the same and publishes
start.playback_us as the RollStart when the transport begins rolling.
Callbacks larger than max_block_frames are rendered in chunks and each
chunk's instants are advanced by chunk_offset * 1e6 / hz.
Clock limitation (intentional). cpal's StreamInstant counts from the
stream's own epoch; midir's TimestampedMessage::timestamp_us counts from
midir's connection clock. They are not the same clock, so feeding
superb::primitives::midi::input::MidiInputBridge straight into an engine driven by
run_engine yields clamped placement (frame 0 or last frame), not
sample-accurate hardware MIDI. Sample-accurate placement is exact whenever
producer and engine share a clock — offline/stream_clock_us() producers,
sequencers, tests, or a bridge that re-stamps midir timestamps onto the cpal
clock (measure the offset once and add it). If you want the old
deterministic behaviour for hardware input, set
engine.set_midi_timing(MidiTiming::BlockStart).
Real-time notes
Engine::render is allocation-free: it reuses the processor's own output
buffer and copies into out with PlanarBuffer::copy_block_from, and MIDI
is drained from the SPSC consumer into the pre-allocated MidiEventBuffer
rather than collected into a new container. Engine::prepare (which calls
processor.prepare) and Engine::new are the off-thread setup path and may
allocate. On the devices feature, output and input cpal data callbacks call
superb::primitives::realtime::thread::enter_rt() once on first invocation to mark the audio
thread real-time. Output then calls Engine::render; input de-interleaves
into a scratch PlanarBuffer (and optionally CaptureProducer::push).
Sample format conversion uses cpal's FromSample / Sample::to_sample,
also allocation-free. MIDI from set_midi_input's Consumer is drained at
the start of render and placed inside that block by the engine's
FrameMapper (see "Sample-accurate external MIDI" above); the mapper is a
Copy struct with no heap data. Hardware-dependent tests
(plays_a_sine_on_the_default_device, captures_from_the_default_device)
are #[ignore] since CI has no audio device.
Capture / input streams (feature devices)
cargo test --features devices # unit + ignored hardware smokeMirror of the output API for microphones / line inputs:
use superb::primitives::buffers::PlanarBuffer;
use superb::primitives::io::capture::capture_queue;
use superb::primitives::io::device::{input_devices, run_capture, InputConfig, InputStream};
// List inputs (empty or Err without hardware — never panics).
let _ = input_devices();
// Callback style: planar scratch reused every callback (no alloc).
let config = InputConfig { channels: 2, max_block_frames: 512, ..InputConfig::default() };
// let stream = InputStream::open_timed(&config, |block, capture_us| { /* meter / record */ })?;
// Engine-friendly path: pre-allocated recycle SPSC of planar blocks.
let (mut tx, mut rx) = capture_queue(2, 512, 8)?;
// let stream = InputStream::open_to_queue(&config, tx)?;
// …or shorthand:
// let (stream, mut rx) = run_capture(&config, 8)?;- Device samples arrive interleaved from cpal; the callback de-interleaves
into a pre-allocated
PlanarBuffer(same scratch pattern as output, in reverse) and never allocates beyond whatOutputStreamalready does. open_timedstamps each chunk withInputCallbackInfo::timestamp().capture(ADC instant) in microseconds on cpal's stream clock; callbacks larger thanmax_block_framesare split and the timestamp is advanced per chunk.capture_queue(channels, max_block_frames, capacity)pre-allocatesCaptureBlocks on a free/filled SPSC pair. The producer (push) copies into a free slot; overruns incrementdroppedand discard the device block. The consumerpops filled blocks andrecycles them. Dropping aCaptureBlockon the audio thread never happens under normal protocol.- Hardware smoke test
captures_from_the_default_deviceis#[ignore].
Limitations / next
- Writing is WAV only (PCM16, PCM24 and float32; RF64 past 4 GiB). The
wavmodule's own reader is equally plain; for extensible headers, FLAC and friends usefiles::decode(featurefiles). - Decoding (feature
files) reads WAV, AIFF, FLAC, ALAC, MP3, AAC (MP4/M4A), Ogg Vorbis, CAF and Matroska. Symphonia is built withall-codecsandall-formats; there is no further feature to turn on. Only the first audio track is read; no seeking, streaming or partial reads — the whole file is decoded into memory. No metadata (tags, cue points, loop markers) is surfaced. Withoutfiles,wav::readreads WAV only. - No encoders other than WAV; no FLAC/OGG export.
- Resampling is offline only:
resampleallocates and works on whole clips. A real-time (process_into_buffer-based, pre-allocated) variant for varispeed / device-clock drift is not implemented. - Capture is planar-only at the API boundary — interleaved delivery is not exposed (cpal's interleaved buffer is converted in the callback). Duplex (shared input+output stream) and device hot-plug are not wrapped.
- Hardware MIDI timing across midir + cpal is still block-coarse because the two libraries use different clocks (see above). Placement is exact for producers on the engine's stream clock or the cpal callback clock.
devicerequires thedevicesfeature (and ALSA headers on Linux) and is entirely absent from the default build.OutputStream::pausedepends on backend support in cpal and is not guaranteed to work on every host.
primitives::plugins
Plugin hosting: a format-agnostic instance model, a graph node adapter, a CLAP host, and a VST3 stub.
Architecture
How Superb is put together, and the rules every module follows. Read [TERMINOLOGY.md](TERMINOLOGY.md) first if the DAW words are new to you, and [flows.md](flows.md) for the top tier in detail.