From 857449f89184dac2fb9599b8507dbfd4b82fb8cd Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 04:25:40 +0000 Subject: [PATCH 1/6] Fix RX: reopen MicRecorder when the audio input device is changed 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 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt index e8de0b4c6..58ba742d7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt @@ -537,6 +537,14 @@ fun RadioAudioSettings( audioInputChannels = selectedDeviceMaxChannels( context, deviceId, AudioManager.GET_DEVICES_INPUTS, ) + // The live MicRecorder only reads audioInputDeviceId at construction + // time (openAudioRecord()'s setPreferredDevice / openUsbAudioInput()), + // so without this the newly-picked device is persisted but the running + // capture keeps using whatever it opened with — silently, with no error + // — until the next USB attach/detach or app restart happens to reopen + // it. Mirrors the RX-channel-change and SCO-connect paths, which already + // reopen for the same reason. + mainViewModel.reinitializeAudioInput() // Selecting a Bluetooth-SCO mic must actually bring SCO up (issue #723); // deselecting it releases SCO unless a Bluetooth rig still needs it. mainViewModel.refreshBluetoothHeadsetMode() From 698ce9a2b1fcfc4216e4f367f54c5000dfc1df89 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:11:29 +0000 Subject: [PATCH 2/6] Fix RX: a transient AudioRecord failure permanently stopped capture 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 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/wave/MicRecorder.java | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 3bbf877d6..d04ec9b70 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -45,6 +45,19 @@ public class MicRecorder { private boolean useUsbAudio = false; private volatile boolean isRunning = false;//whether currently in recording state + // Whether the OWNER (HamRecorder) wants capture running -- set only by + // start()/stopRecord(), the two owner-facing entry points. Deliberately + // separate from isRunning, which a failed AudioRecord also flips to false + // as a side effect (see startAudioRecordCapture()'s capture thread, and + // reinitialize() below): reinitialize() used to key its end-of-method + // restart decision off isRunning, so one transient createRecord_l failure + // (observed in the field right after a replug, before the audio HAL had + // caught up) permanently masked the owner's actual intent -- every later + // reinitialize() (e.g. the operator picking a perfectly valid device + // afterward) saw isRunning already false and silently skipped start(), + // leaving capture stopped for the rest of the session even though + // setPreferredDevice() kept reporting ok=true/deviceFound=true. + private volatile boolean shouldBeRunning = false; private OnDataListener onDataListener; // AudioRecord capture-thread handoff. reinitialize() can be called from @@ -430,6 +443,7 @@ public int chosenInputDeviceType() { } public synchronized void start(){ + shouldBeRunning = true; if (isRunning) return; if (useUsbAudio && usbAudioDevice != null) { @@ -669,9 +683,16 @@ public void run() { /** * Stop recording. When recording stops, all monitors in the listener list are removed. + * + *

Owner-facing: also clears {@link #shouldBeRunning}, so a later + * {@link #reinitialize()} (e.g. from a USB attach event) knows not to + * restart capture the operator explicitly stopped. {@link #reinitialize()} + * itself does the equivalent teardown inline rather than calling this, so + * its own internal stop/rebuild cycle never clears that intent flag. */ public synchronized void stopRecord() { isRunning = false; + shouldBeRunning = false; if (useUsbAudio && usbAudioDevice != null) { usbAudioDevice.stopCapture(); } @@ -693,14 +714,20 @@ public void setOnDataListener(OnDataListener onDataListener) { */ @SuppressLint("MissingPermission") public synchronized void reinitialize() { - boolean wasRunning = isRunning; OnDataListener savedListener = onDataListener; - // Supersede the running reader thread's generation, then stop capture. + // Supersede the running reader thread's generation, then stop capture + // inline (NOT via stopRecord(): that also clears shouldBeRunning, which + // must survive this method's own internal stop/rebuild cycle -- see + // shouldBeRunning's field comment for why keying off isRunning here + // used to permanently wedge capture after one transient failure). captureGeneration++; - stopRecord(); + isRunning = false; + if (useUsbAudio && usbAudioDevice != null) { + usbAudioDevice.stopCapture(); + } - // Release old AudioRecord (stopRecord only sets isRunning=false). The + // Release old AudioRecord (the above only sets isRunning=false). The // reader thread may still be inside a blocking read() on it: stop() // unblocks that read, and we must wait for the thread to exit before // release() — releasing mid-read is a fatal native abort @@ -771,9 +798,13 @@ public synchronized void reinitialize() { } } - // Restore listener and restart if it was running + // Restore listener and restart if the owner still wants capture + // running -- shouldBeRunning, NOT isRunning: a transient failure in + // the rebuild above (or an earlier reinitialize() call) may already + // have left isRunning false with no owner-driven stopRecord() ever + // happening, and that must not be read as "capture no longer wanted". onDataListener = savedListener; - if (wasRunning) { + if (shouldBeRunning) { start(); } } From f4d378332165a89910e8c45f1cecf60e5d3f54b0 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 05:46:51 +0000 Subject: [PATCH 3/6] Fix RX: capture PCM16 instead of float, convert in software 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 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/wave/MicRecorder.java | 48 ++++++++++++++- .../wave/MicRecorderInt16ToFloatTest.java | 58 +++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index d04ec9b70..a5a175e93 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -37,8 +37,22 @@ public class MicRecorder { // createAudioRecord(). Volatile because reinitialize() writes it from // another thread than the reader loop that snapshots it. private volatile int captureChannelCount = 1; - //private static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //quantization bit depth - private static final int audioFormat = AudioFormat.ENCODING_PCM_FLOAT; //quantization bit depth + // NOT ENCODING_PCM_FLOAT: confirmed on real hardware (2026-10-22, QMX + // over USB Audio Class, MediaTek audio HAL) that requesting float capture + // from a USB input silently produces unusable (effectively silent) + // 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 or device selection. Capture PCM16 instead and convert to float + // in the reader loop below (see startAudioRecordCapture()), matching + // what already reliably decodes on this hardware. + private static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //quantization bit depth private AudioRecord audioRecord = null;//AudioRecord object private UsbAudioDevice usbAudioDevice = null; // USB audio device @@ -583,6 +597,11 @@ private void startAudioRecordCapture() { // read(), native releaseBuffer abort from two threads on one instance). final AudioRecord rec = audioRecord; final int myGeneration = captureGeneration; + // Raw capture buffer: audioFormat is ENCODING_PCM_16BIT (see its field + // comment for why), so AudioRecord.read() fills a short[], converted + // to float[] below for the rest of the pipeline (decode/waterfall) + // unchanged. + short[] rawBuffer = new short[bufferSize]; float[] buffer = new float[bufferSize]; // Snapshotted with `rec`: the fold below must match the channel count of // the AudioRecord this thread owns, not whatever a later reinit opened. @@ -615,7 +634,11 @@ public void run() { } //read recording data - int bufferReadResult = rec.read(buffer, 0, buffer.length,AudioRecord.READ_BLOCKING); + int bufferReadResult = rec.read(rawBuffer, 0, rawBuffer.length, AudioRecord.READ_BLOCKING); + + if (bufferReadResult > 0) { + int16ToFloat(rawBuffer, bufferReadResult, buffer); + } if (bufferReadResult < 0) { // Error codes (ERROR_INVALID_OPERATION=-3, ERROR_BAD_VALUE=-2, @@ -903,6 +926,25 @@ private static String audioSourceName(int source) { return String.valueOf(source); } + /** + * Convert {@code length} 16-bit PCM samples to float, filling {@code dest} + * in place from index 0 -- the caller's hot-path buffer, reused across + * reads rather than allocated per call. Matches {@code float2Short()}'s + * inverse scale elsewhere in the app (the TX path, {@code + * FT8TransmitSignal.java}): full-scale short (+/-32767) maps to +/-1.0f. + * + *

Package-visible for testing. + * + * @param src source samples; must be at least {@code length} long + * @param length number of samples to convert + * @param dest destination; must be at least {@code length} long + */ + static void int16ToFloat(short[] src, int length, float[] dest) { + for (int i = 0; i < length; i++) { + dest[i] = src[i] / 32767.0f; + } + } + /** * Returns a safe buffer size for AudioRecord. If {@code rawSize} is an * error value ({@code <= 0}), returns {@link #FALLBACK_BUFFER_SIZE}. diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java new file mode 100644 index 000000000..9047a1010 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderInt16ToFloatTest.java @@ -0,0 +1,58 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests {@link MicRecorder#int16ToFloat} — the PCM16-to-float conversion the + * capture reader loop applies to every buffer now that {@code MicRecorder} + * requests {@code ENCODING_PCM_16BIT} instead of {@code ENCODING_PCM_FLOAT} + * (see that field's own comment for the real-hardware reason: requesting + * float capture from a USB input silently produced unusable samples on the + * MediaTek audio HAL, confirmed by reproducing the fix/break twice in a + * single app process with everything else held constant). + */ +public class MicRecorderInt16ToFloatTest { + + @Test + public void convertsFullScalePositive() { + short[] src = {32767}; + float[] dest = new float[1]; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(1.0f); + } + + @Test + public void convertsFullScaleNegative() { + // -32768 is short's true minimum, but float2Short()'s own inverse + // scale (elsewhere in the app, the TX path) uses 32767.0 as the + // divisor for both directions, so -32768 lands just past -1.0f — + // matching that existing convention rather than a separate one here. + short[] src = {-32767}; + float[] dest = new float[1]; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(-1.0f); + } + + @Test + public void convertsSilence() { + short[] src = {0}; + float[] dest = new float[1]; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(0.0f); + } + + @Test + public void onlyConvertsUpToLength_leavesRestOfDestUntouched() { + // The reader loop passes bufferReadResult, not the full buffer length + // (a short/partial read is normal) — dest entries past that count + // must be left alone, not zeroed or garbage-filled. + short[] src = {32767, 32767, 32767}; + float[] dest = {-9f, -9f, -9f}; + MicRecorder.int16ToFloat(src, 1, dest); + assertThat(dest[0]).isEqualTo(1.0f); + assertThat(dest[1]).isEqualTo(-9f); + assertThat(dest[2]).isEqualTo(-9f); + } +} From fdd577e310ca2c4426bcbdf6b6e2c2f1f3613a21 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 20:24:16 +0000 Subject: [PATCH 4/6] Trim debugging-narrative comments; add test for the restart decision Trims the audioFormat field comment to the why in a few lines (per Copilot review feedback on PR #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 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../java/com/k1af/ft8af/wave/MicRecorder.java | 41 +++++++++---------- .../wave/MicRecorderRestartDecisionTest.java | 31 ++++++++++++++ 2 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index a5a175e93..7272f32d2 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -37,21 +37,11 @@ public class MicRecorder { // createAudioRecord(). Volatile because reinitialize() writes it from // another thread than the reader loop that snapshots it. private volatile int captureChannelCount = 1; - // NOT ENCODING_PCM_FLOAT: confirmed on real hardware (2026-10-22, QMX - // over USB Audio Class, MediaTek audio HAL) that requesting float capture - // from a USB input silently produces unusable (effectively silent) - // 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 or device selection. Capture PCM16 instead and convert to float - // in the reader loop below (see startAudioRecordCapture()), matching - // what already reliably decodes on this hardware. + // NOT ENCODING_PCM_FLOAT: on this phone's MediaTek USB audio HAL, + // requesting float capture from a USB input silently produces unusable + // samples (the HAL's own log shows it falling back to a 16-bit word size + // internally while still being asked for 4-byte floats). Capture PCM16 + // and convert to float in software instead (see startAudioRecordCapture()). private static final int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //quantization bit depth private AudioRecord audioRecord = null;//AudioRecord object @@ -821,17 +811,26 @@ public synchronized void reinitialize() { } } - // Restore listener and restart if the owner still wants capture - // running -- shouldBeRunning, NOT isRunning: a transient failure in - // the rebuild above (or an earlier reinitialize() call) may already - // have left isRunning false with no owner-driven stopRecord() ever - // happening, and that must not be read as "capture no longer wanted". onDataListener = savedListener; - if (shouldBeRunning) { + if (shouldRestartAfterReinitialize(shouldBeRunning)) { start(); } } + /** + * Whether {@link #reinitialize()} should restart capture once it has + * rebuilt the audio input. Keyed on {@code shouldBeRunning} (owner + * intent, touched only by {@link #start()}/{@link #stopRecord()}), NOT + * {@code isRunning} -- a transient failure in the rebuild above (or an + * earlier {@link #reinitialize()} call) can leave {@code isRunning} false + * with no owner-driven {@link #stopRecord()} ever happening, and that + * must not be read as "capture no longer wanted" (the bug fixed by + * introducing {@link #shouldBeRunning} in the first place). + */ + static boolean shouldRestartAfterReinitialize(boolean shouldBeRunning) { + return shouldBeRunning; + } + /** * Wait for the AudioRecord reader thread to exit before its AudioRecord is * released. Bounded by {@link #CAPTURE_JOIN_TIMEOUT_MS} so a thread stuck diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java new file mode 100644 index 000000000..64bbadd91 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/MicRecorderRestartDecisionTest.java @@ -0,0 +1,31 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests {@link MicRecorder#shouldRestartAfterReinitialize} -- the restart + * decision behind the {@code shouldBeRunning} fix (a transient + * {@code AudioRecord} creation failure right after a replug/power-cycle used + * to permanently wedge capture, because {@link MicRecorder#reinitialize()} + * used to key its restart decision off {@code isRunning}, which that failure + * also flips to false with no owner action). + */ +public class MicRecorderRestartDecisionTest { + + @Test + public void restarts_afterTransientFailureLeftIsRunningFalse() { + // start() set shouldBeRunning=true; a transient AudioRecord failure + // (not stopRecord()) later set isRunning=false as a side effect, but + // shouldBeRunning is untouched -- reinitialize() must still restart. + assertThat(MicRecorder.shouldRestartAfterReinitialize(true)).isTrue(); + } + + @Test + public void doesNotRestart_afterOwnerCalledStopRecord() { + // stopRecord() clears shouldBeRunning -- reinitialize() (e.g. from a + // later USB attach event) must not restart capture the operator stopped. + assertThat(MicRecorder.shouldRestartAfterReinitialize(false)).isFalse(); + } +} From d5880af9b53840c581594d6ab3a8ba508132cdd4 Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 20:47:21 +0000 Subject: [PATCH 5/6] Fix: fall back to matching USB audio devices by product name when the 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 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../ft8af/ft8transmit/FT8TransmitSignal.java | 25 ++++++-- .../ft8af/ui/AudioDeviceSpinnerAdapter.java | 39 +++++++++-- .../java/com/k1af/ft8af/wave/MicRecorder.java | 22 +++++-- .../com/k1af/ft8af/wave/UsbAudioDevice.java | 40 ++++++++++++ .../wave/UsbAudioDeviceResolveIndexTest.java | 64 +++++++++++++++++++ 5 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 5be5ee0aa..8cd9d4615 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -3404,12 +3404,25 @@ private static AudioDeviceInfo findAudioDeviceById(int deviceId, int deviceType) AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); if (audioManager == null) return null; AudioDeviceInfo[] devices = audioManager.getDevices(deviceType); - for (AudioDeviceInfo device : devices) { - if (device.getId() == deviceId) { - return device; - } - } - return null; + int[] ids = new int[devices.length]; + String[] names = new String[devices.length]; + for (int i = 0; i < devices.length; i++) { + ids[i] = devices[i].getId(); + CharSequence name = devices[i].getProductName(); + names[i] = name == null ? null : name.toString(); + } + // Falls back to matching by the persisted output VID:PID (via its + // current product name) when the exact id isn't found -- a USB + // device's numeric AudioDeviceInfo id is not stable across a + // replug/power-cycle re-enumeration. See + // UsbAudioDevice.resolveDeviceIndex. + UsbDevice fallback = GeneralVariables.usbAudioOutputVendorId == 0 ? null + : UsbAudioDevice.findDeviceByVidPid(context, + GeneralVariables.usbAudioOutputVendorId, + GeneralVariables.usbAudioOutputProductId); + int idx = UsbAudioDevice.resolveDeviceIndex(ids, names, deviceId, + fallback == null ? null : fallback.getProductName()); + return idx < 0 ? null : devices[idx]; } /** diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java index a9897c28c..3c1bcebea 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java @@ -134,18 +134,28 @@ public UsbAudioDevice.UsbAudioDeviceInfo getUsbAudioDeviceInfo(int position) { /** * Get the list position for a given device ID. * deviceId == 0 → 0 (Default) - * deviceId > 0 → search AudioManager devices + * deviceId > 0 → search AudioManager devices by id; if not found (stale + * across a USB replug/power-cycle re-enumeration -- confirmed on real + * hardware: a QMX power-cycle alone changed its id and reverted this + * picker to "Default"), fall back to matching by the persisted + * VID:PID's current product name, which doesn't change across + * reconnects. See {@link UsbAudioDevice#resolveDeviceIndex}. * deviceId == -1 → search USB audio devices by VID:PID */ public int getPositionByDeviceId(int deviceId) { if (deviceId == 0) return 0; if (deviceId > 0) { + int[] ids = new int[audioDeviceList.size()]; + String[] names = new String[audioDeviceList.size()]; for (int i = 0; i < audioDeviceList.size(); i++) { - if (audioDeviceList.get(i).getId() == deviceId) { - return i + 1; - } + ids[i] = audioDeviceList.get(i).getId(); + CharSequence name = audioDeviceList.get(i).getProductName(); + names[i] = name == null ? null : name.toString(); } + int idx = UsbAudioDevice.resolveDeviceIndex(ids, names, deviceId, + persistedVidPidProductName()); + if (idx >= 0) return idx + 1; } // For USB audio (deviceId == -1), match by VID:PID @@ -172,6 +182,27 @@ public int getPositionByDeviceId(int deviceId) { return 0; // fallback to Default } + /** + * Product name of the already-enumerated {@link #usbAudioDeviceList} + * entry matching this direction's persisted VID:PID, or null if there's + * nothing persisted (VID 0) or no current match. + */ + private String persistedVidPidProductName() { + int vid = isInput + ? com.k1af.ft8af.GeneralVariables.usbAudioInputVendorId + : com.k1af.ft8af.GeneralVariables.usbAudioOutputVendorId; + if (vid == 0) return null; + int pid = isInput + ? com.k1af.ft8af.GeneralVariables.usbAudioInputProductId + : com.k1af.ft8af.GeneralVariables.usbAudioOutputProductId; + for (UsbAudioDevice.UsbAudioDeviceInfo info : usbAudioDeviceList) { + if (info.device.getVendorId() == vid && info.device.getProductId() == pid) { + return info.device.getProductName(); + } + } + return null; + } + @SuppressLint({"ViewHolder", "InflateParams"}) @Override public View getView(int position, View view, ViewGroup viewGroup) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 7272f32d2..6dac1b88a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -371,7 +371,9 @@ private AudioRecord openAudioRecord(int audioSource, int channelMask) { } /** - * Find AudioDeviceInfo by device ID + * Find AudioDeviceInfo by device ID. Falls back to matching by the + * persisted input VID:PID (via its current product name) when the exact + * id isn't found -- see {@link UsbAudioDevice#resolveDeviceIndex}. */ private AudioDeviceInfo findAudioDeviceById(int deviceId, int deviceType) { Context context = GeneralVariables.getMainContext(); @@ -379,12 +381,20 @@ private AudioDeviceInfo findAudioDeviceById(int deviceId, int deviceType) { AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); if (audioManager == null) return null; AudioDeviceInfo[] devices = audioManager.getDevices(deviceType); - for (AudioDeviceInfo device : devices) { - if (device.getId() == deviceId) { - return device; - } + int[] ids = new int[devices.length]; + String[] names = new String[devices.length]; + for (int i = 0; i < devices.length; i++) { + ids[i] = devices[i].getId(); + CharSequence name = devices[i].getProductName(); + names[i] = name == null ? null : name.toString(); } - return null; + UsbDevice fallback = GeneralVariables.usbAudioInputVendorId == 0 ? null + : UsbAudioDevice.findDeviceByVidPid(context, + GeneralVariables.usbAudioInputVendorId, + GeneralVariables.usbAudioInputProductId); + int idx = UsbAudioDevice.resolveDeviceIndex(ids, names, deviceId, + fallback == null ? null : fallback.getProductName()); + return idx < 0 ? null : devices[idx]; } /** diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java index e878e98e7..5323f6459 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java @@ -184,6 +184,46 @@ public static UsbDevice findDeviceByVidPid(Context context, int vendorId, int pr return null; } + /** + * Resolves a possibly-stale {@code AudioManager} device id against the + * current device list: an exact match on {@code ids} if present, + * otherwise the first entry whose {@code productNames} equals {@code + * fallbackProductName}. A USB device's numeric {@code AudioDeviceInfo} id + * is NOT stable across a replug/power-cycle re-enumeration (confirmed on + * real hardware: a QMX power-cycle alone changed it, reverting the + * Settings audio-device picker to "Default"), but its product name is -- + * callers derive {@code fallbackProductName} from the persisted VID:PID + * via the live USB device list (e.g. {@link #findDeviceByVidPid}'s + * {@code getProductName()}), which doesn't change across reconnects + * either. + * + *

Pure/plain-array so it is unit-testable without Android types: + * {@code AudioDeviceInfo} has no public constructor, so a caller passing + * real {@code AudioDeviceInfo[]} would make this logic untestable outside + * Robolectric. + * + * @param ids device ids, in list order + * @param productNames product names, same order as {@code ids} + * (entries may be null) + * @param deviceId the persisted device id to resolve + * @param fallbackProductName product name to fall back to matching by, + * or null if there is nothing to fall back to + * @return the matching index into {@code ids}/{@code productNames}, or + * -1 if nothing matched + */ + public static int resolveDeviceIndex(int[] ids, String[] productNames, int deviceId, + String fallbackProductName) { + for (int i = 0; i < ids.length; i++) { + if (ids[i] == deviceId) return i; + } + if (fallbackProductName != null) { + for (int i = 0; i < productNames.length; i++) { + if (fallbackProductName.equals(productNames[i])) return i; + } + } + return -1; + } + /** * Open the USB audio device and discover audio endpoints. */ diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java new file mode 100644 index 000000000..2731d3015 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java @@ -0,0 +1,64 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests {@link UsbAudioDevice#resolveDeviceIndex} -- the stale-id fallback + * behind a real-hardware bug: a QMX power-cycle alone re-enumerated its USB + * audio interface with a new {@code AudioDeviceInfo} id, reverting the + * Settings audio-device picker (and the actual {@code AudioRecord}/{@code + * AudioTrack} routing) to "Default" even though the same physical device + * was still attached under its stable product name. + */ +public class UsbAudioDeviceResolveIndexTest { + + @Test + public void exactIdMatch_returnsThatIndex() { + int[] ids = {5, 7, 9}; + String[] names = {"A", "B", "C"}; + assertThat(UsbAudioDevice.resolveDeviceIndex(ids, names, 7, "unused")).isEqualTo(1); + } + + @Test + public void noExactMatch_fallsBackToProductName() { + // The persisted id (1285) is stale after a re-enumeration; the device is + // still present under the same product name at a new id (1340). + int[] ids = {1340, 42}; + String[] names = {"QMX Transceiver", "Built-in Mic"}; + assertThat(UsbAudioDevice.resolveDeviceIndex(ids, names, 1285, "QMX Transceiver")) + .isEqualTo(0); + } + + @Test + public void noExactMatch_noFallbackName_returnsNegativeOne() { + int[] ids = {1340}; + String[] names = {"QMX Transceiver"}; + assertThat(UsbAudioDevice.resolveDeviceIndex(ids, names, 1285, null)).isEqualTo(-1); + } + + @Test + public void noExactMatch_fallbackNameNotPresent_returnsNegativeOne() { + int[] ids = {42}; + String[] names = {"Built-in Mic"}; + assertThat(UsbAudioDevice.resolveDeviceIndex(ids, names, 1285, "QMX Transceiver")) + .isEqualTo(-1); + } + + @Test + public void nullProductNameEntries_doNotThrow() { + // AudioDeviceInfo.getProductName() can legitimately return null. + int[] ids = {1, 2}; + String[] names = {null, "QMX Transceiver"}; + assertThat(UsbAudioDevice.resolveDeviceIndex(ids, names, 99, "QMX Transceiver")) + .isEqualTo(1); + assertThat(UsbAudioDevice.resolveDeviceIndex(ids, names, 99, null)).isEqualTo(-1); + } + + @Test + public void emptyLists_returnNegativeOne() { + assertThat(UsbAudioDevice.resolveDeviceIndex(new int[0], new String[0], 5, "anything")) + .isEqualTo(-1); + } +} From fd03b07cb2f5ddacbe5272eeff3041ee4e98005e Mon Sep 17 00:00:00 2001 From: quadra Date: Thu, 22 Oct 2026 20:54:24 +0000 Subject: [PATCH 6/6] Fix the two real blockers found via on-phone dumpsys: VID:PID never 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 Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf --- .../ft8af/ui/AudioDeviceSpinnerAdapter.java | 32 ++++++++ .../com/k1af/ft8af/ui/ConfigFragment.java | 52 ++++++++++--- .../com/k1af/ft8af/wave/UsbAudioDevice.java | 78 ++++++++++++++++--- .../ft8af/ui/settings/RadioAudioSettings.kt | 53 +++++++++++-- .../wave/UsbAudioDeviceNameMatchTest.java | 56 +++++++++++++ .../wave/UsbAudioDeviceResolveIndexTest.java | 13 ++++ 6 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceNameMatchTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java index 3c1bcebea..c014a8eab 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/AudioDeviceSpinnerAdapter.java @@ -131,6 +131,38 @@ public UsbAudioDevice.UsbAudioDeviceInfo getUsbAudioDeviceInfo(int position) { return null; } + /** + * The {@code AudioManager}-recognized device's own product name at this + * position, when it's a USB-type entry (confirmed on real hardware: the + * QMX shows up this way as "USB-Audio - QMX Transceiver", + * TYPE_USB_DEVICE). Used by the picker to find and persist that device's + * VID:PID via {@link UsbAudioDevice#matchByProductName}, since {@code + * AudioDeviceInfo} itself exposes no VID:PID -- without this, every pick + * of a framework-routed USB device persisted VID 0 (the {@link + * #getUsbAudioDeviceInfo} path only covers USB-direct entries), so the + * VID:PID fallback in {@link #getPositionByDeviceId} (and in {@code + * MicRecorder}/{@code FT8TransmitSignal}) had nothing to fall back to. + * + * @return the product name, or null for the "Default" row, a non-USB + * device, or a USB-direct entry (which already carries its own + * VID:PID via {@link #getUsbAudioDeviceInfo}) + */ + public String getUsbFrameworkDeviceProductName(int position) { + if (position <= 0) return null; + int adPos = position - 1; + if (adPos >= audioDeviceList.size()) return null; // USB-direct entry + AudioDeviceInfo device = audioDeviceList.get(adPos); + if (!isUsbType(device.getType())) return null; + CharSequence name = device.getProductName(); + return name == null ? null : name.toString(); + } + + private static boolean isUsbType(int type) { + return type == AudioDeviceInfo.TYPE_USB_DEVICE + || type == AudioDeviceInfo.TYPE_USB_HEADSET + || type == AudioDeviceInfo.TYPE_USB_ACCESSORY; + } + /** * Get the list position for a given device ID. * deviceId == 0 → 0 (Default) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java index c428eafca..f7f4d235a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ConfigFragment.java @@ -906,11 +906,31 @@ public void onItemSelected(AdapterView adapterView, View view, int i, long l) String.valueOf(GeneralVariables.usbAudioInputProductId)); requestUsbPermissionIfNeeded(usbInfo.device); } else if (deviceId != -1) { - // Clear USB audio settings if non-USB device selected - GeneralVariables.usbAudioInputVendorId = 0; - GeneralVariables.usbAudioInputProductId = 0; - writeConfig("usbAudioInputVid", "0"); - writeConfig("usbAudioInputPid", "0"); + // A framework-routed USB device (e.g. "USB-Audio - + // QMX Transceiver") carries no VID:PID of its own -- + // find the raw UsbDevice by product-name overlap and + // persist ITS VID:PID, so a later re-enumeration + // (which changes the numeric id but not the product + // name) can still resolve it instead of falling back + // to Default. See AudioDeviceSpinnerAdapter's + // getUsbFrameworkDeviceProductName javadoc. + String productName = + audioInputDeviceAdapter.getUsbFrameworkDeviceProductName(i); + UsbAudioDevice.UsbAudioDeviceInfo rawUsb = productName == null ? null + : UsbAudioDevice.matchByProductName( + UsbAudioDevice.findUsbAudioDevices(requireContext()), + productName, true); + if (rawUsb != null) { + GeneralVariables.usbAudioInputVendorId = rawUsb.device.getVendorId(); + GeneralVariables.usbAudioInputProductId = rawUsb.device.getProductId(); + } else { + GeneralVariables.usbAudioInputVendorId = 0; + GeneralVariables.usbAudioInputProductId = 0; + } + writeConfig("usbAudioInputVid", + String.valueOf(GeneralVariables.usbAudioInputVendorId)); + writeConfig("usbAudioInputPid", + String.valueOf(GeneralVariables.usbAudioInputProductId)); } } @@ -939,10 +959,24 @@ public void onItemSelected(AdapterView adapterView, View view, int i, long l) String.valueOf(GeneralVariables.usbAudioOutputProductId)); requestUsbPermissionIfNeeded(usbInfo.device); } else if (deviceId != -1) { - GeneralVariables.usbAudioOutputVendorId = 0; - GeneralVariables.usbAudioOutputProductId = 0; - writeConfig("usbAudioOutputVid", "0"); - writeConfig("usbAudioOutputPid", "0"); + // Same reasoning as the input picker above. + String productName = + audioOutputDeviceAdapter.getUsbFrameworkDeviceProductName(i); + UsbAudioDevice.UsbAudioDeviceInfo rawUsb = productName == null ? null + : UsbAudioDevice.matchByProductName( + UsbAudioDevice.findUsbAudioDevices(requireContext()), + productName, false); + if (rawUsb != null) { + GeneralVariables.usbAudioOutputVendorId = rawUsb.device.getVendorId(); + GeneralVariables.usbAudioOutputProductId = rawUsb.device.getProductId(); + } else { + GeneralVariables.usbAudioOutputVendorId = 0; + GeneralVariables.usbAudioOutputProductId = 0; + } + writeConfig("usbAudioOutputVid", + String.valueOf(GeneralVariables.usbAudioOutputVendorId)); + writeConfig("usbAudioOutputPid", + String.valueOf(GeneralVariables.usbAudioOutputProductId)); } } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java index 5323f6459..eed82e5b5 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java @@ -187,15 +187,22 @@ public static UsbDevice findDeviceByVidPid(Context context, int vendorId, int pr /** * Resolves a possibly-stale {@code AudioManager} device id against the * current device list: an exact match on {@code ids} if present, - * otherwise the first entry whose {@code productNames} equals {@code - * fallbackProductName}. A USB device's numeric {@code AudioDeviceInfo} id - * is NOT stable across a replug/power-cycle re-enumeration (confirmed on - * real hardware: a QMX power-cycle alone changed it, reverting the - * Settings audio-device picker to "Default"), but its product name is -- - * callers derive {@code fallbackProductName} from the persisted VID:PID - * via the live USB device list (e.g. {@link #findDeviceByVidPid}'s - * {@code getProductName()}), which doesn't change across reconnects - * either. + * otherwise the first entry whose {@code productNames} contains (or is + * contained by -- see {@link #namesOverlap}) {@code fallbackProductName}. + * A USB device's numeric {@code AudioDeviceInfo} id is NOT stable across + * a replug/power-cycle re-enumeration (confirmed on real hardware: a QMX + * power-cycle alone changed it, reverting the Settings audio-device + * picker to "Default"), but its product name is -- callers derive {@code + * fallbackProductName} from the persisted VID:PID via the live USB + * device list (e.g. {@link #findDeviceByVidPid}'s {@code + * getProductName()}), which doesn't change across reconnects either. + * Containment, not equality: confirmed on real hardware that Android's + * generic USB-Audio HAL decorates the raw USB descriptor's product name + * ("QMX Transceiver", what {@code UsbDevice.getProductName()} and this + * method's {@code fallbackProductName} carry) with a prefix for the + * {@code AudioDeviceInfo} it exposes ("USB-Audio - QMX Transceiver", + * what {@code productNames} carries here) -- the two are never + * byte-identical. * *

Pure/plain-array so it is unit-testable without Android types: * {@code AudioDeviceInfo} has no public constructor, so a caller passing @@ -218,12 +225,63 @@ public static int resolveDeviceIndex(int[] ids, String[] productNames, int devic } if (fallbackProductName != null) { for (int i = 0; i < productNames.length; i++) { - if (fallbackProductName.equals(productNames[i])) return i; + if (namesOverlap(productNames[i], fallbackProductName)) return i; } } return -1; } + /** + * Finds the raw USB device (from a fresh {@link #findUsbAudioDevices} + * list) whose descriptor is the underlying hardware for a picked + * {@code AudioManager} device, by product-name overlap (see {@link + * #namesOverlap}) in the given direction. Used to persist a VID:PID for + * a framework-routed pick (Settings), since {@code AudioDeviceInfo} + * itself exposes no VID:PID. Returns null if {@code audioDeviceProductName} + * is null/blank, or if zero or more than one raw device matches -- never + * guess between two physically attached USB audio devices. + */ + public static UsbAudioDeviceInfo matchByProductName( + List usbDevices, String audioDeviceProductName, boolean input) { + List eligible = new ArrayList<>(); + for (UsbAudioDeviceInfo info : usbDevices) { + if (input ? info.hasInput : info.hasOutput) eligible.add(info); + } + String[] names = new String[eligible.size()]; + for (int i = 0; i < eligible.size(); i++) { + names[i] = eligible.get(i).device.getProductName(); + } + int idx = unambiguousNameMatchIndex(names, audioDeviceProductName); + return idx < 0 ? null : eligible.get(idx); + } + + /** + * Index of the single entry in {@code productNames} that overlaps + * {@code audioDeviceProductName} (see {@link #namesOverlap}), or -1 if + * zero or more than one match -- never guess between two candidates. + * Pure/plain-array (see {@link #resolveDeviceIndex}'s javadoc for why). + */ + static int unambiguousNameMatchIndex(String[] productNames, String audioDeviceProductName) { + if (audioDeviceProductName == null || audioDeviceProductName.trim().isEmpty()) return -1; + int match = -1; + for (int i = 0; i < productNames.length; i++) { + if (namesOverlap(audioDeviceProductName, productNames[i])) { + if (match != -1) return -1; // ambiguous: more than one match + match = i; + } + } + return match; + } + + /** Case-insensitive substring match, either direction; false if either side is blank. */ + private static boolean namesOverlap(String a, String b) { + if (a == null || b == null) return false; + String na = a.trim().toLowerCase(java.util.Locale.US); + String nb = b.trim().toLowerCase(java.util.Locale.US); + if (na.isEmpty() || nb.isEmpty()) return false; + return na.contains(nb) || nb.contains(na); + } + /** * Open the USB audio device and discover audio endpoints. */ diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt index 58ba742d7..4a5c193f0 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt @@ -528,10 +528,31 @@ fun RadioAudioSettings( ) mainViewModel.requestUsbPermissionIfNeeded(usbInfo.device) } else if (deviceId != -1) { - GeneralVariables.usbAudioInputVendorId = 0 - GeneralVariables.usbAudioInputProductId = 0 - mainViewModel.databaseOpr.writeConfig("usbAudioInputVid", "0", null) - mainViewModel.databaseOpr.writeConfig("usbAudioInputPid", "0", null) + // A framework-routed USB device (e.g. "USB-Audio - QMX + // Transceiver") carries no VID:PID of its own -- find the + // raw UsbDevice by product-name overlap and persist ITS + // VID:PID, so a later re-enumeration (which changes the + // numeric id but not the product name) can still resolve + // it instead of falling back to Default. + val productName = audioInputAdapter.getUsbFrameworkDeviceProductName(position) + val rawUsb = productName?.let { + UsbAudioDevice.matchByProductName( + UsbAudioDevice.findUsbAudioDevices(context), it, true, + ) + } + if (rawUsb != null) { + GeneralVariables.usbAudioInputVendorId = rawUsb.device.vendorId + GeneralVariables.usbAudioInputProductId = rawUsb.device.productId + } else { + GeneralVariables.usbAudioInputVendorId = 0 + GeneralVariables.usbAudioInputProductId = 0 + } + mainViewModel.databaseOpr.writeConfig( + "usbAudioInputVid", GeneralVariables.usbAudioInputVendorId.toString(), null, + ) + mainViewModel.databaseOpr.writeConfig( + "usbAudioInputPid", GeneralVariables.usbAudioInputProductId.toString(), null, + ) } audioInputName = audioInputAdapter.getDeviceDisplayName(position) audioInputChannels = selectedDeviceMaxChannels( @@ -577,10 +598,26 @@ fun RadioAudioSettings( ) mainViewModel.requestUsbPermissionIfNeeded(usbInfo.device) } else if (deviceId != -1) { - GeneralVariables.usbAudioOutputVendorId = 0 - GeneralVariables.usbAudioOutputProductId = 0 - mainViewModel.databaseOpr.writeConfig("usbAudioOutputVid", "0", null) - mainViewModel.databaseOpr.writeConfig("usbAudioOutputPid", "0", null) + // Same reasoning as the input picker above. + val productName = audioOutputAdapter.getUsbFrameworkDeviceProductName(position) + val rawUsb = productName?.let { + UsbAudioDevice.matchByProductName( + UsbAudioDevice.findUsbAudioDevices(context), it, false, + ) + } + if (rawUsb != null) { + GeneralVariables.usbAudioOutputVendorId = rawUsb.device.vendorId + GeneralVariables.usbAudioOutputProductId = rawUsb.device.productId + } else { + GeneralVariables.usbAudioOutputVendorId = 0 + GeneralVariables.usbAudioOutputProductId = 0 + } + mainViewModel.databaseOpr.writeConfig( + "usbAudioOutputVid", GeneralVariables.usbAudioOutputVendorId.toString(), null, + ) + mainViewModel.databaseOpr.writeConfig( + "usbAudioOutputPid", GeneralVariables.usbAudioOutputProductId.toString(), null, + ) } audioOutputName = audioOutputAdapter.getDeviceDisplayName(position) audioOutputChannels = selectedDeviceMaxChannels( diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceNameMatchTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceNameMatchTest.java new file mode 100644 index 000000000..d95c43029 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceNameMatchTest.java @@ -0,0 +1,56 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests {@link UsbAudioDevice#unambiguousNameMatchIndex} -- the pure decision + * behind {@link UsbAudioDevice#matchByProductName}, used to persist a + * VID:PID for a framework-routed USB audio pick in Settings (confirmed on + * real hardware: picking "USB-Audio - QMX Transceiver" from the AudioManager + * list persisted VID:PID 0:0, since {@code AudioDeviceInfo} carries no + * VID:PID of its own and the picker only read it off the USB-direct list). + */ +public class UsbAudioDeviceNameMatchTest { + + @Test + public void singleMatch_byContainment_returnsItsIndex() { + // The real-hardware mismatch: the AudioManager pick's name ("USB-Audio - + // QMX Transceiver") is what's passed in here; the raw UsbDevice list + // carries the shorter undecorated name ("QMX Transceiver"). + String[] names = {"QMX Transceiver", "CM108 Audio"}; + assertThat(UsbAudioDevice.unambiguousNameMatchIndex(names, "USB-Audio - QMX Transceiver")) + .isEqualTo(0); + } + + @Test + public void noMatch_returnsNegativeOne() { + String[] names = {"CM108 Audio"}; + assertThat(UsbAudioDevice.unambiguousNameMatchIndex(names, "USB-Audio - QMX Transceiver")) + .isEqualTo(-1); + } + + @Test + public void ambiguousMatch_returnsNegativeOne() { + // Two physically attached USB audio devices that could both match -- + // never guess which one the operator actually picked. + String[] names = {"QMX Transceiver", "QMX Transceiver"}; + assertThat(UsbAudioDevice.unambiguousNameMatchIndex(names, "USB-Audio - QMX Transceiver")) + .isEqualTo(-1); + } + + @Test + public void blankOrNullAudioDeviceProductName_returnsNegativeOne() { + String[] names = {"QMX Transceiver"}; + assertThat(UsbAudioDevice.unambiguousNameMatchIndex(names, null)).isEqualTo(-1); + assertThat(UsbAudioDevice.unambiguousNameMatchIndex(names, " ")).isEqualTo(-1); + } + + @Test + public void nullEntriesInCandidateList_doNotThrow() { + String[] names = {null, "QMX Transceiver"}; + assertThat(UsbAudioDevice.unambiguousNameMatchIndex(names, "USB-Audio - QMX Transceiver")) + .isEqualTo(1); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java index 2731d3015..1a0dfe7b6 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java @@ -46,6 +46,19 @@ public void noExactMatch_fallbackNameNotPresent_returnsNegativeOne() { .isEqualTo(-1); } + @Test + public void fallsBackByContainment_whenAudioManagerDecoratesTheRawProductName() { + // The exact real-hardware mismatch found via dumpsys: UsbDevice.getProductName() + // gives "QMX Transceiver" (what callers persist/pass as fallbackProductName), but + // the AudioManager/AudioDeviceInfo list name for the same card is prefixed: + // "USB-Audio - QMX Transceiver". These are never byte-identical, so matching must + // be containment, not equality. + int[] ids = {1340}; + String[] names = {"USB-Audio - QMX Transceiver"}; + assertThat(UsbAudioDevice.resolveDeviceIndex(ids, names, 1285, "QMX Transceiver")) + .isEqualTo(0); + } + @Test public void nullProductNameEntries_doNotThrow() { // AudioDeviceInfo.getProductName() can legitimately return null.