Skip to content

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-flags confirmed 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:

  1. 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.
  2. default.clock.min-quantum is 256 at 48 kHz, rate-scaled to 170 at 32 and 235 at 44.1, and a request below it is raised to it.
  3. 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-quantum is 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: 4524 Buffer too small lines 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 prints measurement.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-quantum is 256 in 002-graph-quantum.patch, but that is 256 at default.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-quantum is unset here and defaults to true (settings.c:30), so flp2() 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

  1. 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 setsettings performs. pw-top's ERR column says which node, if any.
  2. The 480-frame headroom, which is conditional. recalc_headroom() adds it only under if (!state->disable_tsched || state->resample). In IRQ mode PipeWire wakes on the period interrupt and knows the pointer exactly, so the 480 frames drop to api.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's package.mk already copies config/${DEVICE}/*.conf, and that directory does not exist yet; a monitor.alsa.rules block setting api.alsa.disable-tsched = true is where this goes. Worth 10 to 15 ms and entirely untested.
  3. The 480-frame step itself. See below.

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:

  1. The driver constants, which are not per-graph. The 480-frame step and SNDRV_PCM_INFO_BATCH are applied in q6apm_dai_open to 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 pathological Sink rate warning RetroArch used to log (-17451 ppm of 32000) is gone, replaced by -50 ppm. #428 then clears SNDRV_PCM_INFO_BATCH in push-pull mode and moves the topology to SH_MEM_PULL_MODE, which should take the headroom too; merged, not yet run.
  2. Whether the shipped blob even has a low-latency graph. Answerable by inspection: tplg-playback-rates.py already walks the type-7 PCM blocks and found only MultiMedia1 Playback and MultiMedia2 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 with extra-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.
  3. Authoring a graph, if it is absent. This is the wall. AUDIO_SAMPLE_RATES.md already records that the available AudioReach .m4 source 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.