|
Sound Byte Libs 0.5.1-121-g3358a44
C++ firmware library for audio applications on 32-bit ARM Cortex-M processors
|
Template-based C++ firmware for ARM Cortex-M processors
We build with a painter's workshop as our guiding abstraction. In this concept, the hardware is the canvas, Sound Byte Libs is the toolkit (paint, brushes, palette knives, etc), and the application is the artwork.
Sound Byte Libs organizes code into three domains — Audio, Control, and Hardware — plus shared infrastructure. Audio and Hardware are independent: audio processing doesn't depend on hardware I/O, and hardware components don't depend on DSP. Control is the bridge between them — it takes Hardware signals and shapes them for Audio ports.
Data flow: Hardware produces normalized signals (pot readings, CV, MIDI CC) → Control merges and routes them inside a staged tick (event queues, CC map, merge primitives, publish to the bridge) → Audio consumes them at ports (_su inputs with widget-owned conditioning).
Audio and Hardware are deep stacks with layered abstractions. Control is intentionally thin — lightweight primitives for the routing and transformation work that doesn't belong in either stack. All three share Common infrastructure (logging, diagnostics, generated config).
The DSP processing chain — five layers from stateless math to fully realized audio modules. The layers are organized by opacity: the lower layers (math, primitives, compositions) are transparent — you reason about them by understanding their internals. The upper layers (widgets, modules) are opaque — you reason about them by their port interface. The black box boundary is described below.
The black box boundary falls between compositions and widgets. Below it, you see the parts and trace the signal. Above it, you trust the interface. This distinction shapes the signal transport convention: _su normalization is a port boundary concern. Signals are normalized on the transport layer (the wires between boxes). Once inside a widget or module, the component uses whatever internal representation suits the algorithm — Hz, coefficients, octaves. See the Signal Transport Specification (docs/conventions/signal-transport.md in the workspace).
Components expose two kinds of inputs: ports and parameters. Ports accept normalized signals (_su suffix) — the component owns the mapping to engineering units internally. Parameters configure structural behavior (mode, sample rate, settling time) and use natural units directly. Engineering-unit setters for ports are private, accessible only via TestAccess friend structs for testing.
sbl::dsp:: is the container namespace for the audio stack. Types (e.g., FilterMode) may live directly in sbl::dsp::.
Location: src/sbl/dsp/math/
Domain-neutral mathematical functions, constants, and lookup table access. No _su (no parameters). Pure computation used at every other layer — a toolbox everyone reaches into.
math/fast_math.hpp - Fast approximations (fast_exp2f, fast_tan_pif, fast_sinf)math/interpolate.hpp - Crossfade, smooth_step, equal_powermath/pitch.hpp - Pitch utilities (semitones_to_ratio, note_to_frequency)math/param.hpp - Parameter normalization (map_linear, map_exponential, map_scurve)math/stereo.hpp - Constant-power panning and stereo mixmath/lut.hpp - Lookup table functions (linear, cubic, crossfade)math/exp_mod.hpp - Exponential modulation converter (base × exp2(mod × depth))Location: src/sbl/dsp/types/
Types, constants, and DMA boundary conversion infrastructure.
types/types.hpp - DSP types (FilterMode, etc.)types/fixed.hpp - SAMPLE_RATE / SAMPLE_RATE_F globals, Sample type aliastypes/convert.hpp - Float↔int32 conversion at DMA boundaries (interleave/deinterleave, NaN guard)types/frame.hpp - Frame<N>: Stereo buffer management (begin/end/scale/apply/mix/tap/clear)Location: src/sbl/dsp/diagram/
A model's wiring as data, for pictures (AP-037). Physical-modeling components carry a describe(Graph&) that adds the nodes a paper's block diagram would draw — delay lines, filters, reflections, junctions, sources, loads, taps — and the edges between them; a widget describes the connections its lambdas made. graph.hpp is fixed-capacity POD with no allocation; mermaid.hpp (host-only) renders a Graph as a Mermaid flowchart at full depth or with nested components folded. describe() is never called from audio code, so the target binary carries none of it. tools/diagram/ builds sbl-diagram, which instantiates a model and prints its Mermaid.
Location: src/sbl/dsp/tables/
LUTE-generated constant data — float32 waveforms, pitch ratio tables, and bow friction families (bow_stribeck, bow_stk: one load-line-solved curve per force slice plus its stick limits). The bows are generated from tables/lute.json.
Location: src/sbl/dsp/primitives/
Discrete components — the resistors, capacitors, and op-amps of the audio stack. Stateful, single-concern, fully transparent. Each does one thing: accumulates phase, stores samples across time, low-pass filters a signal, generates random values. Each primitive manipulates one property of sound without conflating it with others. You reason about primitives by understanding their internals.
Signal storage / generation:
primitives/phase_accumulator.hpp - PhaseAccumulator (32-bit natural wrapping)primitives/wavetable_reader.hpp - WavetableReader (linear/cubic interpolation)primitives/delay_line.hpp - DelayLine (float, fractional interpolated reads)primitives/transfer_function.hpp - TransferFunction (table-driven LUT)primitives/rng.hpp - Rng (xorshift32 PRNG)primitives/friction_curve.hpp - FrictionCurve: a bow — friction solved against the string's load line offline by lute, read here as one lookup; stick/slip hysteresis in one bool of stateprimitives/allpass_filter.hpp - AllpassFilter (Schroeder phase dispersion)primitives/thiran_allpass.hpp - ThiranAllpass<N>: maximally-flat group-delay allpass for static fractional delay (waveguide tuning); Hermite interpolation remains the tool for a delay that moves per sampleprimitives/mass_spring_damper.hpp - MassSpringDamper: one lumped resonator, trapezoidal (passive); exposes instantaneous admittance and history so a junction can solve against it, or runs as a plain force-in/velocity-out resonator. Two parametrisations: physical (frequency, Q, mass) and musical (set_resonance: frequency, Q, match — the damper relative to the string's impedance, so the reflection at resonance is (1 − match)/(1 + match))Signal processors:
primitives/one_pole.hpp - OnePole IIR filter (LP/HP, set_cutoff_su())primitives/soft_limiter.hpp - SoftLimiter (rational sigmoid, no params)primitives/folder.hpp - Folder (triangle-wave reflection, no params)primitives/asymmetric_shaper.hpp - AsymmetricShaper (configurable even-harmonic generation)primitives/slew_limiter.hpp - SlewLimiter (independent rise/fall rates)primitives/dc_blocker.hpp - DcBlocker (first-order HPF)primitives/hysteresis_quantizer.hpp - HysteresisQuantizer (stepped quantizer with dead zones)Smoothing / parameter infrastructure:
primitives/parameter_smoother.hpp - ParameterSmoother (split from OnePole, time constant API)primitives/critical_damp.hpp - CriticalDamp (cascaded one-pole, no overshoot)primitives/parameter_interpolator.hpp - ParameterInterpolator (RAII per-sample linear ramp)Curve engines:
primitives/warp.hpp - FloatRationalWarp (analytical curve engine for SegmentGenerator)Location: src/sbl/dsp/comp/
Common subcircuits — the RC filters and voltage dividers of the audio stack. Composed from primitives in patterns you'd repeat every time. Transparent: you can see the parts and trace the signal through them. These exist to be composed further into widgets or used directly in application code. A periodic function generator (phase accumulator + table reader) is a composition — it becomes an LFO or VCO only when a widget gives it semantic meaning.
comp/analog_drift.hpp - AnalogDrift (Rng + OnePole + mean reversion)comp/wavefold_aa.hpp - WavefoldAA (ADAA anti-aliased folder)comp/warm_saturator.hpp - WarmSaturator (gain + AsymmetricShaper + SoftLimiter)comp/slew.hpp - Slew (rate-limited signal tracking)comp/waveguide_loop.hpp - WaveguideLoop: a string as two waveguide segments with the excitation point as their junction; inverting reflections, a loss filter and Thiran tuning in the loop. The caller is handed the velocity arriving at the junction and returns what to inject there; a second callable does the same at the bridge end (rigid wall by default)comp/bridge_junction.hpp - BridgeJunction<Load>: the scattering junction between a waveguide and a lumped load — one algebraic solve per sample, no delay-free loop; taps for the body's velocity and the force into itcomp/mode_bank.hpp - ModeBank<N>: a body as several resonances at one point — MassSpringDampers in parallel with mode-shape weights, exposing the same load interface (admittance, history, commit) so a BridgeJunction scatters against it unchanged; each mode is where / how wide / how much it takes, with a coupling weight at the bridge and an independent radiation weight for what is heard (radiated())comp/sample_and_hold.hpp - SampleAndHold (sample on trigger, hold between)comp/fader.hpp - Fader (taper curve + ParameterSmoother gain control)comp/pan.hpp - Pan (constant-power stereo placement + ParameterSmoother)comp/lfo.hpp - Lfo (PhaseAccumulator + WavetableReader with depth scaling)comp/envelope_follower.hpp - EnvelopeFollower (rectifier + asymmetric OnePole amplitude extraction)comp/bit_crusher.hpp - BitCrusher (BitReducer + depth mapping)Location: src/sbl/dsp/widgets/
ICs — the first black box layer. You place them on the board, connect to their pins, and reason about their function, not their internals. Widgets standardize at their port boundary (_su inputs) and own their internal conditioning (smoothing, interpolation, clamping). Internal representation is private — the widget converts from signal units at the port and works in whatever domain suits the algorithm. Standalone-capable, but also composable into modules.
widgets/polyblep_osc.hpp - PolyBlepOsc: Band-limited saw/square/triangle/pulse (PolyBLEP corrections)widgets/svf.hpp - SVF: ZDF state-variable filter (Simper), unconditionally stable, LP/HP/BP/Notchwidgets/shaper.hpp - Shaper: Bias → ADAA fold → warm_saturate → oversampling → compensation → dry/wet mixwidgets/segment.hpp - SegmentGenerator: Multi-segment envelope engine with curve warpingwidgets/midi_converter.hpp - MidiConverter: MIDI events in, normalized signals out (pitch, gate, velocity, CC)widgets/channel.hpp - Channel: Fader + Pan + Sends + mute/solo, front/back model for mixer integrationwidgets/bowed_string.hpp - BowedString: WaveguideLoop driven continuously by FrictionCurve — bow velocity, pressure, position, damping and a crossfade across a set of bows; no envelope, articulation is the bow moving. Optionally a body at the bridge (BridgeJunction + MassSpringDamper) with a selectable pickupLocation: src/sbl/dsp/modules/
Finished PCB assemblies — the most opinionated black boxes. Fully realized, standalone musical tools with complete I/O contracts. May embed widgets internally. _su ports with full conditioning (smoothing, interpolation, slew). A curated set that together can build a full instrument. You bolt them in and play.
modules/morph_osc.hpp - MorphOsc: PolyBLEP saw/pulse/tri morph with AnalogDriftmodules/ladder.hpp - Ladder: Huovilainen 4-pole Moog-style, tanh saturation, self-oscillationmodules/tape_delay.hpp - TapeDelay: Opinionated tape echo (motor slew, wow/flutter, tape character)modules/plate_reverb.hpp - PlateReverb: Dattorro-inspired plate with modulated tankmodules/mixer.hpp - Mixer: N audio channels + M return channels with effect callbacks, send/return routing, solo-in-place, master busThe bridge between Hardware and Audio — responsible for signal routing, merging, and execution orchestration. The control stack decides what connects to what and when things run. Control Stack v2 is decided in ADR-011 and designed in FDP-073 (scheduler and event transport), FDP-074 (merge primitives and bridge discipline) and FDP-075 (CcMapper), all in the workspace docs/planning/. Declarations — the CC map and the merge policy — are constexpr tables in app code; Davis Jr.'s params.hpp is the reference.
The principle: if it routes, transforms, or orchestrates without processing audio, it belongs in control. If it processes audio, it belongs in the audio stack.
Location: src/sbl/control/
control/cc_mapper.hpp - CcMapper<Target>: routes MIDI CCs through a constexpr table of {channel, cc, target, curve, flags} rows; curves are cam tables or linear; Paired14 reassembles 14-bit MSB/LSB pairscontrol/cam.hpp - Cam evaluation: cam_evaluate(), cam_evaluate_block() — rate-agnostic LUT transfer functions; CcMapper's curve backendcontrol/calibration.hpp - LinearFit, fit_points(), VOctStandard, PointCalibration<N, Standard>: capture points against a standard, fit, apply; storage stays app-sidecontrol/merge.hpp - Merge primitives for a parameter driven by several sources: Priority<N> (highest-priority active source wins), Latest<N> (last source to move wins), Pickup (a takeover source must reach the held value before it captures the parameter)control/scheduler.hpp - Scheduler<Slots>: the control context. One base tick, divider slots, and a fixed stage order per tick (Drain, Update, Merge, Publish). run(sample_clock) takes the hal::audio::SampleClock reading; subscribers receive the samples elapsed since their slot last ran.control/event_queue.hpp - EventQueue<T, N>: SPSC queue carrying events from another execution context into the control tick. A full queue rejects the push and counts the drop.Future candidates: preset management.
Hardware interface chain — from MCU registers to application-level controls. Consolidated under src/sbl/hw/.
Location: src/sbl/hw/hal/, drivers in sbl-hardware
Hardware abstraction templates and convenience functions. HAL templates resolve to platform-specific driver implementations at build time.
hw/hal/gpio/ - GPIO types (PinMode)hw/hal/adc/ - ADC convenience (read, start_scan, stop_scan)hw/hal/audio/ - Audio output convenience (start, SFINAE validation)hw/hal/cv/ - CV input (read, read_dma) and audio-rate readshw/hal/button/ - Button HAL convenience (sbl::button::)hw/hal/encoder/ - Encoder HAL convenience (sbl::encoder::)hw/hal/led/ - RGB LED HAL convenience (sbl::led::)hw/hal/pot/ - Potentiometer HAL convenience (sbl::pot::)hw/hal/uart/ - UART utilitieshw/hal/timing/ - Timing utilitieshw/hal/memory/ - Memory barriers, alignmenthw/hal/interrupts/ - Interrupt handlingLocation: src/sbl/hw/components/
Hardware I/O components with signal conditioning:
hw/components/cv/ - CvInput (EWMA smoothing, scaling), GateInput (Schmitt trigger)hw/components/input/ - Button (debounce, edge detection), Encoder (quadrature decode), Pot (EWMA + deadband + pickup mode)hw/components/display/ - RgbLed (Color enum, uint8_t duty)Location: src/sbl/hw/util/
Hardware-oriented data structures:
hw/util/ring_buffer.hpp - Ring buffer (ISR-safe producer/consumer)hw/util/ewma.hpp - Exponential weighted moving averageLocation: src/sbl/hw/validation/
Compile-time SFINAE validation and error messaging for platform contract enforcement.
Location: src/sbl/hw/fs/
FatFs integration for SD card and POSIX-backed file I/O.
Location: src/sbl/protocol/midi/, src/sbl/usb/
Communication protocol handlers:
midi/ - MIDI parser (running status, SysEx, channel filtering), MIDI input pollingmidi/smf_reader.hpp - SmfReader/SmfTrack: Standard MIDI File (formats 0/1) read in place, no allocationmidi/sequencer.hpp - Sequencer: plays an SMF into N outputs, told elapsed samples; Start/Stop/Continue, loopusb/ - USB CDC (virtual serial port), USB MIDI (composite descriptor, bidirectional)Shared infrastructure under src/sbl/common/:
src/sbl/common/log/): Lightweight, zero-overhead logging with compile-time level filteringsrc/sbl/common/diagnostics/): Profiling (DWT cycle counter, AudioBudget tracker, compile-time gated behind SBL_PROFILING_ENABLED) and debug capture (AudioCapture ring buffer)src/sbl/common/timing/): Non-blocking delay utilitiessrc/sbl/common/assert.hpp, src/sbl/common/fault.hpp): SBL_ASSERT()/SBL_PANIC() with HardFault register dump over polling UARTsrc/sbl/defaults.hpp): Sensible buffer size constantssbl::hw::): Build-time generated hardware configuration from slothWhen you build, templates resolve to concrete platform types. A GPIO template becomes STM32 register access or whatever your DSP-capable ARM Cortex-M platform's driver provides. SBL requires a hardware FPU (see ADR-008); STM32H7 is the platform in depth today, RP2350 is planned but shelved.
Minimal runtime dispatch, optimized for ARM Cortex-M with direct hardware access wrapped in clean APIs.
The core library contains zero platform-specific code. No #ifdef blocks, no hardcoded platform names, no conditional compilation.
MCU driver implementations are maintained separately in the sbl-hardware repository. The build system discovers them via sloth resolution from sbl.json.
This approach provides architectural integrity. Every MCU is equal - there are no defaults or preferred platforms baked into the core library.
Applications define hardware through a target file (targets/<name>.sbl.json, one per hardware target; sbl.json for a single-target project), which names a target device. The sloth tool resolves the device's manifest chain (device → main module → MCU, plus attached modules) and generates type-safe headers in sbl/hw/config/ (GPIO handles, ADC handles, UART handles, Board abstraction, MCU metadata) with compile-time constants.
Two config namespaces (FDP-068):
sbl::hw::system::) — hardware constraints like sample_rate, block_size, sysclk_mhz. Merged from manifest chain + target file config section.sbl::app::) — typed per-target application constants like master_gain, bus_gain. Defaults declared in app.sbl.json (with explicit types), per-target overrides in the target files (targets/<name>.sbl.json). sloth validates types and merges defaults ← overrides. No #ifdef — different targets compile with different inline constexpr values.**float [-1.0, 1.0] is the canonical audio type.** All widgets, atoms, lookup tables, and LUTE-generated data use single-precision float. This is the natural format for Cortex-M7 (hardware FPU) and eliminates redundant type conversions between processing stages.
Sample rate is a global constant: sbl::dsp::types::SAMPLE_RATE (uint32_t) and SAMPLE_RATE_F (float), defaulting to 48000. Components read it directly — no per-component set_sample_rate() calls. The master sample rate is a hardware constraint set by the SAI/PLL configuration. Components that operate at sub-rates (CV at 1kHz, control at 200Hz) derive them internally from the master rate. Tests set the global before batch runs.
The only integer audio exists at DMA boundaries, where dsp/types/convert.hpp provides:
interleave_from_float(L, R, tx, frames) — NaN guard, clamp ±1.0, scale to 24-bit int32, interleave stereodeinterleave_to_float(rx, L, R, frames) — deinterleave stereo int32 to floatThe DMA boundary is the audio safety net. interleave_from_float() implements defense-in-depth: NaN values (from uninitialized buffers, division by zero, or corrupted filter state) are replaced with silence before the clamp, preventing undefined behavior from static_cast<int32_t>(NaN * SCALE). A profiling-gated counter tracks NaN occurrences for diagnostics.
All internal processing between these boundaries is float.
All signals share one transport: normalized floats. There is no "audio type" vs "control type" — a 440 Hz oscillator and a 0.1 Hz LFO are the same signal at different rates. A component's port doesn't know whether its input comes from a pot, MIDI CC, LFO, or literal constant. It receives a normalized signal, maps internally to engineering units, and acts on it. See the Signal Transport Specification (docs/conventions/signal-transport.md in the workspace) for the full standard.
Ports accept signals via _su methods. The component owns mapping and conditioning (smoothing, interpolation, rate limiting). Parameters configure structural behavior (mode, sample rate) and use natural units.
Modulation is composition. Vibrato is an LFO signal routed to the oscillator's pitch port, not a built-in oscillator feature. Modulation depth is an attenuverter scaling a signal before it reaches a port. The application routes signals between ports — converting su to engineering units and back is a code smell (the component should own that mapping).
For frequency-domain ports (filter cutoff, oscillator pitch), the component uses exponential mapping internally (exp_mod()). For amplitude-domain ports (volume, mix, feedback), the component uses linear mapping. The caller doesn't need to know which — it just sends a normalized signal to the port.
Static allocation only.
MCU drivers are maintained in sbl-hardware and implement the HAL interfaces. Exclusively supports 32-bit ARM Cortex-M processors.
Hardware is described using three JSON manifest types (see ADR-010, FDP-065):
system section (MCU reference + boot config like crystal frequency), claims board-level peripherals (codec, UART), and exposes remaining pins. An attached module has no system section — it claims pins from a parent module and declares components (knobs, jacks, buttons).The resolver reads the device manifest, loads modules, and resolves bottom-up: MCU pins → main module claims and exposes → attached module claims. Conflict detection validates that all claims are satisfiable. attaches_to lives in the device manifest, not in module manifests — this enables sharing module definitions across real hardware and virtual workbench targets.
See the Hardware Schema Documentation for schema definitions and diagrams.
Sound Byte Libs enforces platform implementations through compile-time SFINAE validation. Every platform must satisfy interface contracts with clear error messages guiding implementation.
Typical audio application setup:
Environment variables point to library locations:
SBL_PATH - Path to sound-byte-libsSBL_HW_PATH - Path to sbl-hardware