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