Skip to content

Commit c5fb0f3

Browse files
authored
Add on-target (QEMU) Xtensa conformance test suite (#55)
Adds `tests/qemu/`, which runs the RFC 8251 decoder-conformance vectors on an emulated ESP32-S3 under `qemu-system-xtensa`. This exercises the Xtensa LX7 assembly built with xtensa-gcc, which the host suite (`tests/conformance/`) never compiles. **How it works** - The 12 `.bit` vectors are embedded in the app image and decoded on the emulated S3 at 1 and 2 channels. - Raw int16 PCM streams out UART1 (to a host file via `-serial file:`) one frame at a time, so device RAM stays flat. UART0 carries only per-decode markers (`@@VEC` / `@@EnD … bytes=N status=ok`). - On the host, `compare.sh` carves the PCM file by the `@@END` byte counts and runs `opus_compare` against the references. A raw binary side-channel (vs base64 over the console) keeps stray logs out of the stream and runs ~2x faster. Semihosting would be faster still, but ESP-IDF gates its semihosting VFS on a JTAG debugger that QEMU doesn't present. **Build variants** - `esp32s3` — float decoder (S3 default); exercises `mathops_lx7.c`. - `esp32s3_fixed` — layers `sdkconfig.fixed.defaults` to exercise the fixed-point path and `fixed_lx7.h`. Same image, so `run-image.sh` / `compare.sh` are unchanged. **CI** The QEMU job is slow and only matters when the on-target image can change, so a `changes` job diffs the base and runs it only when `lib/opus`, `patches/`, `cmake/staging.cmake`, `tests/qemu/`, or the workflow changed. Wrapper-only changes skip it (the host suite covers those); a skipped job reports `skipped`, which branch protection counts as passing. To force a run, add the `ci-run-qemu` label, which re-runs only the QEMU job. Detection uses plain `git diff` and fails safe if the base commit is unreachable. **Running** ```bash tests/fetch_vectors.sh tests/qemu/run-qemu.sh # float tests/qemu/run-qemu.sh --env esp32s3_fixed # fixed-point ```
1 parent 9da38c4 commit c5fb0f3

18 files changed

Lines changed: 929 additions & 1 deletion

.github/workflows/ci.yml

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
branches: [main]
77
pull_request:
88
branches: [main]
9+
# `labeled` lets the `ci-run-qemu` label start a run. Every job except changes/cross-qemu is
10+
# gated on `github.event.action != 'labeled'`, so labeling re-runs only QEMU, not the whole suite.
11+
types: [opened, synchronize, reopened, labeled]
912

1013
# Least privilege: every job here only reads the repo (checkout, build, test, cache). Jobs that
1114
# need more should add a narrower job-level permissions block rather than widening this default.
@@ -15,6 +18,8 @@ permissions:
1518
jobs:
1619
pre-commit:
1720
name: Pre-commit checks
21+
# Skip on label-only events so labeling runs only the QEMU job (see pull_request types).
22+
if: github.event.action != 'labeled'
1823
runs-on: ubuntu-latest
1924
steps:
2025
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -27,6 +32,7 @@ jobs:
2732

2833
lint:
2934
name: Static analysis
35+
if: github.event.action != 'labeled'
3036
runs-on: ubuntu-latest
3137
steps:
3238
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -43,6 +49,7 @@ jobs:
4349

4450
build:
4551
name: Build
52+
if: github.event.action != 'labeled'
4653
runs-on: ubuntu-latest
4754
steps:
4855
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -57,6 +64,7 @@ jobs:
5764

5865
test:
5966
name: Unit tests (${{ matrix.alloc_mode }})
67+
if: github.event.action != 'labeled'
6068
runs-on: ubuntu-latest
6169
strategy:
6270
fail-fast: false
@@ -82,6 +90,7 @@ jobs:
8290

8391
conformance:
8492
name: opus_compare conformance
93+
if: github.event.action != 'labeled'
8594
runs-on: ubuntu-latest
8695
steps:
8796
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -113,8 +122,123 @@ jobs:
113122
- name: Run conformance tests
114123
run: ctest --test-dir build -L conformance --output-on-failure
115124

125+
# Decide whether the on-target build changed. The QEMU run only adds value for Xtensa codegen (the
126+
# patches/ asm and fixed-point paths); the src/ wrapper is already covered on host, so wrapper-only
127+
# changes skip it. A job skipped via `if:` reports "skipped", which branch protection treats as passing.
128+
changes:
129+
name: Detect on-target changes
130+
runs-on: ubuntu-latest
131+
outputs:
132+
target: ${{ steps.filter.outputs.target }}
133+
steps:
134+
# Full history so the diff base (PR base or previous push tip) is reachable.
135+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
136+
with:
137+
fetch-depth: 0
138+
139+
- name: Detect changes to on-target build inputs
140+
id: filter
141+
env:
142+
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
143+
run: |
144+
# Run QEMU only when the on-target image could change. A lib/opus submodule bump appears as
145+
# a change to the gitlink path. The src/ and include/ wrapper is omitted on purpose; host
146+
# jobs already cover it. Fail safe: run QEMU if the base commit is unreachable (force-push,
147+
# first push, shallow clone).
148+
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
149+
echo "Base ${BASE_SHA} unreachable; running QEMU job to be safe."
150+
echo "target=true" >> "$GITHUB_OUTPUT"
151+
exit 0
152+
fi
153+
changed="$(git diff --name-only "${BASE_SHA}...HEAD")"
154+
echo "Changed files:"
155+
echo "${changed}"
156+
if echo "${changed}" | grep -qE '^(lib/opus$|patches/|cmake/staging\.cmake$|tests/qemu/|\.github/workflows/ci\.yml$)'; then
157+
echo "target=true" >> "$GITHUB_OUTPUT"
158+
else
159+
echo "target=false" >> "$GITHUB_OUTPUT"
160+
fi
161+
162+
cross-qemu:
163+
name: Cross (Xtensa QEMU conformance, ${{ matrix.variant }})
164+
needs: changes
165+
# Skip on wrapper-only changes. Add the `ci-run-qemu` label to force a run.
166+
if: >
167+
needs.changes.outputs.target == 'true' ||
168+
contains(github.event.pull_request.labels.*.name, 'ci-run-qemu')
169+
runs-on: ubuntu-latest
170+
# esp-idf version matches the PlatformIO framework (esp-idf v5.4.x) so the
171+
# xtensa-gcc codegen is the same as the local run. Xtensa emulation is
172+
# native-speed on the amd64 runner.
173+
container: espressif/idf:v5.4.1
174+
# Float is the ESP32-S3 default; fixed layers sdkconfig.fixed.defaults to
175+
# exercise the fixed-point decode path and fixed_lx7.h. Separate runners so
176+
# the two QEMU runs go in parallel.
177+
strategy:
178+
fail-fast: false
179+
matrix:
180+
include:
181+
- variant: float
182+
sdkconfig_defaults: sdkconfig.defaults
183+
- variant: fixed
184+
sdkconfig_defaults: sdkconfig.defaults;sdkconfig.fixed.defaults
185+
steps:
186+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
187+
with:
188+
submodules: false
189+
190+
- name: Initialize component submodules
191+
run: |
192+
git config --global --add safe.directory '*'
193+
git submodule update --init --depth 1 lib/opus lib/micro-ogg-demuxer
194+
195+
# Same static-vector cache as the host conformance job. The vectors must be
196+
# present before the firmware is built: embed_vectors.py bakes them into the
197+
# app image at configure time.
198+
- name: Cache test vectors
199+
id: cache-vectors
200+
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
201+
with:
202+
path: tests/vectors
203+
key: opus-testvectors-${{ hashFiles('tests/fetch_vectors.sh') }}
204+
205+
- name: Download test vectors
206+
if: steps.cache-vectors.outputs.cache-hit != 'true'
207+
run: tests/fetch_vectors.sh
208+
209+
- name: Install QEMU (xtensa)
210+
shell: bash
211+
run: |
212+
. "$IDF_PATH/export.sh"
213+
python "$IDF_PATH/tools/idf_tools.py" install qemu-xtensa
214+
215+
- name: Build conformance firmware (idf.py, esp32s3)
216+
shell: bash
217+
run: |
218+
. "$IDF_PATH/export.sh"
219+
idf.py -C tests/qemu \
220+
-DSDKCONFIG_DEFAULTS="${{ matrix.sdkconfig_defaults }}" \
221+
set-target esp32s3 build
222+
223+
- name: Decode vectors under QEMU and compare on host
224+
shell: bash
225+
run: |
226+
. "$IDF_PATH/export.sh"
227+
QEMU_XTENSA="$(command -v qemu-system-xtensa)"
228+
[ -n "$QEMU_XTENSA" ] || { echo "qemu-system-xtensa not found on PATH after install"; exit 1; }
229+
export QEMU_XTENSA
230+
cc -O2 -o /tmp/opus_compare lib/opus/src/opus_compare.c -lm
231+
tests/qemu/run-image.sh \
232+
tests/qemu/build/bootloader/bootloader.bin \
233+
tests/qemu/build/partition_table/partition-table.bin \
234+
tests/qemu/build/qemu_opus_test.bin \
235+
/tmp/qemu.log \
236+
--timeout 900
237+
tests/qemu/compare.sh /tmp/qemu.log /tmp/opus_compare tests/vectors /tmp
238+
116239
platformio:
117240
name: PlatformIO Build
241+
if: github.event.action != 'labeled'
118242
runs-on: ubuntu-latest
119243
strategy:
120244
matrix:

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ examples/*/build/
103103
examples/*/sdkconfig
104104
examples/*/sdkconfig.old
105105

106+
# PlatformIO build output
107+
.pio/
108+
109+
# QEMU conformance firmware build artifacts (tests/qemu)
110+
tests/qemu/build/
111+
tests/qemu/sdkconfig
112+
tests/qemu/sdkconfig.old
113+
# Generated per-env sdkconfig (resolved from sdkconfig.defaults at build time);
114+
# only sdkconfig.defaults and sdkconfig.fixed.defaults are tracked sources.
115+
tests/qemu/sdkconfig.esp32s3*
116+
# Embedded test vectors: generated from tests/vectors/*.bit at configure time
117+
# into the build dir (main/CMakeLists.txt). A copy in the source tree is stale.
118+
tests/qemu/vectors_data.c
119+
106120
# Benchmark/test data
107121
*.tar.gz
108122
*.tar

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ host_examples/ # Host platform tools (opus_to_wav)
2222
tests/ # Host CTest suite (unit + opus_compare conformance)
2323
unit/ # Wrapper/parser tests
2424
conformance/ # opus_compare validation against RFC test vectors
25+
qemu/ # On-target (ESP32-S3 QEMU) conformance: validates Xtensa asm
2526
support/ # Shared test helpers (in-memory Ogg muxing)
2627
tools/ # Measurement tools (opt-in)
2728
fetch_vectors.sh # Downloads the RFC 8251 test vectors

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ cmake --build tests/build
240240
ctest --test-dir tests/build --output-on-failure
241241
```
242242

243+
An on-target conformance suite in [`tests/qemu/`](tests/qemu/) runs the same RFC 8251 vectors on an
244+
emulated ESP32-S3 under `qemu-system-xtensa` to exercise the Xtensa LX7 assembly that the host suite
245+
cannot compile. See [tests/qemu/README.md](tests/qemu/README.md).
246+
243247
## License
244248

245249
This project uses a dual-license structure:

tests/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ runs two kinds of tests through CTest:
88
- **`conformance/`** validates our patched libopus by decoding the official Opus test vectors with
99
our decoder and comparing each result against the reference decode with upstream `opus_compare`.
1010

11+
A third suite, **`qemu/`**, runs the same conformance vectors on an emulated ESP32-S3 under
12+
`qemu-system-xtensa` to validate the Xtensa assembly optimizations that this host suite cannot
13+
compile. It is a standalone ESP-IDF firmware project, not a CTest target; see
14+
[`qemu/README.md`](qemu/README.md).
15+
1116
## Running
1217

1318
```bash
@@ -65,7 +70,7 @@ The host build is fixed-point and the decode still passes against the float-deri
6570
because `opus_compare` uses a perceptual threshold, not bit-exactness. The vectors are decoder
6671
vectors, so they validate the decode path (notably the always-on `celt_stack_alloc` patch). The
6772
Xtensa DSP optimizations build only for ESP32/ESP32-S3 targets, so they are not exercised by this
68-
host suite.
73+
host suite. The [`qemu/`](qemu/README.md) suite covers them on an emulated ESP32-S3.
6974

7075
## Tools
7176

tests/qemu/CMakeLists.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Top-level ESP-IDF project for the on-target (QEMU) conformance firmware.
2+
#
3+
# Built with idf.py in CI (espressif/idf container) and with PlatformIO locally;
4+
# both point at main/ as the app component. The micro-opus component is pulled
5+
# in from the repo root via EXTRA_COMPONENT_DIRS.
6+
cmake_minimum_required(VERSION 3.16)
7+
8+
list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../..")
9+
10+
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
11+
project(qemu_opus_test)

tests/qemu/README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# On-target (QEMU) conformance test
2+
3+
Runs the RFC 8251 decoder-conformance vectors on an emulated ESP32-S3 under
4+
`qemu-system-xtensa` to exercise the Xtensa LX7 assembly. The host suite
5+
(`tests/conformance/`) builds with host gcc/clang and never compiles the Xtensa
6+
paths. This firmware is built with xtensa-gcc for `esp32s3`, with
7+
`CONFIG_OPUS_ENABLE_XTENSA_OPTIMIZATIONS` on, so it covers the asm the host suite
8+
cannot:
9+
10+
- `mathops_lx7.c`: `loopnez` float-to-int16 conversion (float build)
11+
- `silk/.../SigProc_FLP_lx7.h`: `round.s` / `float.s` SILK conversions (float build)
12+
- `fixed_lx7.h`: `mulsh` / `clamps` fixed-point multiplies (fixed build)
13+
- `pitch_lx7.h`: `dual_inner_prod` on the Xtensa MAC unit (both; the `FIXED_POINT`
14+
path only in the fixed build)
15+
16+
There are two build variants, since the ESP32-S3 selects float or fixed at
17+
compile time and the two paths use different assembly:
18+
19+
- **`esp32s3`** (float): the S3 default (`CONFIG_OPUS_FLOATING_POINT=y`).
20+
- **`esp32s3_fixed`** (fixed-point): layers `sdkconfig.fixed.defaults` to set
21+
`CONFIG_OPUS_FLOATING_POINT=n`. Same target and image, so `run-image.sh` and
22+
`compare.sh` are unchanged.
23+
24+
Run both to cover both decode paths. CI runs them as a matrix.
25+
26+
## How it works
27+
28+
`opus_compare` can't run on the device: it inflates both signals to float and
29+
builds whole-signal spectral buffers (about 30 MB for a 5 MB vector), and this
30+
build has no PSRAM. So the device decodes and the host compares.
31+
32+
1. On the emulated S3 (`main/qemu_opus_test.cpp`): the 12 `.bit` inputs are baked
33+
into the app image (`embed_vectors.py` writes `vectors_data.c` at configure
34+
time; the 16 MB flash and a custom `partitions.csv` make room). Each vector is
35+
decoded at 1 and 2 channels with `OpusPacketDecoder`, the same framing as
36+
`tests/conformance/decode_vectors.cpp`. The raw interleaved int16 PCM goes out
37+
UART1 one frame at a time, so RAM stays flat; the console (UART0) carries only
38+
per-decode markers (`@@VEC`, `@@END name=... ch=... bytes=... status=ok`).
39+
2. On the host (`compare.sh`): UART1 is routed to a file (`-serial file:`).
40+
`compare.sh` carves it into per-decode `.sw` files by the `@@END` byte counts,
41+
then runs a host build of `opus_compare` against the `.dec` references, using
42+
the same stereo-or-mono accept logic as the host suite.
43+
44+
## Running locally
45+
46+
Needs PlatformIO, Espressif's `qemu-system-xtensa`, and a C compiler. Install
47+
QEMU from <https://github.com/espressif/qemu/releases> (xtensa-softmmu) into
48+
`~/.espressif/tools/qemu-xtensa/`, or point `QEMU_XTENSA` at the binary. The
49+
build must support `-machine esp32s3` (the Espressif fork, not vanilla Homebrew
50+
QEMU). It runs natively on macOS, Apple Silicon and Intel; no Docker.
51+
52+
```bash
53+
tests/fetch_vectors.sh # once: vectors must exist before the build
54+
tests/qemu/run-qemu.sh # float build, run under QEMU, compare
55+
tests/qemu/run-qemu.sh --env esp32s3_fixed # fixed-point build
56+
tests/qemu/run-qemu.sh --no-build --timeout 900
57+
```
58+
59+
CI runs the same firmware via `idf.py` in the `espressif/idf` container; see the
60+
`cross-qemu` job in `.github/workflows/ci.yml`.
61+
62+
## Files
63+
64+
| File | Purpose |
65+
|---|---|
66+
| `main/qemu_opus_test.cpp` | Firmware: decode each embedded vector, raw PCM out UART1, markers out UART0 |
67+
| `main/embed_vectors.py` | Writes `vectors_data.c` from `tests/vectors/*.bit` at configure time |
68+
| `main/qemu_vectors.h` | Declares the embedded-vector table |
69+
| `partitions.csv` | 12 MB factory app partition to hold the embedded `.bit` inputs |
70+
| `sdkconfig.defaults` | esp32s3, no PSRAM, 16 MB flash, perf optimization (float) |
71+
| `sdkconfig.fixed.defaults` | Fragment layered for the fixed-point variant (`CONFIG_OPUS_FLOATING_POINT=n`) |
72+
| `run-image.sh` | Assemble the flash image, run QEMU, capture UART0 to the log and UART1 to `<log>.pcm` |
73+
| `compare.sh` | Carve `<log>.pcm` by the `@@END` byte counts and run `opus_compare` against the references |
74+
| `run-qemu.sh` | Local driver: PlatformIO build, then `run-image.sh`, then `compare.sh` |
75+
76+
## Transfer channel
77+
78+
The PCM (about 95 MB of raw int16 across the 24 decodes) leaves the chip on its
79+
own UART (UART1, routed to a file with `-serial file:`); the console and markers
80+
stay on UART0. A dedicated binary channel keeps stray ESP-IDF log lines out of
81+
the stream, skips the 4/3 base64 inflation and its encode cost, and runs about
82+
2x faster than base64 over the console VFS. QEMU models both UARTs and routes
83+
each `-serial` to its own backend, so this behaves the same in CI and locally.
84+
85+
Semihosting straight to a host file would be faster still (one host trap per
86+
buffer instead of one MMIO write per byte), but ESP-IDF gates its semihosting VFS
87+
on an attached JTAG debugger (`esp_cpu_dbgr_is_attached()`), which QEMU does not
88+
present, so it is unavailable here. The earlier base64-over-UART0 approach also
89+
worked but moved about 126 MB and was bound by the console UART VFS.

0 commit comments

Comments
 (0)