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..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,21 +131,63 @@ 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) - * 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 +214,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/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/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 3bbf877d6..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 @@ -37,14 +37,31 @@ 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: 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 private UsbAudioDevice usbAudioDevice = null; // USB audio device 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 @@ -354,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(); @@ -362,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]; } /** @@ -430,6 +457,7 @@ public int chosenInputDeviceType() { } public synchronized void start(){ + shouldBeRunning = true; if (isRunning) return; if (useUsbAudio && usbAudioDevice != null) { @@ -569,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. @@ -601,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, @@ -669,9 +706,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 +737,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,13 +821,26 @@ public synchronized void reinitialize() { } } - // Restore listener and restart if it was running onDataListener = savedListener; - if (wasRunning) { + 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 @@ -872,6 +935,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/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java index e878e98e7..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 @@ -184,6 +184,104 @@ 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} 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 + * 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 (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 e8de0b4c6..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,15 +528,44 @@ 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( 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() @@ -569,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/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); + } +} 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(); + } +} 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 new file mode 100644 index 000000000..1a0dfe7b6 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/UsbAudioDeviceResolveIndexTest.java @@ -0,0 +1,77 @@ +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 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. + 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); + } +}