Sound Byte Libs 0.5.1-121-g3358a44
C++ firmware library for audio applications on 32-bit ARM Cortex-M processors
Loading...
Searching...
No Matches
Sound Byte Libs Architecture

Template-based C++ firmware for ARM Cortex-M processors

Application structure

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.

Architecture

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.

┌──────────────────────────────────────────┐
│ Application │
└──┬──────────────┬──────────────┬──────────┘
┌───────▼───────┐ ┌───▼───────┐ ┌──▼──────────┐
│ Audio Stack │ │ Control │ │ HW Stack │
│ │ │ │ │ │
│ Modules │ │ Scheduler│ │ Components │
│ ↑ │ │ Merge │ │ ↑ │
│ Widgets │ │ CcMapper │ │ HAL + Proto │
│ ↑ │ │ │ │ ↑ │
│ Compositions │ │ │ │ Drivers │
│ ↑ │ │ │ │ │
│ Primitives │ │ │ │ │
│ ↑ │ │ │ │ │
│ Math │ │ │ │ │
└───────────────┘ └───────────┘ └──────────────┘

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).

Audio Stack

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::.

Math (<tt>sbl::dsp::math::</tt>)

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.

Types (<tt>sbl::dsp::types::</tt>)

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 alias
  • types/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)

Diagram (<tt>sbl::dsp::diagram::</tt>)

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.

Tables (<tt>sbl::dsp::lut::</tt>)

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.

Primitives (<tt>sbl::dsp::primitives::</tt>)

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 state
  • primitives/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 sample
  • primitives/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:

Smoothing / parameter infrastructure:

Curve engines:

Compositions (<tt>sbl::dsp::comp::</tt>)

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 it
  • comp/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)

Widgets (<tt>sbl::dsp::widgets::</tt>)

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/Notch
  • widgets/shaper.hpp - Shaper: Bias → ADAA fold → warm_saturate → oversampling → compensation → dry/wet mix
  • widgets/segment.hpp - SegmentGenerator: Multi-segment envelope engine with curve warping
  • widgets/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 integration
  • widgets/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 pickup

Modules (<tt>sbl::dsp::modules::</tt>)

Location: 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.

Control Stack

The 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/

Signal Transformation

  • 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 pairs
  • control/cam.hpp - Cam evaluation: cam_evaluate(), cam_evaluate_block() — rate-agnostic LUT transfer functions; CcMapper's curve backend
  • control/calibration.hpp - LinearFit, fit_points(), VOctStandard, PointCalibration<N, Standard>: capture points against a standard, fit, apply; storage stays app-side

Source Merging

  • control/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)

Execution

  • 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 Stack

Hardware interface chain — from MCU registers to application-level controls. Consolidated under src/sbl/hw/.

HAL (<tt>sbl::hal::</tt>, <tt>sbl::driver::</tt>)

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 reads
  • hw/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 utilities
  • hw/hal/timing/ - Timing utilities
  • hw/hal/memory/ - Memory barriers, alignment
  • hw/hal/interrupts/ - Interrupt handling

Components (<tt>sbl::components::</tt>)

Location: 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)

Utilities (<tt>sbl/hw/util/</tt>)

Location: src/sbl/hw/util/

Hardware-oriented data structures:

Validation (<tt>sbl/hw/validation/</tt>)

Location: src/sbl/hw/validation/

Compile-time SFINAE validation and error messaging for platform contract enforcement.

Filesystem (<tt>sbl/hw/fs/</tt>)

Location: src/sbl/hw/fs/

FatFs integration for SD card and POSIX-backed file I/O.

Protocols (<tt>sbl::midi::</tt>, <tt>sbl::usb::</tt>)

Location: src/sbl/protocol/midi/, src/sbl/usb/

Communication protocol handlers:

  • midi/ - MIDI parser (running status, SysEx, channel filtering), MIDI input polling
  • midi/smf_reader.hpp - SmfReader/SmfTrack: Standard MIDI File (formats 0/1) read in place, no allocation
  • midi/sequencer.hpp - Sequencer: plays an SMF into N outputs, told elapsed samples; Start/Stop/Continue, loop
  • usb/ - USB CDC (virtual serial port), USB MIDI (composite descriptor, bidirectional)

Common (Cross-cutting)

Shared infrastructure under src/sbl/common/:

  • Logging (src/sbl/common/log/): Lightweight, zero-overhead logging with compile-time level filtering
  • Diagnostics (src/sbl/common/diagnostics/): Profiling (DWT cycle counter, AudioBudget tracker, compile-time gated behind SBL_PROFILING_ENABLED) and debug capture (AudioCapture ring buffer)
  • Timing (src/sbl/common/timing/): Non-blocking delay utilities
  • Safety (src/sbl/common/assert.hpp, src/sbl/common/fault.hpp): SBL_ASSERT()/SBL_PANIC() with HardFault register dump over polling UART
  • Defaults (src/sbl/defaults.hpp): Sensible buffer size constants
  • Generated config (sbl::hw::): Build-time generated hardware configuration from sloth

Template-based HAL

When 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.

Platform Neutrality

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.

Configuration System

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):

  • System config (sbl::hw::system::) — hardware constraints like sample_rate, block_size, sysclk_mhz. Merged from manifest chain + target file config section.
  • App config (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.

Audio Data Format

**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 stereo
  • deinterleave_to_float(rx, L, R, frames) — deinterleave stereo int32 to float

The 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.

Signal Transport

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.

Memory Management

Static allocation only.

Platform Support

MCU drivers are maintained in sbl-hardware and implement the HAL interfaces. Exclusively supports 32-bit ARM Cortex-M processors.

Hardware Manifest System

Hardware is described using three JSON manifest types (see ADR-010, FDP-065):

  • MCU — Fixed silicon. Pin definitions and capabilities.
  • Modules — Logical boards. A main module has a 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).
  • Devices — Composition authority. A device manifest names a main module plus zero or more attached modules and specifies the attachment topology. App-agnostic — multiple apps can target the same device.

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.

Platform Contract Validation

Sound Byte Libs enforces platform implementations through compile-time SFINAE validation. Every platform must satisfy interface contracts with clear error messages guiding implementation.

Project Structure

Typical audio application setup:

your-app-name/
├── hardware/ # Schematics, PCB
└── firmware/
├── main.cpp # Application entry point
├── sbl.json # Hardware target configuration
└── CMakeLists.txt # Build configuration

Environment variables point to library locations:

  • SBL_PATH - Path to sound-byte-libs
  • SBL_HW_PATH - Path to sbl-hardware

Related Documentation