Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RTDSP Lab | Emulated Cortex-M Audio Processing Testbed

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.


Architecture

 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.


Why this frame size and sample rate

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.


FIR filter

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 in float, 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_t samples/coefficients, a 64-bit accumulator (no overflow risk across 31 taps of full-scale products), round-to-nearest before the >>15, and saturation to int16_t range.

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.

Floating-point vs. Q15 fixed-point

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 the sinf/cosf/sqrtf calls 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_t integer 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.)


QEMU / Zephyr setup

This is a standard Zephyr application. You need:

  1. 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.
  2. Once west and 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
  1. Build and run under QEMU:
west build -p always -b qemu_cortex_m3 . -d build
west build -d build -t run

Or 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 FPU

The 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 reference and validation

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.py emits src/test_vectors.h — the exact input signal and FIR coefficients (float and Q15) the firmware compiles in.

  • python/compare_output.py re-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 float output and a float64 NumPy streaming-FIR reference, per checked frame (default threshold 2e-4, driven by float32-vs-float64 rounding).
    • Q15 path: the firmware's int16_t output 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).

Run it standalone against any captured log:

python3 python/compare_output.py qemu_console.log

Exit code is non-zero on any validation failure, which is what .github/workflows/ci.yml uses to gate CI.


Latency and memory results

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's ring_buf_get() completion, converted to µs via sys_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_report for 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.


Test commands

# 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_report

CI (.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.


Emulation, not hardware

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.

About

Real-time embedded DSP experiments in C using Zephyr and QEMU on a simulated Cortex-M3 target.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages