Sonus
Sonus is a C++23 audio playback library built on miniaudio. It provides a small thread-safe engine for sound file playback, PCM sample sequences, and generated metronome clicks, with std::expected-based error reporting through Modulus.
Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter.
Devices And Threading
enumerate_playback_devices() returns device descriptions or a Modulus error if the audio backend cannot initialize or enumerate. A successful empty list means no playback devices were reported. Copy a description's id to audio_config::playback_device_id before creating an engine. IDs are opaque tokens derived from enumeration order and metadata, intended for immediate reuse; re-enumerate after hardware or backend changes. They are not permanent hardware identifiers.
Selection tries the ID first, then an exact playback_device_name fallback. Duplicate names select the first match, so use the ID to distinguish them. Empty selectors preserve the backend's default device. An unresolved explicit selector returns an error. no_device rejects device selectors and enables manual rendering instead.
Period size may be requested in frames or milliseconds (not both), with an optional period count and performance profile. The backend may adjust these requests to hardware capabilities.
Control calls on one engine are serialized and may run concurrently with playback. update_metronome() preserves the running/stopped state at publication. Render callbacks use immutable click buffers and lock-free snapshot reads; reclamation runs on control threads. Idle publications reclaim previous snapshots immediately. Snapshots retired during rendering are collected by subsequent metronome controls, collect_finished(), active_sound_count(), playback, or device stop. Manual renders are serialized. Do not move or destroy an engine while another thread is calling it.
Call collect_finished() periodically to release sounds that reach EOF without further playback or status calls. Sonus compiles miniaudio internally; installed consumers link sonus::sonus and need only Modulus core and its Boost dependency. Miniaudio headers are needed only to build Sonus.
Sample Sequences
sample_sequence owns mono PCM buffers, frame-based sample events, a caller-provided release envelope, total duration, gain, and voice capacity. Each event selects a buffer and an independent start/duration/gain. The attack plays once; the half-open [loop_start, loop_end) region sustains until the event ends, then the stored release envelope is applied. Buffers must match the engine sample rate. Sonus does not interpret musical beats, pitches, repeats, or instrument definitions and does not synthesize sequence tones.
Use prepared_sequence::create(sequence, sample_rate) on a worker/control thread to validate data and preallocate the actual peak voice count. Move the result into engine::play_sequence(prepared) for serialized publication. The convenience play_sequence(sample_sequence) also prepares synchronously on its caller. load_sample(path, sample_rate) decodes bounded mono PCM through miniaudio off the callback; set its loop boundaries before preparation. Prepared sequences are single-use, move-only objects and cannot be mutated after validation.
The returned playback_session is a copyable thread-safe status/cancellation handle. status() distinguishes PLAYING, COMPLETED, STOPPED, and FAILED; natural completion includes the entire declared timeline and all release tails. Replacing a sequence, stop_sequence(), stop_all(), destruction, or session.stop() cancels it without reporting completion. Device stop() suspends rendering; use sequence cancellation when a session must not resume. Session status survives engine-side cleanup. Call collect_finished() periodically to release completed/canceled PCM on a control thread. Keep handles away from final destruction in your own real-time callbacks.
One sequence is active per engine and can mix with file playback/metronome; master volume applies to it. Rendering uses the engine sample clock with no callback allocation, I/O, locking, trigonometry, or buffer destruction. Concurrent cancellation takes effect by the next audio buffer. Overlapping equal pitches have independent voices; capacity is checked including release tails, never handled by stealing notes. Preparation limits are 1,000,000 events, 4,096 samples, 64 MiB decoded PCM, 256 voices maximum (64 default), 24 hours, and a release envelope from one to zero of at most 500 ms. Gains/PCM must be finite and bounded; final output is clamped to unit range. Applications may impose stricter limits.
Build
Code style is managed by Modulus: run ./modulus.py format or ./modulus.py format --check. The generated Neovim integration uses the same pinned container formatter for C++ and Python, including existing format-on-save toggles. Configure first to prepare tooling; customize tooling.editor.clang_format and tooling.format.exclude in modulus.json, not generated editor files. See the shared Modulus README for the compact layout and no-alias policy.
Configure, build, and test the default target:
python3 modulus.py configure
python3 modulus.py build
python3 modulus.py test
Create packages:
python3 modulus.py package --all
License
Sonus is released under the MIT License.
Recording transports combine a streamed backing file with preloaded click WAVs on one rendered-frame clock. prepared_transport::create(config, sample_rate, channels) performs file preparation away from the audio callback; engine::play_transport() publishes it atomically and returns a transport_session. The session exposes frames(), status() and stop(). Replacing a transport cancels the previous session, and engine::stop_all() cancels it too.
transport_config takes an optional backing path, beat1/beat2 paths, ordered click_event frame positions and a duration_frames boundary. With a backing file, its complete decoded length defines completion and must cover that boundary. Click samples are cut off at the boundary, allowing a recording tail without clicks. Without backing, the boundary defines completion. WAV decoding preserves the configured channel count. Stream refill happens on miniaudio workers; the callback mixes into a preallocated buffer, reports underruns as FAILED, and publishes only frames actually rendered. Call collect_finished() on a control thread to retire finished resources.