Android: static popping during playback #135
Closed
opened 2026-08-19 16:42:32 +00:00 by logan
·
5 comments
No Branch/Tag Specified
main
fix/146-stub-etxtbsy
fix/175-wizard-follows-the-library
fix/231-setter-rollback
fix/197-duplicate-column-label
docs/225-fixtures-wav-tags
docs/220-skill-check-scope
test/217-fixture-names-in-queue-selection
fix/216-riff-parse-allocation
fix/170-queue-header-action-names
fix/210-nav-sheet-scroll-affordance
docs/50-readme-landing-page
feat/65-art-prefetch-ahead
feat/71-more-as-a-bottom-sheet
feat/54-native-touch-feel
feat/67-entity-links-into-menus
test/196-visual-tier-gates
fix/138-ui-test-storage-leak
fix/104-wav-tags-read
fix/207-sheet-scroll-affordance
fix/204-ui-visual-update-filter
pi-agent-backlog-automation
63-touch-model-phase-2
63-android-touch-model
186-touch-targets-settings
186-touch-targets-page-header
187-seek-bar-hit-area
189-190-explore-correctness
135-android-underrun-instrumentation
51-android-small-screens
fix/171-phone-queue-scrim
fix/137-touch-only-affordances
fix/154-nested-css-check
feat/58-mini-player-progress-line
fix/66-album-page-scrolls-as-one
60-context-menu-action-sheet
64-android-system-volume
59-slim-the-mini-player
55-queue-as-a-screen
feat/57-drop-the-android-top-bar
feat/62-jobs-as-a-notification
fix/53-seek-bar-never-moves
fix/159-android-task-app-id
fix/52-android-activity-recreation-restarts-the-process
fix/150-expand-button-under-the-art
feat/42-inline-volume-and-centred-transport
fix/156-queue-selection-fixture-order
fix/151-fuse-the-scroll-guard-and-the-write
fix/43-queue-panel-selection
fix/143-top-bar-fits-its-window
feat/27-jobs-into-settings
feat/25-configurable-sidebar-tabs
feat/6-global-back-forward
fix/72-active-view-broadcast
fix/69-page-header-action-overflow
fix/quick-wins-batch
fix/118-in-library-clear
fix/61-mini-player-plain-text
fix/68-hover-affordances-pointer
fix/119-dev-headless-port
fix/130-issue-claim-user
fix/131-codegen-check-scope
feat/28-autotag-match-on-album
feat/17-demote-version-selector
feat/38-ownership-visibility
ci/115-manual-release
feat/34-icon-language
feat/7-full-tracklist-toggle
fix/16-tagwriter-totals
fix/unclaim-ca-certs
fix/unclaim-shell
ci/unclaim-on-close
docs/closing-keyword
docs/retire-stale-planning-docs
docs/issue-driven-workflow
integration/small-fixes
fix/small-issue-batch
fix/queue-toggle-state
fix/drag-count-badge
fix/album-card-year
fix/album-tracklist-heading
fix/seek-bar-clock-width
fix/explore-art-scanner-requests
chore/workflow-guardrails
v0.7.0
v0.6.0
v0.5.0
v0.4.0
v0.3.1
v0.3.0
v0.2.3
v0.2.2
v0.2.1
v0.2.0
v0.1.0
v0.0.1
v0.0.0
Labels
Clear labels
Area/Design
Area/Downloads
Area/Explore
Area/Library-UI
Area/Metadata
Area/Packaging
Area/Player
Area/Queue
Area/Settings
Area/Shell-Nav
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Platform/Android
Platform/Desktop
Breaking change that won't be backward compatible
Something is not working
Documentation changes
Improve existing functionality
New functionality
This is security issue
Issue or pull request related to testing
Priority
Critical
1
The priority is critical
Priority
High
2
The priority is high
Priority
Medium
3
The priority is medium
Priority
Low
4
The priority is low
Reviewed
Confirmed
1
Issue has been confirmed
Reviewed
Duplicate
2
This issue or pull request already exists
Reviewed
Invalid
3
Invalid issue
Reviewed
Won't Fix
3
This issue won't be fixed
Status
Blocked
1
Something is blocking this issue or pull request
Status
Need More Info
2
Feedback is required to reproduce issue or to continue work
Status
Abandoned
3
Somebody has started to work on this but abandoned work
Status
In Progress
Somebody is actively working on this right now
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: yonlu/yellowjacket#135
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Report
Audio on Android has a static/popping noise while a track is playing. Reported
from the device; not reproduced on desktop.
Findings
I have not reproduced this — it needs the phone — so everything below is read
from the source rather than measured, and the last section says what would
settle it. Three things in the audio path are candidates, and the first is the
one whose audible signature is exactly this symptom.
1. An underrun is rendered as silence, not as a stall. When the ring buffer
is empty,
BufferedStreamer.Streamzeroes the caller's buffer and returnsok:A run of zeros spliced into a waveform is a discontinuity at both edges, and a
step discontinuity is what a click or pop is. So an underrun here does not
sound like a gap or a stutter — it sounds like a pop, and a series of short
underruns sounds like static. Everything that makes an underrun likelier is
worse on a phone than on a desktop: slower storage, a CPU governor that parks
cores, background work, GC.
Nothing counts or logs it.
starved/starvedSinceexist (from the stallfix in #122) but they only feed the 3-second give-up threshold; a hundred
20 ms underruns a minute are invisible to the log, to the UI and to every test
tier. That is why this is a report rather than a measurement, and it is the
cheapest thing to change first.
2. The output rate is hardcoded to 44100, which is not a phone's native
rate.
Every file is resampled to it (
beep.Resample(4, sr, speakerSampleRate, ...))and the speaker is opened at it on every platform — there is no Android-specific
audio code at all (
backend/playerhas no build tags). Android's native outputis 48000 on essentially every device, so the platform resamples a second
time on the way out, and asking Oboe for a non-native rate is also what stops it
taking the fast/low-latency path. Two resamplings do not by themselves make
popping, but the second one is happening in the layer whose buffer is being
missed.
3. The device buffer is ~100 ms, not the 200 ms the comment claims. The
comment at
InitSpeakersays "Speaker buffer is 200ms", and beep then splits itin half:
So Oboe gets ~100 ms. oto's own documentation for that option says, in as many
words, to raise it "if you want to adjust latency or reduce noises". On
Android oto drives Oboe (
oboe.Play(sampleRate, channelCount, ..., bufferSizeInBytes)indriver_android.go), so this is the device-level bufferand it has never been tuned for a phone.
A distant fourth, and only if the popping correlates with notifications:
SetDuckapplies its attenuation instantaneously with no ramp, so each duck andun-duck is a step change in gain — one click per event, not continuous static.
It only fires below API 26.
Direction
Measure before changing anything, because all three candidates above are
plausible and the fix for each is different.
BufferedStreameralready tracks astarvation run for the stall threshold; add a cumulative counter and a debug
log, and read it off the device with
make android-inspect/logcatwhilethe popping is audible. If the count is zero while it pops, candidates 1 and
3 are both out and it is the resampling or something below us.
speaker buffer (the
TODOatInitSpeakeris exactly this knob, and itshould be per-platform rather than a single constant — a phone can afford
latency a desktop player cannot); raise the 2 s read-ahead; and consider
whether an underrun should hold the last sample rather than jump to zero,
which turns a pop into a much less audible artifact.
AudioManager.PROPERTY_OUTPUT_SAMPLE_RATEandPROPERTY_OUTPUT_FRAMES_PER_BUFFER, read on the Java side(
build/android/.../WailsForegroundService.javais where our Java lives) andhanded to
InitSpeaker. Opening at the device's native rate also removes thesecond resampling for free.
Worth knowing while working on this:
make android-inspectforwards theWebView's devtools socket, but this is entirely below the WebView — the signal
is in
logcat, not in the page. No test tier here can see it either: CI'saudio device is a PulseAudio null sink chosen because it keeps time, not
because it reproduces a phone's scheduling.
Taking this. Branch
135-android-underrun-instrumentation, stacked on160-android-slog-logcatbecause it depends on it: this issue's ownDirection is "count the underruns and log them ... read it off the
device with logcat", and until #160 every slog line on Android went to
/dev/null. Instrumenting and then reading nothing is the failure mode
worth avoiding.
Following the Direction as written rather than jumping to a fix: a
cumulative counter plus a bounded log first, then listen on the
device, and only then choose between the three candidates. If the
count is zero while it pops, candidates 1 and 3 are both out.
Measured on the device. The ring buffer is not underrunning, and the
Findings' first candidate is out.
Instrument in PR #191 (
feat(player): count what the ring buffer misses), following this issue's Direction step 1 rather than jumpingto a fix. It is stacked on #160 for the reason argued on #73: until
that landed, "read it off the device with logcat" was not a thing that
could be done.
Setup. TLP301, Android 14, arm64, debug build, real 1,577-track
library on
/sdcard/Music/t8-library. Test signal is a 4-minute440 Hz sine (
ffmpeg -f lavfi -i sine=frequency=440:duration=240),because the generated fixtures are ~2 s and a pop in a pure tone is
unmistakable where it hides in a mix. Index build stopped before each
measurement and confirmed stopped via
jobs.Service.GetJobs.Result: zero underruns, in every condition tried.
Playback was genuinely running throughout, not merely reported as
running:
PlaybackPositionChangedarrived once a second with amonotonic position (
130,131,132,133,134,135), anddumpsys audioshowed our pid with an
OpenSL ES AudioPlayer (Buffer Queue)instate:started.The instrument is live, and that had to be checked separately. A
counter that cannot move reports zero for the wrong reason, and "zero
underruns" from an unwired instrument is exactly the conclusion worth
not drawing. So a temporary build logged unconditionally rather than
only on a change, and produced the line at 1 Hz with
runs=0 calls=0 silenceMs=0— the path is reached, the count is genuinely zero. Theshipped build is silent while playback is healthy, as intended.
So candidates 1 and 3 are out, which is what this issue's own
Direction says to conclude: "If the count is zero while it pops,
candidates 1 and 3 are both out and it is the resampling or something
below us." Nothing is being spliced into the waveform by
BufferedStreamer, and a device buffer that were too small would showup here as underruns.
And candidate 2 has independent device evidence now. From the same
dumpsys audio, for our own pid:The device's output stream is 48000 Hz, and
speakerSampleRateishardcoded to
beep.SampleRate(44100). So every file is resampled to44100 by us and then resampled again to 48000 below us, which is the
Findings' candidate 2 confirmed on hardware rather than inferred. Worth
noting it is OpenSL ES, not AAudio — so the "asking for a
non-native rate stops Oboe taking the fast path" reasoning applies with
a different mechanism than the Findings assumed, and that is worth
re-reading before acting on it.
What this does not establish, and I want to be plain about it: I
could not hear the phone. These measurements say the ring buffer did
not underrun during them; they do not say the popping was audible at
the time, because nothing here can listen. So the honest statement is
"the cheapest hypothesis does not fire under load, seeks, or steady
playback", not "the popping is gone" or "the popping is the sample
rate".
Next, and it now has evidence behind it rather than being one of
three guesses:
recipe is
AudioManager.PROPERTY_OUTPUT_SAMPLE_RATEandPROPERTY_OUTPUT_FRAMES_PER_BUFFERread on the Java side and handedto
InitSpeaker. That removes one of the two resamplings for free,and
speakerSampleRatebeing a packagevarrather than a constsuggests it was always meant to be settable.
instrument stays, so "did that change anything" is now a number
rather than an opinion.
below us: oto's OpenSL path and its buffer size, which is the other
half of the
TODOatInitSpeaker.Unclaiming: the instrument is landing in #191 and the fix is a separate
piece of work that wants a device and someone who can hear it.
Listened to on the device by the reporter. The symptom does not
reproduce, in two conditions, and the instrument agrees.
Following the comment above, which counted the underruns and found
none: the missing half was that nothing here can hear the phone. That
was done directly this session.
The second row is the one that matters. The Findings say "everything
that makes an underrun likelier is worse on a phone than on a desktop
-- slower storage, a CPU governor that parks cores, background work,
GC", so the test was built to supply exactly that: real music with a
full index build running against the same single-writer SQLite
connection, on the reference device (TLP301, Android 14). Nothing was
audible and the counter stayed at zero throughout.
A sine was used for the first row on purpose -- a pop in a pure tone is
unmistakable where it hides in a mix -- and the 48 kHz source was
chosen to be the worst case for candidate 2, since it is resampled to
44100 by us and back to 48000 by the platform.
Why it may have gone away, offered as a hypothesis rather than a
claim. This was filed on 2026-08-19, four releases ago, and the
strongest candidate is not in the audio path at all: it is #190, fixed
earlier today. Before that, every launch ran the champion FTS
rebuild -- an
INSERT ... SELECTover a 1,079,667-row index -- whichspilled to a temporary file, failed with
SQLITE_IOERR_GETTEMPPATH,and was retried on the next launch, forever. That is a large, repeated
database operation contending with playback for CPU, IO and the single
SQLite writer on a phone, which is a good shape for an intermittent
audible glitch. It now succeeds once, in 6.6 s, and stops.
That is not proven and cannot now be, since the fix is already in. It
is written down so that whoever meets this again has somewhere to
start.
Closing, because the person who saw it has now listened and says it
is not there. That is the same standard #53 is being held to, with
the answer supplied rather than pending.
What stays is the instrument, which is the durable half of this
issue:
audio underrunlines in logcat now say immediately whether thering buffer is the cause, with runs, calls and milliseconds of silence,
and they are silent while playback is healthy. If this returns it is a
number rather than a report.
And the sample-rate mismatch is real but is not this bug. The
device's output stream is 48000 Hz and
speakerSampleRateis hardcodedto 44100, so every file is resampled twice;
dumpsys audioalso showsthe stream opening as OpenSL ES rather than AAudio. Both are worth
fixing on their own terms and neither is a defect anyone can hear right
now, so changing the audio path on the strength of them would be
exactly what this issue's own Direction warns against -- "measure
before changing anything". Filed separately as #194.
Closes #135
Closed on the evidence in the comment above: measured at zero underruns
in every condition tried, and listened to on the device by the reporter
under both steady playback and a full index build, with nothing
audible.
The instrument stays, which is the durable half. #194 carries the
sample-rate finding.
Successor: #203. The reporter has listened again on the most recent
release build and popping is still audible — less obvious than the
static this issue described, so the work here improved it without
settling it.
One fact from this issue needs qualifying rather than repeating, which
is why #203 exists instead of a reopen. The measurement above was
taken on a debug build of
main, and the counter that produced it isnot in any release.
842fe47landed afterv0.5.0was tagged:So "zero underruns" is true of what it measured and says nothing about
the build people install — candidate 1 is not eliminated for that
build, it was never tested on it. The listening test has the same
qualification, plus the conditions it was necessarily run in: attached
to USB, charging, screen on, debuggable process. All three bear on a
real-time deadline.
Nothing needs to change for that to become answerable: the instrument
logs at Info from an untagged file, and
make android-logsfilters onthe fixed
yellowjackettag rather than the package id, so it readsthe release app too.
Leaving this closed. Its record — the instrument, two measured
conditions and a listening test — is the useful thing and stays intact.