Skip to content

Fix RX: MediaTek USB audio HAL capture + reinit lifecycle bugs (split from #925) - #949

Open
laniol8926 wants to merge 6 commits into
patrickrb:devfrom
laniol8926:fix/micrecorder-usb-pcm16-capture
Open

laniol8926 wants to merge 6 commits into
patrickrb:devfrom
laniol8926:fix/micrecorder-usb-pcm16-capture

Conversation

@laniol8926

@laniol8926 laniol8926 commented Oct 22, 2026 •

Copy link
Copy Markdown

Debug APK

Fork PR — CI builds the debug APK on ft8af/** changes but can't post a comment here (read-only token). See the Debug APK for manual testing job's summary page on this PR's checks tab once it finishes.

Device test

  • Needs device test (labeled needs-device-test): changes MicRecorder's capture format (float → PCM16) and its reinitialize/restart lifecycle, which affects USB-audio and standard-mic RX on every rig, not just the one it was found on.
  • Emulator is enough

Manual test checklist

  1. Connect a rig over USB audio (CAT rig of your choice) and start RX. Expected: waterfall/decodes appear as before.
  2. In Settings, switch the audio input device while RX is running. Expected: capture actually switches to the new device (previously it silently kept using whatever it opened with until the next USB reconnect/app restart).
  3. Unplug/replug the USB audio device a few times in quick succession. Expected: capture keeps recovering every time, including after picking a device right after a replug (previously one transient AudioRecord creation failure right after a replug could permanently stop capture for the rest of the app session).
  4. Confirm decode quality/latency on at least one non-QMX rig (e.g. IC-7300/705, a CM108/Digirig-style interface, or a Bluetooth SCO mic) is unchanged from before this PR — this is the main open question this PR exists to answer.
  5. Pick the USB rig as the input device in Settings, then power-cycle the radio (not just unplug/replug). Expected: the picker still shows the rig selected (not "Default") after reopening Settings, and RX keeps working without having to manually re-pick it.

What changed

Split out of PR #925 (QRP Labs QMX via hamlib) per review feedback: that PR found and fixed three MicRecorder bugs during an all-night real-hardware RX investigation (full narrative in that PR's comment thread), but none of the three are QMX-specific — they affect every rig's RX audio capture — so they're reviewed/tested here independently of the QMX catalog change.

  1. Picking a new input device in Settings never reached the live capture. The device-picker's onSelect persisted the new device id but never told the already-running MicRecorder to rebuild with it — it only reads that field at construction time. Fixed by calling the existing reinitializeAudioInput() from the picker, mirroring the RX-channel-change and Bluetooth-SCO-connect paths, which already did this for the same reason.
  2. A transient AudioRecord creation failure permanently stopped capture for the rest of the app session. reinitialize() decided whether to restart by reading isRunning, but a transient failure (observed right after a USB replug/power-cycle, before the audio HAL caught up) sets isRunning = false as a side effect with no owner action — so every later reinitialize() (even the operator picking a perfectly valid device afterward) saw isRunning already false and silently skipped restarting. Fixed with a separate shouldBeRunning intent flag, touched only by start()/stopRecord(), with the restart decision keyed off that instead (shouldRestartAfterReinitialize, now unit-tested).
  3. Requesting ENCODING_PCM_FLOAT silently breaks capture on at least one real MediaTek USB audio HAL. Found via a precise real-hardware A/B (same running app process, same CAT mode, same device — only the HAL's negotiated format differed across a USB re-enumeration): every float-format open decoded nothing, every PCM16-format open decoded normally. The HAL's own log explains why: it falls back to a 16-bit word size internally while still being asked to hand back 4-byte floats, producing unusable samples with no error surfaced anywhere. Fixed by requesting ENCODING_PCM_16BIT and converting to float in software in the reader loop (int16ToFloat).
  4. A USB device's AudioDeviceInfo id is not stable across a reconnect — confirmed on real hardware when a QMX power-cycle alone reverted the Settings input picker to "Default". MicRecorder.findAudioDeviceById, FT8TransmitSignal.findAudioDeviceById, and AudioDeviceSpinnerAdapter.getPositionByDeviceId all matched only by exact numeric id, so a stale id after any USB re-enumeration silently fell back to the default device/display, even though the app already persists the device's VID:PID on every pick. Fixed with UsbAudioDevice.resolveDeviceIndex: exact id match first, else fall back to the product name of the live USB device whose VID:PID matches what was persisted (product name doesn't change across reconnects). Applied symmetrically to both input and output.
    • Two follow-up bugs surfaced via real on-phone dumpsys data while testing Fix 26 bugs: crashes, resource leaks, encoding errors, RTL support #4: (a) picking a framework AudioManager USB entry (as opposed to the separate USB-direct list) always persisted VID:PID 0:0 — nothing populated it for that path — so the fallback above had nothing to fall back to; fixed by looking up the matching raw UsbDevice by product name at pick time (AudioDeviceSpinnerAdapter.getUsbFrameworkDeviceProductName + UsbAudioDevice.matchByProductName) and persisting its VID:PID, in both the Compose picker and the legacy ConfigFragment. (b) Even with VID:PID persisted correctly, UsbDevice.getProductName() ("QMX Transceiver") never equals the AudioDeviceInfo list's name for the same card ("USB-Audio - QMX Transceiver") — the generic USB-Audio HAL decorates the raw name — so the match needed to be case-insensitive containment, not equality.

Risks

Medium. This changes the capture audio format and the restart/reinit lifecycle for every rig's RX path, not just the one it was diagnosed on (a Motorola Edge 2022, Android 15, QMX over USB Audio Class via MediaTek's audio HAL). PCM16 is the format Android's AudioRecord guarantees support for, and 24-bit-capable USB codecs losing precision to 16-bit is not meaningful for FT8/FT4 decoding — but this hasn't been smoke-tested against other USB audio interfaces (IC-7300/705, CM108/Digirig) or Bluetooth SCO mics, which is exactly what this PR's own manual test checklist (item 4) and QA pass should confirm before merge.

Tested automatically

  • MicRecorderInt16ToFloatTest (new): the PCM16→float conversion applied to every capture buffer — full-scale positive/negative, silence, and partial-length reads.
  • MicRecorderRestartDecisionTest (new): shouldRestartAfterReinitialize — restarts when a transient failure left isRunning false without stopRecord(); does not restart after stopRecord() itself cleared shouldBeRunning.
  • UsbAudioDeviceResolveIndexTest (new): resolveDeviceIndex — exact id match, product-name fallback when the id isn't found (incl. the real decorated-name mismatch), no match when there's nothing to fall back to, null product-name entries don't throw.
  • UsbAudioDeviceNameMatchTest (new): unambiguousNameMatchIndex (the pure decision behind matchByProductName) — single containment match, no match, ambiguous match (two candidates) refuses to guess, blank/null input, null entries in the candidate list.

Code review

QA evidence

(to be filled in by QA)

🤖 Generated with Claude Code

https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf

laniol8926 and others added 4 commits October 22, 2026 20:22
Picking a new input device in Settings persisted
GeneralVariables.audioInputDeviceId and audioInputVendorId/ProductId,
but nothing told the already-running MicRecorder to reopen with it --
MicRecorder only reads those fields at construction time
(openAudioRecord()'s setPreferredDevice() / openUsbAudioInput()).  So
the new selection silently had no effect on the live capture until the
next USB attach/detach or app restart happened to rebuild it.

Confirmed on real hardware (Edge 2022 + QMX): logcat showed
writeConfig persisting the new device id with no MicRecorder reinit or
preferredDevice line following it, and the running capture stayed on
audioInputDeviceId=0 (the phone's built-in mic) the whole session --
exactly the silent-no-op the user suspected.

Mirrors the RX-channel-change (onRxAudioChannelChanged) and
Bluetooth-SCO-connect (refreshBluetoothHeadsetMode) paths, which
already call reinitializeAudioInput() for the same reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
reinitialize() decided whether to restart capture by reading isRunning
-- but a transient AudioRecord creation failure (AudioFlinger
createRecord_l returning -1, observed in the field right after a
replug/power-cycle, before the audio HAL had caught up) sets isRunning
false as a side effect from the capture thread's own failure-detection
path, with no owner-driven stopRecord() ever happening. Every
reinitialize() after that read the now-stale isRunning as false and
silently skipped start() -- permanently, even once the operator picked
a completely valid device afterward (confirmed on real hardware:
setPreferredDevice() kept reporting ok=true/deviceFound=true, and
AudioFlinger genuinely created the record track, but start() was
simply never called again).

Fix: track the owner's actual intent in a new field, shouldBeRunning,
touched only by the two owner-facing entry points (start()/
stopRecord()) and left alone by transient internal failures.
reinitialize() now keys its end-of-method restart decision off that
instead of isRunning, and does its own internal stop/rebuild teardown
inline rather than through stopRecord() (which would otherwise clear
the very flag it needs to survive).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Confirmed on real hardware (QMX over USB Audio Class, MediaTek audio
HAL) that requesting ENCODING_PCM_FLOAT from a USB input silently
produces unusable samples on this HAL -- AudioALSAStreamIn's own log
line, "wrong format(0x5), default use wordSize = 2", shows it falling
back to a 16-bit word size internally while still being asked for
4-byte floats. Reproduced twice in two single app processes with
everything else held constant (same CAT mode, same device, same
session): the HAL occasionally renegotiates to 16-bit PCM across a USB
re-enumeration, and decoding started working immediately every time
that happened, then stopped immediately on the next reinit back to
float. This is what the whole night's RX investigation was actually
chasing -- not mode, not audio-device selection, not I/Q mode (the
radio decoded fine in Digi/MD6 with fmt=1 negotiated and no Q90; ever
sent, which also rules that fix out as the deciding factor here, even
though it's independently correct for the FT8TW#78 scenario).

MicRecorder now requests ENCODING_PCM_16BIT (which is what the
commented-out line already showed as the original format before an
earlier change switched to float) and converts to float in the reader
loop via the new int16ToFloat(), matching float2Short()'s existing
inverse scale in the TX path. Covered by a new unit test file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Trims the audioFormat field comment to the why in a few lines (per Copilot
review feedback on PR patrickrb#925, where this fix originated, carried over here
since it's the same underlying change).

Extracts reinitialize()'s restart check into
MicRecorder.shouldRestartAfterReinitialize(boolean), matching this file's
existing convention for pinning a load-bearing boolean decision with its
own test (captureLoopShouldContinue, shouldJoinCaptureThread), and adds
MicRecorderRestartDecisionTest covering both scenarios: restart after a
transient failure left isRunning false without stopRecord(), and no
restart after stopRecord() itself cleared shouldBeRunning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
@laniol8926

Copy link
Copy Markdown
Author

Couldn't self-apply the needs-device-test label from this fork (no permission on the upstream repo) — flagging it here instead: this touches USB-audio capture format and the input-device reinit lifecycle for every rig, so it needs real-hardware testing per AGENTS.md's "Device testing" criteria, not just the emulator.

@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 41.92%. Comparing base (f702100) to head (fd03b07).

Files with missing lines Patch % Lines
...dio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt 0.00% 27 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##                dev     #949      +/-   ##
============================================
+ Coverage     41.70%   41.92%   +0.22%     
  Complexity      252      252              
============================================
  Files           280      280              
  Lines         34002    34105     +103     
  Branches       4076     4088      +12     
============================================
+ Hits          14180    14299     +119     
+ Misses        19531    19494      -37     
- Partials        291      312      +21     
Flag Coverage Δ
android 18.18% <0.00%> (+0.51%) ⬆️
native 12.70% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...dio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt 0.25% <0.00%> (-0.01%) ⬇️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

laniol8926 and others added 2 commits October 22, 2026 20:47
persisted AudioDeviceInfo id goes stale

Confirmed on real hardware: power-cycling a QMX alone re-enumerated its USB
audio interface with a new AudioDeviceInfo id, reverting the Settings
input-device picker (and the actual MicRecorder/FT8TransmitSignal routing)
to "Default" even though the same physical device was still attached --
AudioDeviceInfo's numeric id is not guaranteed stable across a USB
reconnect, but its product name is, and the app already persists the
device's VID:PID whenever it's picked (just never used it as a fallback).

Added UsbAudioDevice.resolveDeviceIndex: exact id match first, else match
by the product name of the live USB device whose VID:PID equals what was
persisted. Wired into MicRecorder.findAudioDeviceById (input),
FT8TransmitSignal.findAudioDeviceById (output), and
AudioDeviceSpinnerAdapter.getPositionByDeviceId (the Settings picker's own
re-selection on screen open) -- all three previously only matched by exact
id, so a power-cycle silently fell back to Default in all of them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
persisted for framework picks, and exact name match fails on the HAL's
decorated name

Blocker 1: picking a framework AudioManager USB device (e.g. "USB-Audio -
QMX Transceiver") always persisted VID:PID 0:0 -- getUsbAudioDeviceInfo()
only returns non-null for the separate USB-direct list, so the picker's
`else if (deviceId != -1)` branch unconditionally zeroed it, leaving
resolveDeviceIndex's fallback with nothing to fall back to. Fixed by
looking up the raw UsbDevice by product-name match
(AudioDeviceSpinnerAdapter.getUsbFrameworkDeviceProductName +
UsbAudioDevice.matchByProductName) and persisting ITS VID:PID instead, in
both RadioAudioSettings.kt (Compose) and ConfigFragment.java (legacy).

Blocker 2: even with VID:PID persisted correctly, UsbDevice.getProductName()
("QMX Transceiver", confirmed via dumpsys usb) never equals the
AudioDeviceInfo list's name for the same card ("USB-Audio - QMX
Transceiver", confirmed via dumpsys audio) -- Android's generic USB-Audio
HAL decorates the raw descriptor name. resolveDeviceIndex's match (and the
new matchByProductName) now use case-insensitive containment instead of
equality.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant