Skip to content

Audio I/O and latency

crates/engine/src/audio_io.rs, on cpal 0.18.

  • On Linux cpal is built with its PipeWire, PulseAudio and JACK hosts and realtime-dbus. The preference order is PipeWire, PulseAudio, JACK, ALSA; JACK is skipped unless a server is running. Windows and macOS use cpal’s default host (WASAPI, Core Audio); ASIO is not enabled.
  • list_devices hides devices that only confuse: on ALSA only hw: devices and the pipewire PCM are listed; PipeWire and PulseAudio hide monitor sources. Device ids are cpal’s host:id strings; find_device accepts any id the host knows (headless tests use alsa:null) and falls back to the default device.
  • AudioConfig (saved in settings.json › audio): host, input, output, sample rate (48000), buffer size (256), input_enabled, auto_monitor, recording_delay_ms. same_devices decides whether a change reopens the streams.
  • choose_config needs the requested rate to be supported (there is no fallback) and a sample format of f32, i32 or i16. The buffer size is fixed when the device allows it.

audio_io::start spawns an audio-io thread that builds the streams, reports an AudioStatus back and then owns them until the AudioIo handle is dropped.

  • The output callback is the clock. It records the output latency (playback timestamp minus callback timestamp), pulls the input that has arrived, calls Engine::process under try_lock (silence if a render holds the engine) and inside catch_unwind (silence and an engine_errors count if it panics), clamps to ±4 and converts to the device format.
  • Input arrives through an rtrb ring of one second. The input callback records the input latency (callback minus capture timestamp) and writes what fits.
  • Drift control: when the ring holds more than three output blocks, the excess is dropped; an underflow is padded with zeros. There is no resampling between the two devices.
  • ring_frames (how much input is waiting) is published for latency compensation.
  • Errors are only logged; after an interface is unplugged or a driver error, use Settings › Audio › Reset Audio Engine (restart_audio), which reopens the devices and rebuilds the graph when the rate changed.
  • Real-time priority comes from cpal (realtime-dbus, through rtkit) for its ALSA and PipeWire callback threads; JACK threads are real-time through the JACK server.

Session::record_latency_frames = (input latency + output latency) + ring_frames + Recording Delay + the PDC total. finish_recording moves each audio take earlier by that amount (trimming anything before zero into the region offset). Recorded MIDI is moved by the output latency plus the PDC total only, since MIDI travels one way. Cycle and Autopunch shaping use the value in the RecPlan, computed when recording starts.

Live MIDI is delivered at offset 0 of the next callback, and recorded MIDI is stamped with the block’s start position, so both have up to one buffer of timing granularity.

Recorder::begin (recorder.rs) creates the WAV file (24-bit integer or 32-bit float) and a ring of four seconds, and returns the producer, which the session hands to the engine with Cmd::RecordSink. The audio thread pushes the armed track’s input samples inside the capture window; a full ring counts an overrun (EngineStatus::overruns). The recorder thread drains every ring every 10 ms into the file, an in-memory copy (so the take plays straight away) and peak previews for the UI (Tick::previews). finish_all finalizes the files.

Where a finished take goes over existing regions follows Settings › Recording › Overlapping Recordings, read through RecordingPrefs::audio_mode / midi_mode: the control bar’s Replace button (RecordingPrefs::replace) turns both into Replace. Audio is placed by place_take (Replace trims the regions underneath). MIDI (place_recording) makes a new region, or merges into the newest MIDI region it overlaps (merge_midi_take, notes and controllers), or with Replace first clears the recorded range, MidiWindow::lo..hi (punch-in or record start to the stop, or the cycle window), from every MIDI region on the track (replace_midi_take, with midi::erase_range restoring changed controllers after the range) and then merges.

Step Input hears hardware MIDI through the ticks: Tick::midi_in carries the messages the inputs’ activity log got since the last tick.

When no output device is running, Session::send processes the command queue inline, so the project stays consistent. The ALSA null device has no clock and runs as fast as the CPU allows, which the headless examples rely on.