Audio latency: current path and measurements¶
Reviewed 18 October 2026. These settings describe the current tree; the investigation log below retains the earlier measurements and superseded models in their original sequence.
Current configuration¶
| Layer | Shipped behavior |
|---|---|
| RetroArch | 8 ms audio buffer request; this is not button-to-sound latency |
| RetroArch PipeWire driver | Requests one quarter of that setting as a quantum, with a 3 ms floor rounded up to whole frames: 96 at 32 kHz, 133 at 44.1 kHz, 144 at 48 kHz |
| PipeWire graph | Allows 32, 44.1 and 48 kHz; 144-frame minimum at the 48 kHz reference rate, scaled with the rate; power-of-two rounding disabled |
| AudioReach playback | Shared-memory pull mode with sample-accurate position reporting; no SNDRV_PCM_INFO_BATCH headroom |
| DSP period constraint | 10 ms of frames at the stream's rate, subject to alignment; this interrupt period is distinct from the graph quantum |
The 3 ms quantum is the tuned floor, not a fixed quantum for every client. Other applications and outputs can negotiate larger buffers. RetroArch's ring, the running graph quantum, PCM queue and physical speaker latency are separate quantities.
Sources: RetroArch's quantum patch, PipeWire graph configuration, and the default latency setting. See sample rates for which rates the cores and physical links can carry.
What has been measured¶
- The original Snes9x/Kirby Super Star camera report was 42–79 ms from button to sound, against Android's 42–50 ms. Those are historical image results.
- Rate-scaled DSP periods (#422) and pull-mode/no-batch playback (#428)
lowered the crackle-free RetroArch setting from the reported 32 ms to
8 ms.
pcm-flagsconfirmed the driver's flags and accepted periods. - A forced-quantum sweep found failures around 2 ms and clean operation at 3 ms in the tested sessions. With graph rounding disabled, requests of 96, 133 and 144 frames ran for 30 seconds at the three rates with no sink/client xruns. A 32 kHz 96-frame run also lasted about ten minutes.
- At 32 kHz the PCM's mean queued delay fell from about 6.5 ms at a 128-frame quantum to 4.9 ms at 96 frames. This is PCM delay, not end-to-end latency.
- Direct ALSA playback and a smaller DSP period did not improve the measured queue enough to replace the tuned PipeWire path. Light cores underrun on small direct-ALSA buffers because they deliver frame-sized audio bursts.
- NeoCD's 18 October capture tests also recorded zero xruns at the shipped 44.1 kHz / 8 ms setting; see NeoCD.
These tests do not establish stability for every core, wired headphones, Bluetooth or external displays. An absolute camera result for the complete current configuration is still needed.
Checking a current image¶
Run on the Nova while a game plays:
. /etc/profile
get_setting global.audiolatency
cat /proc/asound/card0/pcm0p/sub0/hw_params
pw-top
journalctl -b | grep -E 'period frames|headroom|xrun|underrun'
grep 'ms setting' /storage/.config/retroarch/logs/*
pcm-flags in an unofficial debug image checks the driver's capabilities
and accepted period sizes. Check that playback is not marked BATCH. Read
pw-top's running sink quantum and ERR counters; a follower's QUANT column
can show its request rather than the quantum actually running. A journal
summary depends on the enabled log level. Do not expect the old 480-frame
batch headroom from the historical model.
setsettings.sh writes audio_latency at every launch. To test a different
request, set global.audiolatency (or a system/game override) through
set_setting, then relaunch; a RetroArch-menu edit alone is not persistent.
Restore the original setting after an experiment. For logging and tools,
see debugging and the performance plan.
Remaining work¶
Measure physical button-to-sound latency and audio/video alignment on the current image, using the same trigger and game as the Android comparison. Instrument the threaded audio pipeline before attributing delay to its nominal target. Extend playback and rate-switch tests across cores and outputs, and compare wakeups and power without changing latency at the same time. Lowering the quantum below 3 ms is not a validated default.
Investigation log (historical)¶
The record below covers successive configurations. Statements such as "now", "shipped", "not yet run", the 24 ms default, the fixed 128-frame floor and power-of-two rounding refer to the build under investigation at that point. They are retained to explain the experiments, not as current setup instructions. Use the configuration and checks above on current images.
Original report¶
A user measured button-to-sound on the Nova against Android, filming at 240 fps and counting frames in Premiere: 10-19 frames on PortareOS (42-79 ms) against 10-12 on Android (42-50 ms). The same tests put PortareOS ahead on button-to-screen by about a frame. They also found RetroArch's audio latency setting would not go below 32 ms without crackling, where Android took 15. The game was Kirby Super Star, so the path under test was Snes9x at a 32 kHz link.
Where it ended up¶
The setting now goes to 8 ms, which is as low as RetroArch allows. The
sweep was run downwards to find the breaking point and never found one: at 4
the frontend logs 8 ms setting ... (raised to the minimum) and runs at 8.
pw-top's ERR column stayed flat on Snes9x and on SwanStation, the core this
tree already documents as having run 55.65 fps with the buffer running dry.
audio_latency floor, 32 kHz |
|
|---|---|
| as reported | 32 ms, crackled below |
| after #422 | ~24 ms |
| after #422 and #428 | 8 ms, the frontend's own minimum |
The DSP path has stopped being the constraint. Both changes are now read out
of the driver rather than inferred, with pcm-flags:
info flags, as the driver reports them:
BATCH no <- #428, patch 1074
at 32000 Hz: accepted period sizes 320 640 960 <- #422, step 320 = 10.0 ms
at 44100 Hz: accepted period sizes 448 896 <- #422, step 448 = 10.2 ms
SNDRV_PCM_INFO_BATCH is what made PipeWire keep an extra period queued, and
it was the largest single term in the budget below. It is gone. The step that
was 480 frames at every rate is now 10 ms of frames at the stream's own rate.
The intermediate measurement, after #422 but before #428, was the same tester's camera method: 8 to 15 frames at 240 fps, averaging 13 - 33 to 63 ms against the original 42 to 79, with the floor already below Android's best of 10 frames. The absolute figure has not been re-measured since.
Why 8 and not less: there are two floors and they are not the same one.
RetroArch's PipeWire driver asks for a quantum of a quarter of the setting and
floors it at 128 frames, holding the ring to at least two quanta. Separately,
and earlier, RetroArch clamps the setting itself: Latency setting of 4 ms is
below the 8 ms floor; using 8 ms. That check is upstream's, it is not in any
patch of ours, and it runs before the driver is reached - so lowering the
quantum floor cannot make 4 mean anything.
The clamp is liftable and it changes nothing. audio_latency_floor is a
setting, not a constant, and the hard minimum under it is 1: the source says
the 8 "is a setting now [...] because the reasoning above is about drivers
mishandling zero rather than about hardware, and an exclusive-mode driver
that negotiates its period with the device can go below it where the device
allows." Setting it to 4 and audiolatency to 4 was tried on the device.
The setting reaches the driver - the warning stops - and nothing downstream
moves, because both of the driver's own floors sit underneath it:
buf_samples = (uint64_t)latency * rate / 4000;
if (buf_samples < 128) buf_samples = 128;
At 32 kHz that is 64 -> 128 for a setting of 8 and 32 -> 128 for a setting
of 4, and highwater_mark is floored at two quanta, so the ring is 2048
bytes either way. Measured over a minute of Snes9x at each setting, the two
are the same run:
| setting 8 | setting 4 | |
|---|---|---|
| requested quantum | 128 frames, 4 ms | 128 frames, 4 ms |
| ring | 2048 bytes | 2048 bytes |
pw-top QUANT / ERR |
128 / 0 | 128 / 0 |
ALSA delay, mean |
211 frames, 6.60 ms | 210 frames, 6.57 ms |
ALSA delay, max |
256 frames, 8.00 ms | 256 frames, 8.00 ms |
So the shipped 8 ms setting already runs a 4 ms quantum. The number in
the setting names the ring, and the two-quanta floor overrides it at this
rate. Anyone reading "8 ms" as the period is reading it wrong, and the
driver's own init line says so: 8 ms setting: a 2048-byte ring (8 ms, rate
control holds it about half full) in front of a requested 128-frame quantum
(4 ms).
Lowering it did change what the shipping 8 ms setting asks for, 128 frames to
64, and 64 frames at 32 kHz stops this device's sink. Measured with
clock.force-quantum on the graph while a game played:
| forced quantum | PCM state | xruns in 8 s |
|---|---|---|
| 1024 | RUNNING | 0 |
| 512 | RUNNING | 0 |
| 256 | RUNNING | 0 |
| 128 | RUNNING | 0 |
| 64 | SETUP | no audio |
At 64 the PCM leaves RUNNING and spa.alsa: snd_pcm_avail after recover:
Broken pipe repeats a few hundred times a second, pw-top shows the sink at
QUANT 0 RATE 0, and nothing comes out until the quantum is raised. It
recovers the moment it is.
The floor is not 128 frames, and it is a time¶
Shipped as RetroArch patch 0017 (2026-10-05): the requested quantum is floored at 3 ms of frames at the stream's rate, rounded up: 96 at 32 kHz, 133 at 44.1, 144 at 48, instead of 128 frames. The floor is a duration, not a fixed frame count. The 8 ms ring is unchanged; the two-quanta floor under it is now 6 ms, so the setting names the ring at every rate.
The request alone changes nothing, measured 2026-10-06. PipeWire
clamps a node's request to the graph's min-quantum (256 at 48 kHz, scaled
to 170 at 32) and then, with clock.power-of-two-quantum on by default,
rounds it down to a power of two. A 96-frame request ran the sink at 128;
at 48 kHz both 128 and 144 ran it at 256. Lowering only the floor is worse:
96 rounded down to 64, the 2 ms that stops the sink, and threw xruns at
once. With the rounding off and the floor at 144/48000 (pipewire patch
002), the graph grants 96, 133 and 144 exactly and a 64 request is clamped
up to 96; each ran 30 s with the sink already running and 0 xruns on sink
and client, at all three rates.
The table above jumps from 128 to 64, and this document concluded from it
that 128 was the floor. It was only the first power of two that held.
clock.force-quantum is exempt from PipeWire's power-of-two rounding -
context.c:1756 applies flp2() only if (... && !force_quantum) - so
everything in between could be run, and none of it had been. Swept on
2026-09-30, 60 s a value, with pw-top's ERR on the sink and the PCM's
state and delay read every 2 ms:
| 32 kHz, Snes9x | sink ERR | PCM | delay min / mean / p99 | |
|---|---|---|---|---|
| 128 | 4.00 ms | 0 | RUNNING | 4.00 / 6.49 / 9.00 ms |
| 120 | 3.75 ms | 0 | RUNNING | 2.75 / 5.82 / 8.00 ms |
| 112 | 3.50 ms | 0 | RUNNING | 3.00 / 5.60 / 7.50 ms |
| 104 | 3.25 ms | 0 | RUNNING | 2.50 / 5.03 / 7.00 ms |
| 96 | 3.00 ms | 0 | RUNNING | 3.00 / 4.91 / 6.00 ms |
| 88 | 2.75 ms | 0 | RUNNING | 0.75 / 3.68 / 5.50 ms |
| 80 | 2.50 ms | 14 | XRUN, SETUP | 0.00 / 3.85 / 11.50 ms |
| 72 | 2.25 ms | 17 | XRUN, SETUP | 0.00 / 3.63 / 10.75 ms |
| 64 | 2.00 ms | - | stopped | - |
96 then ran just under ten minutes under play: no sink or RetroArch error, no xrun in the journal.
| 48 kHz | ARMSX2 | RetroArch, Dolphin | |
|---|---|---|---|
| 256 | 5.33 ms | clean, delay mean 7.43 ms | |
| 144 | 3.00 ms | clean, mean 4.64, min 3.00 | clean, mean 4.68, min 2.00 |
| 132 | 2.75 ms | clean, min 1.75 | clean, min 1.25 |
| 128 | 2.67 ms | clean, min 1.33 | 1 sink error, SETUP |
| 120 | 2.50 ms | clean, min 1.00 | |
| 112 | 2.33 ms | clean, min 1.00 | |
| 104 | 2.17 ms | clean, min 0.67 | |
| 96 | 2.00 ms | stopped |
The sink stops at 2.0 ms at both rates: 64 frames at 32 kHz, 96 at 48, the failure above each time. A floor counted in frames is a different floor at every rate, and a flat 96 would take every 48 kHz core down the moment the graph let it. Nor is recovery certain: forced to 48 kHz with 96 still set, the sink stayed stopped when the quantum was raised to 160, and came back only when both were cleared.
Above 2 ms the client decides. ARMSX2, standalone through the ALSA plugin, held down to 2.17 ms. With RetroArch in the graph it went at 2.5 ms at 32 kHz and once at 2.67 ms at 48 - the Dolphin rows ran partly with the core hung (#463), so under less load than a game. 2.75 ms is the lowest clean with RetroArch at both rates, and 3 ms is a quarter of a millisecond over it. What 3 ms takes off the PCM's mean delay: 6.49 to 4.91 ms at 32 kHz, and 7.43 to 4.68 at 48 with the same Dolphin session, where the graph runs 256 today.
Why the setting cannot reach it¶
Three floors, all policy and none hardware:
- RetroArch asks for at least 128 frames at any rate - 4 ms at 32 kHz, 2.67 at 48, the second already below where RetroArch glitched.
default.clock.min-quantumis 256 at 48 kHz, rate-scaled to 170 at 32 and 235 at 44.1, and a request below it is raised to it.flp2()then rounds the request down to a power of two: 170 to 128, and 96 to 64, which stops the sink.default.clock.power-of-two-quantumis read at startup (settings.c:222) and is not in the settings metadata.
So a 3 ms quantum is three changes that only work together: RetroArch's
floor as 3 ms of frames, min-quantum = 144 (3 ms at 48 kHz, scaled to 96
and 132), and power-of-two off. One alone is inert, or as #433 was,
harmful. Written, not yet run as a request end to end; 44.1 kHz not swept.
RetroArch sizes its ring from the quantum it asked for, not the one it got. At 48 kHz that is 384 frames in front of a 256-frame quantum today, one and a half quanta. Dolphin did not underrun on it, but a 48 kHz core on the threaded pipeline has not been measured.
Found on the way¶
- GameCube has had no sound since #431. Dolphin renders through its own
audio callback and hands the driver whatever it mixed in one write, and
pwire_write()drops a write longer than its ring. At 8 ms every Dolphin batch is: 4524Buffer too smalllines and 13796 periods of silence in one SoulCalibur II session. Written in pieces instead, the sound comes back with no underrun. A crackle is left that nothing here explains, and a hang that is Dolphin's own; both are in #463. pw-top's QUANT on a follower is its request, not the running quantum. The driver's line prints the clock; a follower's printsmeasurement.latency. RetroArch reads 128 whatever the graph runs.- The threaded pipeline was dismissed on its own comment, "slack for the
producer's burst and the consumer's period to slip past each other, not
latency". The code sets the pipe's target at one video frame of audio -
"never under one publish",
audio_driver_pipe_target_frames()- and rate control holds the fill against it. If it holds what the code says, it is the largest term left, around 16 ms at 60 fps, and nothing here has measured it.
Still open: the pipe above; the three changes as one, run as a request and soaked; 44.1 kHz; why RetroArch fails at a quantum ARMSX2 holds; power, since pull mode has the DSP publish its position continuously and nothing has looked at idle wakeups or suspend.
How it was reasoned about first¶
Everything below was written before any of it was measured, from the code this image ships. It is kept because it is what the fixes were aimed at, and it was right about where the milliseconds were.
The chain, at the shipped values¶
1. The setting was 32 ms, and it was not this device's number¶
packages/hardware/quirks/autostart/020-set_audio_latency is a ROCKNIX file
and writes the default when the setting is absent. setsettings.sh's
set_audiolatency then writes it into RetroArch's audio_latency on every
launch, so the global setting is what every core runs with and a value changed
in RetroArch's own menu does not survive a relaunch. 32 was chosen for a tree
that also has to boot on an RK3326; nothing picked it here. It went to 24, and
is 8 now that the DSP no longer sets the floor. post-update carries both
moves, 32 to 24 and 24 to 8, registered in that order so an install still on
upstream's value lands on 8 in one update; each moves only the old default and
leaves a value chosen since alone.
2. What RetroArch does with the setting¶
audio/drivers/pipewire.c, at the pinned commit 9705e03a:
- the quantum it asks the graph for is a quarter of the setting, floored at
128 frames:
buf_samples = latency * rate / 4000 - its own ring is the whole setting, and dynamic rate control holds it about half full
- it logs the budget at init, which is the first thing to read on the device:
[PipeWire] 24 ms setting: a … ring (… ms, rate control holds it about half full) in front of a requested …-frame quantum (… ms)
It sizes the ring to hold at least twice the quantum it asked for. What it gets can be different, and it is never told.
3. What the graph does with the request¶
Two things in src/pipewire/context.c, neither of them obvious from the config:
- the quantum bounds are scaled by the link rate.
min-quantumis 256 in002-graph-quantum.patch, but that is 256 atdefault.clock.rate, which is - At another rate all three bounds are rescaled
(
context.c:1727-1729), so the floor is 235 frames at 44.1 kHz and 170 at 32 kHz - 5.33 ms at every rate. There is no rate-dependent floor. - the result is floored to a power of two.
default.clock.power-of-two-quantumis unset here and defaults totrue(settings.c:30), soflp2()is the last thing applied (context.c:1756). A request for 352 frames becomes 256. A request for 288 becomes 256. A request for 192 becomes 128.
The second is the one that decides the shape. Between about 21 ms and 42 ms at 48 kHz, every setting produces the same 256-frame quantum, so lowering the setting shrinks RetroArch's ring and leaves the pull it has to service unchanged:
| setting | rate | asks for | graph runs at | ring, half full | ring / pull | slack |
|---|---|---|---|---|---|---|
| 32 ms | 48000 | 384 | 256 | 768 | 3.00 | 10.7 ms |
| 32 ms | 44100 | 352 | 256 | 705 | 2.75 | 10.2 ms |
| 32 ms | 32000 | 256 | 256 | 512 | 2.00 | 8.0 ms |
| 24 ms | 48000 | 288 | 256 | 576 | 2.25 | 6.7 ms |
| 24 ms | 44100 | 264 | 256 | 529 | 2.07 | 6.2 ms |
| 24 ms | 32000 | 192 | 128 | 384 | 3.00 | 8.0 ms |
| 16 ms | 48000 | 192 | 256 | 384 | 1.50 | 2.7 ms |
| 16 ms | 44100 | 176 | 128 | 352 | 2.75 | 5.1 ms |
| 16 ms | 32000 | 128 | 128 | 256 | 2.00 | 4.0 ms |
The margin RetroArch believes it has is two pulls. 48 kHz is the rate that loses it first, because 256 frames is a power of two and the quantum therefore cannot step down until the request halves: at 16 ms the ring is 1.5 pulls and 2.7 ms from empty. At 24 ms no rate is under two pulls, which is why 24 is the number.
Corroboration from the device. Commit b2134e956b recorded that "the graph
ran at 256 frames while RetroArch asked for 352" and read it as min-quantum
outvoting the request. flp2(352) is 256, so that observation is the
power-of-two flooring, and it is the only reading of the graph quantum anyone
has actually taken here.
4. The sink: a 480-frame floor that nothing above it can move¶
Superseded by #422 and #428; kept because the mechanism is unchanged and the numbers below are what the fixes were measured against.
q6apm-dai.c declares SNDRV_PCM_INFO_BATCH on both playback and capture, and
constrains PERIOD_SIZE and BUFFER_SIZE to steps of 480 frames, with the
comment "setup 10ms latency to accommodate DSP restrictions".
PipeWire's ALSA plugin, spa/plugins/alsa/alsa-pcm.c in 1.6.8, then:
- takes the graph quantum as the period, halves it because the device is batch,
and calls
snd_pcm_hw_params_set_period_size_near- which the 480-frame step rounds back up to 480 recalc_headroom():if (state->is_batch) state->headroom += state->period_frames, so +480 frames, on the reasoning that a timer-scheduled reader can miss a batch device's pointer update- tops the device up to
threshold + headroom, that is quantum + 480 frames
Unlike the graph's bounds, this 480 is not scaled by rate: it is a driver constant in frames, so it is 10.0 ms at 48 kHz, 10.9 at 44.1 and 15.0 at 32. It is the largest single term in the budget and nothing in userspace can move it.
The budget¶
ring/2 + quantum + (quantum + 480), in milliseconds:
| path | ring, half full | quantum | sink | total |
|---|---|---|---|---|
| 48 kHz, was 32 ms | 16.0 | 5.3 | 15.3 | 36.7 |
| 48 kHz, now 24 ms | 12.0 | 5.3 | 15.3 | 32.7 |
| 44.1 kHz, was 32 ms | 16.0 | 5.8 | 16.7 | 38.5 |
| 44.1 kHz, now 24 ms | 12.0 | 5.8 | 16.7 | 34.5 |
| 32 kHz, was 32 ms | 16.0 | 8.0 | 23.0 | 47.0 |
| 32 kHz, now 24 ms | 12.0 | 4.0 | 19.0 | 35.0 |
| 32 kHz, 24 ms, with #422 | 12.0 | 4.0 | 14.0 | 30.0 |
| 44.1 kHz, 24 ms, with #422 | 12.0 | 5.8 | 16.0 | 33.8 |
The 32 kHz row gains 12 ms rather than 4, because at 24 ms it is the one rate whose request falls below 256 and takes the quantum down to 128 - which shrinks the sink's top-up as well as the quantum itself. That is the path the report measured.
Then the AudioReach graph, MI2S and the aw88166 amps, none of which is visible from here. The measurement was 42-79 ms against a 47.0 ms budget for the path measured, so the rows above account for it and the answer is not hiding in the DSP.
What is still open¶
- Why 32 ms was a floor for the reporter is not explained. By the table
above, 16 ms at a 32 kHz link leaves the ring two pulls deep, which should
not crackle. Either something else on that device was late, or the setting
was not taking effect - and a value changed in RetroArch's menu does not
survive the relaunch
setsettingsperforms.pw-top's ERR column says which node, if any. - The 480-frame headroom, which is conditional.
recalc_headroom()adds it only underif (!state->disable_tsched || state->resample). In IRQ mode PipeWire wakes on the period interrupt and knows the pointer exactly, so the 480 frames drop toapi.alsa.headroom, which is 0 by default. The other half of the guard is already satisfied: the allowed-rates work runs the link at the stream's rate, so the sink does not resample. q6apm-dai's period completion is a DSP event, which is the condition IRQ mode assumes.wireplumber'spackage.mkalready copiesconfig/${DEVICE}/*.conf, and that directory does not exist yet; amonitor.alsa.rulesblock settingapi.alsa.disable-tsched = trueis where this goes. Worth 10 to 15 ms and entirely untested. - The 480-frame step itself. See below.
The 32 kHz link is not the problem¶
It costs about 2 ms against 48 kHz, not the 13 an earlier draft of this document claimed: the fixed 480-frame headroom is worse in milliseconds at a lower rate, but at 24 ms the 32 kHz request is the one that gets a 128-frame quantum, and that nearly cancels it.
| Snes9x at 24 ms | total |
|---|---|
| 32 kHz, as shipped | 35.0 ms |
| 44.1 kHz | 34.5 ms |
| 48 kHz | 32.7 ms |
So dropping 32 kHz for 44.1 or 48 buys half a frame at 240 fps and gives up the
gentler conversion AUDIO_SAMPLE_RATES.md chose it for. Not worth it. Keep the
rate; the milliseconds are in item 2 above and in the step below.
The ULL graph: not a port, and probably not the first move¶
AudioReach is not something to bring to Linux - it is already the framework
mainline uses, sound/soc/qcom/qdsp6, and q6apm-dai opens graphs out of the
same topology blob Android does, by id. What Android has that this does not is a
graph configured for small buffers, plus a path that does not impose 10 ms on
the way in. Three pieces, in increasing order of how much is unknown:
- The driver constants, which are not per-graph. The 480-frame step and
SNDRV_PCM_INFO_BATCHare applied inq6apm_dai_opento every substream, whatever graph is behind it. The comment says the step accommodates "DSP restrictions", which is a claim about the DSP that may only be true of the default graph. Done, and the claim was false. #422 replaced the fixed step with 10 ms of frames at the stream's own rate - 320 at 32 kHz, 448 at 44.1, 480 unchanged at 48 - and the DSP starts the graph as before. Measured on the device with a SNES game running:period_size: 320, and the pathologicalSink ratewarning RetroArch used to log (-17451 ppm of 32000) is gone, replaced by -50 ppm. #428 then clearsSNDRV_PCM_INFO_BATCHin push-pull mode and moves the topology toSH_MEM_PULL_MODE, which should take the headroom too; merged, not yet run. - Whether the shipped blob even has a low-latency graph. Answerable by
inspection:
tplg-playback-rates.pyalready walks the type-7 PCM blocks and found onlyMultiMedia1 PlaybackandMultiMedia2 Playback. Android's blobs usually expose many more frontends, a low-latency one among them, so a two-frontend topology is a sign this one may not carry it. The blob is not in this tree - it arrives withextra-firmware- so this needs a build or a device. Done, and the suspicion was wrong: every sub-graph in the blob is already declared low-latency. See "Android's calibration database and mixer paths" below. - Authoring a graph, if it is absent. This is the wall.
AUDIO_SAMPLE_RATES.mdalready records that the available AudioReach.m4source does not reproduce the shipped binary, which is why the rate work edits the blob in place. Adding a frontend and a graph is a great deal more than patching three fields, and there is no way to regenerate the blob to check the result against.
So: yes in principle, and the honest order is 1, then 2, and only then consider 3. Item 1 may make the rest unnecessary.
What Android does on the same device¶
2026-10-06, the Nova booted into its stock Android, idle, no game yet:
dumpsys media.audio_flinger, dumpsys media.audio_policy, dumpsys audio.
The files are kept outside the tree (~/1234/android/running_no_game);
the lines that decide anything are these.
There is no MMAP output. The policy's output profiles are primary
(PRIMARY|FAST), deep_buffer, hifi_playback, spatial output,
direct_pcm, compressed_offload, voice_tx, voip_rx and
incall_music_uplink; MMAP_NOIRQ appears nowhere. AAudio's exclusive
path, the one that hands an app a buffer the DSP reads directly, does not
exist on this hardware. Every low-latency client lands on the primary
output's FAST track and goes through the FastMixer.
The FastMixer is Android's quantum, and it is 4 ms. The primary
output's HAL frame count is 192 at 48 kHz; the FastMixer reports
mixPeriod=4.00 ms, measured 3.94 to 4.20 ms per cycle, underruns=0
over 2.7 million frames, 158 us of CPU a cycle. Normal tracks go through
the 960-frame normal mixer, 20 ms, and the policy reports the primary
output's latency as 61 ms on that path; a game there would be far worse
than ours, so a 10-frame camera result means RetroArch was on a fast
track.
The FastMixer reports latency=21.00 ms. That is the HAL's figure
for everything from the mixer's output to the speaker: HAL buffering,
the AudioReach graph, the codec and the amplifier. It is the tail this
document could never measure from Linux, on the same silicon. The two
idle fast tracks in the dump carry the same 21.00 ms in their Latency
column.
| Android, fast path | PortareOS, PipeWire, after #540 and #542 | |
|---|---|---|
| app's own buffer | unknown until a game runs | RetroArch ring 8 ms, held about half full: ~4 |
| mixer or graph period | 4 ms | 3 ms quantum |
| queued at the hardware | inside the 21 | ~4.9 ms at the PCM |
| HAL/DSP/codec tail | inside the 21 | not visible; same hardware |
If the tail behind the hardware is the same on both, the Linux path is within 3 to 4 ms of Android before the app's own buffer. The tester's 12 to 13 camera frames against Android's 10 then fit only if Android's RetroArch buffers less than our 8 ms ring, and that is the one unknown left.
What to read in the game-running dump. In media.audio_flinger, the
primary thread's track table: RetroArch's row with F in the first
column (fast track), its FrmCnt (the track's buffer in frames at its
SRate) and its Latency column, Android's total for that track. From
RetroArch's Android log or config: audio_driver (opensl or aaudio),
audio_latency, audio_block_frames. That Latency figure against the
8.9 ms here is the comparison the rest of this document leads to.
Android's calibration database and mixer paths¶
2026-10-06, pulled from the stock Android alongside the dumps above and kept
outside the tree (~/1234/android/vendor): acdbdata/kalama_cdp/CDP_acdb_cal.acdb
(2.7 MB), its CDP_workspaceFileXml.qwsp, the four mixer_paths_kalama_*.xml
and audio_policy_configuration.xml. The question was whether any of it
carries a lower-latency DSP graph than ours, or speaker tuning we lack.
The two sides describe graphs in different places. Android's userspace
(AGM/PAL) opens graphs by key vector out of the ACDB, which also holds the
per-module calibration; mainline's q6apm builds the same APM_CMD_GRAPH_OPEN
from the ALSA topology blob instead, and sends no calibration. So the ACDB's
graph list is readable by name, and ours by decoding the blob's vendor tuples
with the token ids in include/uapi/sound/snd_ar_tokens.h.
Android's graph list. strings on the ACDB gives the stream key vectors:
StreamRX_PCM_ULL_Playback_*, StreamRX_PCM_LL_Playback_*,
StreamRX_PCM_Deep_Buffer_*, StreamRX_Generic_Playback_*,
StreamRX_Compress_Offload_*, StreamRX_RAW_Playback_*, each in speaker,
headphones, BT, USB and HDMI variants. ULL, LL and deep-buffer are the three
playback classes Android's policy exposes (primary with the FAST flag is
the LL one, which is what the idle dump showed RetroArch would get).
Ours. AYN-Odin2-tplg.bin, 19,960 bytes, 25 widgets, decoded on the
device:
| Sub-graph | Modules, in order | Perf mode | Direction |
|---|---|---|---|
stream0 (MultiMedia1 Playback) |
SH_MEM_PULL, PCM_DEC, PCM_CNV, SOFT_VOL, MFC, LOGGING | 2 | RX |
stream1 (MultiMedia2 Playback) |
same | 2 | RX |
stream2 (MultiMedia3 Capture) |
LOGGING, MFC, PCM_CNV, PCM_ENC, RD_SHMEM_EP | 2 | TX |
device113 (RX_CODEC_DMA_RX_0) |
LOGGING, MFC, CODEC_DMA_SINK | 2 | RX |
| device16, device104, device120 | MI2S, DisplayPort, codec DMA TX equivalents | 2 |
Perf mode 2 is APM_SG_PERF_MODE_LOW_LATENCY (apm_sub_graph_api.h in
AudioReach's engine; 1 is LOW_POWER). The kernel passes it straight through
as APM_SUB_GRAPH_PROP_ID_PERF_MODE at graph open (audioreach.c). So both
playback frontends are already the DSP's low-latency class, and the only
difference between them is where they are routed. There is no deep-buffer or
low-power graph in the blob at all; the thing Android has three of, we have
one of, and it is the fast one.
What that means for the list in "The ULL graph" above: item 2 is answered,
the graph is there; item 3, authoring one, is moot. What distinguishes
Android's ULL from its LL is not a different DSP sub-graph property but the
host side: the shared-memory period size AGM configures and the buffering
AudioFlinger puts in front of it (4 ms FastMixer, 21 ms HAL latency per the
dump). On our side those are q6apm_dai's period, PipeWire's quantum and
RetroArch's own buffer, which is where every measured lever has turned out to
be.
Calibration. The ACDB's per-module calibration targets modules we do not
instantiate - speaker protection, limiter, MBDRC and the like are absent from
our stream path, which is decode, convert, volume, rate convert, log. The
speaker tuning that reaches the drivers lives in the amplifier, not the DSP:
the AW88166 pair runs its own firmware with the profiles Music and
Receiver (SPK_L AW88166 Profile Set), loaded from
qcom/sm8550/retroidpocket/rpnova/aw883xx_acf.bin, which nova-firmware
ships from the Nova's own Android image (RPN07220904). Android's inventory
lists the same file at /vendor/firmware/aw883xx_acf.bin; it was not pulled,
so the two are not yet confirmed byte-identical.
Mixer paths. The four mixer_paths_kalama_*.xml are Qualcomm's reference
board files (speaker-hdk, speaker-fluid, speaker-liquid, -vbat
variants); none names an AW88166 control, so none is the Nova's actual
routing. They confirm the RX routing we already have and nothing further.
Not readable. The .qwsp workspace has no plain-text or archive structure
(high entropy from byte 0); it is Qualcomm's tool format, so the ACDB cannot
be edited or diffed here beyond its strings.
RetroArch straight to the PCM: measured, and not a lever either¶
2026-10-06. audio_driver = alsa, audio_device = hw:0,0, PipeWire idle so
the device is free, the DSP period floor at 4 ms (patch 1077), and a 10 Hz
sampler of the PCM state counting XRUN episodes, since RetroArch's ALSA
driver recovers them without a log line.
| core | rate | period / buffer | queue min / mean | XRUN episodes | run |
|---|---|---|---|---|---|
| bsnes | 32 kHz | 128 / 256 (8 ms) | 1.1 / 5.2 ms | 1 | 300 s |
| bsnes | 32 kHz | 128 / 384 (12 ms) | 4.1 / 8.8 ms | 0 | 300 s |
| gambatte | 44.1 kHz | 192 / 384 (8.7 ms) | 0.8 / 5.2 ms | 12 | 90 s |
| mgba | 48 kHz | 192 / 384 (8 ms) | 0.02 / 5.0 ms | 3 | 90 s |
| genesis_plus_gx | 44.1 kHz | 192 / 384 (8.7 ms) | 0.3 / 5.4 ms | 17 | 90 s |
At the only setting that ran clean, the direct path's queue is 8.8 ms, the tuned PipeWire path's 8.9. The light cores underrun more than bsnes at a tenth of its CPU, so the cause is not frame time: RetroArch hands the device one emulated frame of audio at a time, 735 frames at 44.1 kHz, into a buffer that holds 384. The write blocks until half has drained, the buffer is full when it returns, and it has to last until the next frame's write; there is no margin at any core speed. A buffer that holds a frame plus margin is 12 ms and more, and that is the PipeWire figure. PipeWire's software ring absorbs exactly this burst, which is what it is for. An MMAP writer has the same queue structure and would reproduce the table. At the vendor's 10 ms floor the same path is 11.9 ms mean.
The experiment is kept here because it closes the transport question: the
software queue below RetroArch is about 9 ms either way, and replacing
PipeWire with direct ALSA does not explain Android's advantage. Android's
RetroArch receives the same frame-sized batches, so what is left to find is
how Android buffers them downstream, OpenSL or AAudio, fast track or MMAP,
frame counts, and what its DSP graph does after that: dumpsys
media.audio_flinger and dumpsys media.audio_policy on the Android Nova
while a game runs, against the 8.9 ms measured here.
The DSP period floor, measured: not a lever¶
2026-10-06, kernel patch 1077's period_min_ms swept on the device. With
pcm-floor on the bare PCM, PipeWire stopped: 160 frames at 32 kHz (5 ms)
and 128 (4 ms) streamed clean, and at 48 kHz 160 frames (3.3 ms) did; the
DSP runs well below the vendor's 10 ms. Then Super Mario World on bsnes,
60 s a value, the PCM delay (appl_ptr - hw_ptr) read every second and
the sink's xrun counter before and after:
period_min_ms |
period PipeWire chose | delay min / mean / max | xruns |
|---|---|---|---|
| 10 | 960 frames | 128 / 204 / 256 frames (6.4 ms mean) | 0 |
| 5 | 960 frames | 128 / 201 / 288 | 0 |
| 4 | 1024 frames | 128 / 202 / 256 | 0 |
All three sounded clean to the user. The delay did not move because the
sink is timer-scheduled: PipeWire wakes by its own clock against the
sample-accurate position pull mode gives it, tops the device up to the
quantum, and takes its period from api.alsa.period-size (1024), which
the floor only has to permit. The hardware period is the interrupt
interval and nothing else. What sets the delay is the quantum, which is
why 128 to 96 frames (patch 0017) moves it and this does not. The floor
stays at 10; a smaller one would only matter in IRQ mode
(api.alsa.disable-tsched), where the period becomes the wake, and that
trade has not been measured.
The experiment that settles it¶
Read the two lines that state what was actually negotiated. From the journal, the sink's own summary:
journalctl -b | grep -E 'period frames|headroom'
which prints buffer frames … period frames … periods … headroom … batch:1
tsched:…. period frames 480 and headroom 480 confirm section 4; anything
else means the model above is wrong and the rest of this document with it.
Then RetroArch's, which states its half of the budget in milliseconds:
grep 'ms setting' /storage/.config/retroarch/logs/*
And the device's view, while a game is playing:
cat /proc/asound/card0/pcm0p/sub0/hw_params # period_size, buffer_size
pw-top # QUANT on the sink and on RetroArch
pw-top's QUANT on RetroArch's node against the sink's is the direct check on
section 3: a 24 ms setting at 48 kHz should show 256 even though RetroArch asked
for 288, and 128 at a 32 kHz link.
Sweep the setting, one run each, listening for crackle and watching pw-top's
ERR column. set_setting global.audiolatency <ms> then relaunch, because
setsettings rewrites the config at launch:
for ms in 24 21 16 12 8; do ... ; done
Do it on a 48 kHz core and on Snes9x, because the two rates fail in different places: 48 kHz thins out at 16 ms, 32 kHz not until 8.
Re-measure end to end the way the report did - 240 fps, count frames from the D-pad to the waveform - because every number above is a budget and only the camera says what came out of the speaker.