A narrowly-scoped, fully software-based testbed for real-time audio DSP on an emulated ARM Cortex-M processor. Zephyr RTOS runs two cooperating tasks under QEMU that stream synthetic audio through a ring buffer and a FIR filter, while Python/NumPy independently validates the C output and Zephyr's own APIs report latency, deadline misses, and memory usage.
No microcontroller and no audio hardware are used or required. QEMU emulates the CPU instruction-by-instruction; there is no physical board anywhere in this project. See Emulation, not hardware.
Python (offline, build time)
┌─────────────────────────────────────┐
│ generate_signals.py │
│ - synthesize sine / noise / mixed │
│ - design FIR coefficients │
│ - quantize to Q15 │
│ - emit src/test_vectors.h │
└───────────────────┬───────────────────┘
│ (compiled in)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Zephyr RTOS application (built with CMake, run under QEMU) │
│ │
│ ┌────────────────────┐ ring_buf_put() ┌──────────────────┐│
│ │ audio_input_task │ ───────────────────▶│ audio_rb ││
│ │ (Zephyr thread, │ k_sem_give() │ (Zephyr ring ││
│ │ periodic, 8 ms) │ │ buffer, 4 frames)││
│ └────────────────────┘ └─────────┬────────┘│
│ │ get() │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ dsp_task (Zephyr thread) │ │
│ │ 1. FIR filter (float) │ │
│ │ 2. FIR filter (Q15 fixed) │ │
│ │ 3. Goertzel spectral check │ │
│ │ 4. latency / deadline / │ │
│ │ stack telemetry │ │
│ └──────────────┬───────────────┘ │
│ │ printk() │
└─────────────────────────────────────────────────┼───────────────────┘
▼
QEMU UART console (captured to
qemu_console.log)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Python (offline) │
│ compare_output.py │
│ - re-derives the same signal + filter with NumPy │
│ - parses TEL / OUTF / OUTQ / SUMMARY lines from the log │
│ - asserts float output ≈ NumPy reference (float64) │
│ - asserts Q15 output is bit-exact vs. an integer NumPy reference │
│ and reports quantization SNR │
│ - exit code gates CI │
└─────────────────────────────────────────────────────────────────┘
Two Zephyr threads, one Zephyr ring buffer, one semaphore. audio_input_task
is the producer: it simulates a periodic audio source (e.g. what an I2S/PDM
DMA interrupt would hand off in real hardware) by pushing one frame of the
pre-generated test signal into the ring buffer every FRAME_PERIOD_MS.
dsp_task is the consumer: it blocks on a semaphore, pops a frame, runs it
through two independent FIR implementations (float and Q15 fixed-point),
runs a lightweight spectral check, and prints telemetry + validation data to
the console.
| Parameter | Value | Rationale |
|---|---|---|
| Sample rate | 16,000 Hz | Standard wideband-voice rate. Low enough to process comfortably on an emulated Cortex-M without the run taking forever in QEMU; high enough that a 2 kHz low-pass filter is a meaningful, non-trivial test (it must pass the 440 Hz test tone and attenuate a 3.3 kHz tone in the mixed-frequency signal). |
| Frame size | 128 samples (8.0 ms) | Large enough to amortize per-frame RTOS overhead (semaphore give/take, ring buffer put/get, context switch) so that overhead doesn't dominate the latency measurement. Small enough that a single missed deadline is still a meaningful, real-time-relevant event rather than tens of milliseconds of slack — an 8 ms frame budget is in the same ballpark as real low-latency audio buffer sizes (e.g. 128–256 sample buffers at 16–48 kHz are common in embedded audio). |
| Deadline | 8000 µs | Derived directly from frame size / sample rate (1e6 * 128 / 16000) — the frame must finish processing before the next frame's worth of audio has arrived, or the pipeline is by definition not keeping up in real time. |
| FIR taps | 31 | Odd order → exact linear phase, symmetric coefficients. Long enough to get a filter with a real transition band (not a trivial 3-tap smoother), short enough that both the float and Q15 paths finish comfortably inside the deadline on an emulated M3, keeping the "narrow scope" promise. |
All of these are #defines generated once by python/generate_signals.py
into src/test_vectors.h (see Python reference / validation) —
there is exactly one place these numbers live, so the firmware and the
Python validator cannot silently drift apart.
A 31-tap windowed-sinc low-pass filter (Hamming window, 2000 Hz cutoff,
normalized to unity DC gain), designed in python/rtdsp_common.py with
NumPy only (no SciPy dependency):
h[n] = sinc(2*fc*(n - M/2)) * hamming(n), n = 0..N-1, normalized to sum(h) = 1
It is implemented twice in C, as two independent, parallel DSP paths run on every frame:
src/fir_filter.c— direct-form FIR infloat, with a persistent delay-line (history) that carries state across frames. This is what makes "filter frame-by-frame in the firmware" produce the same result as "filter the whole signal at once in NumPy" — frame-at-a-time streaming with correct history handling is the actual property being validated.src/fir_filter_q15.c— the same direct-form structure in Q1.15 fixed point:int16_tsamples/coefficients, a 64-bit accumulator (no overflow risk across 31 taps of full-scale products), round-to-nearest before the>>15, and saturation toint16_trange.
Both paths run on the same input frame, so every run of the firmware directly demonstrates the float-vs-fixed-point tradeoff (see below).
A small src/goertzel.c adds a cheap "spectral analysis" check: the
Goertzel algorithm computes a single DFT bin's magnitude in O(N) time and
O(1) memory (vs. O(N log N) time / O(N) memory for a full FFT). Each frame
reports the magnitude at 440 Hz (in-band, should survive the filter) and
3300 Hz (out-of-band, should be attenuated) — enough to confirm the filter
is doing what it's supposed to, without pulling in a general-purpose FFT
library that this project doesn't otherwise need.
This project intentionally targets qemu_cortex_m3, a Cortex-M3 with
no hardware FPU. That's not an oversight — it's the whole point:
- The float DSP path runs through libgcc soft-float on this target.
Every
+,*, and especially thesinf/cosf/sqrtfcalls in the Goertzel check compile down to library calls doing float math in software, at real cost in cycles and code size. - The Q15 path uses only
int16_t/int64_tinteger arithmetic, which the Cortex-M3's integer ALU (and 32×32→64-bit multiply) handles natively.
This mirrors the actual engineering tradeoff on real low-cost/low-power audio MCUs that lack an FPU: fixed-point DSP exists precisely because it gets you correctly-rounded, saturating arithmetic without paying for floating point you don't have in hardware. The telemetry each run prints lets you compare the two paths' latency directly rather than taking that claim on faith — see Latency and memory results.
(To see the other side of the tradeoff — a board with a hardware FPU —
rerun with ./scripts/run_qemu.sh mps2/an521/cpu0, a Cortex-M33 target
with FPU support; compare the float path's latency there.)
This is a standard Zephyr application. You need:
- Zephyr's build prerequisites (CMake, Ninja/west, Python 3, device tree compiler, Zephyr SDK with the ARM toolchain and QEMU). Follow Zephyr's official Getting Started Guide if you don't already have a Zephyr development environment — this project deliberately does not duplicate that (large, frequently-updated) guide.
- Once
westand the Zephyr SDK are installed, this repo is a standalone west workspace (T2 topology):
git clone <this-repo-url> rtdsp-lab
cd rtdsp-lab
python3 -m venv .venv && source .venv/bin/activate
pip install -r python/requirements.txt
west init -l .
west update
west zephyr-export
pip install -r ../zephyr/scripts/requirements.txt # Zephyr's own Python deps
python3 python/generate_signals.py # generate src/test_vectors.h- Build and run under QEMU:
west build -p always -b qemu_cortex_m3 . -d build
west build -d build -t runOr use the convenience script, which does all of the above (regenerate → build → run → validate) in one step:
./scripts/run_qemu.sh # default board: qemu_cortex_m3
./scripts/run_qemu.sh mps2/an521/cpu0 # Cortex-M33, has an FPUThe firmware runs to completion (60 frames, ~0.5 s of simulated audio) and
main() returns, which ends the QEMU run — no manual Ctrl-A X needed
when run via scripts/run_qemu.sh (it also has a timeout safety net).
python/rtdsp_common.py is the single source of truth for every pipeline
constant and for signal/filter generation. Two things consume it:
-
python/generate_signals.pyemitssrc/test_vectors.h— the exact input signal and FIR coefficients (float and Q15) the firmware compiles in. -
python/compare_output.pyre-derives the same signal and filter with NumPy, parses the firmware's console log, and checks:- Float path: max absolute error between the firmware's
floatoutput and a float64 NumPy streaming-FIR reference, per checked frame (default threshold2e-4, driven by float32-vs-float64 rounding). - Q15 path: the firmware's
int16_toutput must be bit-exact against an integer NumPy reference that mirrors the C accumulate/ round/saturate logic exactly — plus a reported SNR (dB) of the Q15 output against the float64 reference, which characterizes quantization noise rather than being a hard pass/fail gate on its own. - Telemetry sanity: frame count, deadline miss count, latency stats.
The comparison logic was verified against a synthetic log built from an independent Python re-implementation of the C algorithms before being pointed at real firmware output, specifically so a bug in the validator itself can't silently mask a bug in the firmware (or vice versa).
- Float path: max absolute error between the firmware's
Run it standalone against any captured log:
python3 python/compare_output.py qemu_console.logExit code is non-zero on any validation failure, which is what
.github/workflows/ci.yml uses to gate CI.
Every run prints one TEL line per frame and one SUMMARY line at the end:
TEL,<frame_idx>,<latency_us>,<deadline_miss 0|1>,<stack_free_bytes>,<mag440x1000>,<mag3300x1000>
SUMMARY,total_frames=60,deadline_misses=<N>,avg_latency_us=<N>,max_latency_us=<N>,min_stack_free_bytes=<N>
- Latency = cycles between the input task's
ring_buf_put()timestamp and the DSP task'sring_buf_get()completion, converted to µs viasys_clock_hw_cycles_per_sec()— i.e. true producer→consumer latency, not just DSP-task-internal processing time. - Deadline misses = frames where that latency exceeded
DEADLINE_US(8000 µs, see above). - Stack usage =
k_thread_stack_space_get()sampled on the DSP thread every frame; the summary reports the minimum (worst-case) free stack seen across the whole run. - Memory = static: the two FIR history buffers, per-frame scratch
buffers, and the ring buffer are all fixed-size and file-scope/thread-stack
allocated (no heap use in the hot path) — check
west build -d build -t ram_report/rom_reportfor the linked image's exact RAM/ROM footprint per board.
This README ships without a canned numbers table on purpose: paste your
own qemu_console.log SUMMARY line and ram_report/rom_report output
here after running ./scripts/run_qemu.sh, rather than trusting numbers
that weren't actually measured on your toolchain/QEMU version. As a
starting point for what to expect: on an emulated Cortex-M3 with no FPU,
expect the float path's per-frame processing time to measurably exceed the
Q15 path's, with both comfortably inside the 8000 µs deadline for a 31-tap
filter at 128 samples/frame — if you see otherwise (e.g. frequent deadline
misses), that's a real signal something in the build config or filter size
needs adjusting, not a formatting nit.
# Regenerate test vectors from the Python source of truth
python3 python/generate_signals.py
# Build + run under QEMU + validate, one command
./scripts/run_qemu.sh
# Equivalent, manual steps
west build -p always -b qemu_cortex_m3 . -d build
west build -d build -t run | tee qemu_console.log
python3 python/compare_output.py qemu_console.log
# Validate an already-captured log without rebuilding
python3 python/compare_output.py qemu_console.log
# Memory footprint of the built image
west build -d build -t ram_report
west build -d build -t rom_reportCI (.github/workflows/ci.yml) runs the build → QEMU → validate sequence
on every push/PR using the zephyrprojectrtos/ci container image (which
bundles the Zephyr SDK and QEMU), and uploads the console log as a build
artifact either way.
The processor in this project is emulated by QEMU (qemu_cortex_m3 /
optionally mps2/an521/cpu0), not a physical microcontroller. There is
no real audio input, no DAC/ADC, no I2S/PDM peripheral, and no physical
board. "Real-time" here means the Zephyr scheduler's timing guarantees and
the measured latency/deadline behavior as QEMU executes the compiled
ARM instructions — a faithful proxy for firmware timing behavior, but not
a substitute for measurements on real silicon, where interrupt latency,
cache effects, memory-bus contention, and clock accuracy will all differ.