From 13935b6d5eb9260a9f9bc6d3b00b77d55755333a Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 29 May 2026 14:24:49 -0500 Subject: [PATCH] MicRecorder: stop USB audio death-loop; show real version in splash/about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things bundled because they ship together to the same Play release. 1. USB audio death-loop fix. On the car-dash Android 11 tablet, the isochronous capture loop in UsbAudioDevice exits immediately — connection.requestWait() returns null on the first iteration — and the onCaptureStopped callback was unconditionally reinitializing the recorder. That triggered open -> activate -> start -> die -> reinit -> ... at ~30ms per cycle. Symptom in debug.log: hundreds of `openUsbAudioInput: SUCCESS at 48000 Hz` lines per second. - MicRecorder now rate-limits self-rebind to one per 2s. - After 3 consecutive failures without ever delivering audio data, it gives up on the raw USB path and falls back to AudioRecord+mic so the app stops thrashing the USB bus. Degraded but functional. - UsbAudioDevice.captureLoop now writes the reason (requestWait null vs exception) and iteration count to debug.log so the next car-dash capture tells us why isochronous transfers are failing. 2. Splash screen version is now dynamic — reads GeneralVariables.VERSION + VERSION_CODE so each shipped release shows a unique identifier. Replaces the hardcoded "v1.0 · Forked from FT8CN" line. CI bumps versionCode from GITHUB_RUN_NUMBER + 100. 3. About dialog: removed the "Based on FT8CN by BG7YOZ" line and replaced it with the tagline "FT8, made easy." Upstream attribution remains in README.md and LICENSE so this is purely a UI cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/bg7yoz/ft8cn/GeneralVariables.java | 1 + .../com/bg7yoz/ft8cn/wave/MicRecorder.java | 74 +++++++++++++++++-- .../com/bg7yoz/ft8cn/wave/UsbAudioDevice.java | 17 ++++- .../ft8us/ui/settings/SettingsScreen.kt | 12 +-- .../ks3ckc/ft8us/ui/splash/SplashScreen.kt | 3 +- 5 files changed, 93 insertions(+), 14 deletions(-) diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java index bd10d8db..07c55cd3 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java @@ -33,6 +33,7 @@ public class GeneralVariables { private static final String TAG = "GeneralVariables"; public static String VERSION = BuildConfig.VERSION_NAME;//Version number "0.62 (Beta 4)"; + public static int VERSION_CODE = BuildConfig.VERSION_CODE;//Monotonic build number (CI: GITHUB_RUN_NUMBER + 100) public static String BUILD_DATE = BuildConfig.apkBuildTime;//Build time public static int MESSAGE_COUNT = 3000;//Maximum message cache count public static boolean saveSWLMessage = false;//Save decoded messages switch diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/MicRecorder.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/MicRecorder.java index 330e5b76..f4d9a191 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/MicRecorder.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/MicRecorder.java @@ -35,6 +35,19 @@ public class MicRecorder { private volatile boolean isRunning = false;//whether currently in recording state private OnDataListener onDataListener; + // USB-audio death-loop guard. On some hosts (notably car-dash Android + // tablets) UsbRequest.requestWait() returns null on first iteration and + // onCaptureStopped() fires immediately. Without this, MicRecorder would + // reinitialize() in a ~30ms-per-cycle tight loop. We rate-limit the + // self-rebind and after MAX_CONSECUTIVE_USB_FAILURES failed cycles give up + // on the raw USB path and fall back to AudioRecord+default mic so the app + // keeps running (read-only) instead of spinning the USB bus. + private long lastReinitMs = 0; + private int consecutiveUsbFailures = 0; + private volatile boolean usbAudioSawData = false; + private static final long MIN_REINIT_INTERVAL_MS = 2000; + private static final int MAX_CONSECUTIVE_USB_FAILURES = 3; + public interface OnDataListener{ void onDataReceived(float[] data,int len); } @@ -174,9 +187,17 @@ public void start(){ private void startUsbCapture() { final MicRecorder self = this; + usbAudioSawData = false; usbAudioDevice.startCapture(sampleRateInHz, new UsbAudioDevice.AudioInputCallback() { @Override public void onAudioData(float[] data, int length) { + if (length > 0 && !usbAudioSawData) { + usbAudioSawData = true; + consecutiveUsbFailures = 0; + GeneralVariables.fileLog( + "startUsbCapture: first audio data received, " + + "USB stream is live"); + } if (isRunning && onDataListener != null) { onDataListener.onDataReceived(data, length); } @@ -184,11 +205,54 @@ public void onAudioData(float[] data, int length) { @Override public void onCaptureStopped() { - // The USB audio device died (cable yank, suspend, etc). Without - // this, the decode loop's getVoiceData() callback never fires - // and the waterfall just stops. reinitialize() either reopens - // a fresh USB handle or falls back to the built-in mic. - Log.w(TAG, "USB audio capture stopped unexpectedly, reinitializing"); + // The USB audio capture loop exited without an explicit stop. + // Two distinct cases we need to handle differently: + // 1. Genuine device-gone / temporary glitch — rebind once, + // hope the next attempt sticks. + // 2. Host can't drive isochronous transfers and requestWait() + // returns null on the first iteration — capture dies in + // ~10ms. Without a guard, reinitialize -> open -> start -> + // die -> reinitialize... spins forever at ~30ms per cycle + // (saw this on a car-dash Android 11 tablet). + // Strategy: rate-limit to one reinit per MIN_REINIT_INTERVAL_MS, + // and after MAX_CONSECUTIVE_USB_FAILURES cycles without ever + // seeing audio data, force-fall-back to AudioRecord. The app + // can't transmit through USB then but at least stops thrashing. + if (!usbAudioSawData) { + consecutiveUsbFailures++; + } + long now = System.currentTimeMillis(); + long sinceLast = now - lastReinitMs; + GeneralVariables.fileLog(String.format( + "startUsbCapture: capture STOPPED (sawData=%b " + + "consecFailures=%d sinceLastReinit=%dms)", + usbAudioSawData, consecutiveUsbFailures, sinceLast)); + + if (consecutiveUsbFailures >= MAX_CONSECUTIVE_USB_FAILURES + && !usbAudioSawData) { + GeneralVariables.fileLog( + "startUsbCapture: giving up on raw USB after " + + consecutiveUsbFailures + + " failures, forcing fallback to AudioRecord"); + // Temporarily blank the USB selection so reinitialize() + // takes the AudioRecord branch instead of looping. + GeneralVariables.audioInputDeviceId = 0; + self.reinitialize(); + return; + } + + if (sinceLast < MIN_REINIT_INTERVAL_MS) { + GeneralVariables.fileLog( + "startUsbCapture: throttling reinit (last was " + + sinceLast + "ms ago)"); + try { + Thread.sleep(MIN_REINIT_INTERVAL_MS - sinceLast); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } + } + lastReinitMs = System.currentTimeMillis(); self.reinitialize(); } }); diff --git a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/UsbAudioDevice.java b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/UsbAudioDevice.java index 83652103..e4b87e28 100644 --- a/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/UsbAudioDevice.java +++ b/ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/UsbAudioDevice.java @@ -295,12 +295,23 @@ private void captureLoop(int targetRate, int ratio, int packetSize, ArrayList accumulator = new ArrayList<>(inputSampleRate); float[] outputBuffer = new float[targetRate]; // 1s max + int iterations = 0; while (capturing) { UsbRequest completed = connection.requestWait(); if (completed == null) { - if (capturing) Log.e(TAG, "requestWait returned null"); + if (capturing) { + com.bg7yoz.ft8cn.GeneralVariables.fileLog(String.format( + "UsbAudio.captureLoop: requestWait returned null " + + "after %d iterations (target=%dHz " + + "input=%dHz channels=%d packetSize=%d " + + "ratio=%d) — host likely cannot drive " + + "isochronous transfers via UsbRequest", + iterations, targetRate, inputSampleRate, + inputChannels, packetSize, ratio)); + } break; } + iterations++; int bufIndex = (int) completed.getClientData(); ByteBuffer buf = buffers[bufIndex]; @@ -358,7 +369,9 @@ private void captureLoop(int targetRate, int ratio, int packetSize, } } } catch (Exception e) { - Log.e(TAG, "Capture loop error: " + e.getMessage()); + com.bg7yoz.ft8cn.GeneralVariables.fileLog( + "UsbAudio.captureLoop: exception — " + + e.getClass().getSimpleName() + ": " + e.getMessage()); } finally { for (int i = 0; i < NUM_URBS; i++) { if (requests[i] != null) { diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt index 91216ca6..e61e269f 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/settings/SettingsScreen.kt @@ -1824,12 +1824,12 @@ private fun AboutDialog( ) Text( - text = "Version ${GeneralVariables.VERSION}\n" + - "Build ${GeneralVariables.BUILD_DATE}\n\n" + - "Based on FT8CN by BG7YOZ\n\n" + - "FT8US is a standalone FT8 transceiver app for Android. " + - "It supports USB, Bluetooth, and network rig control " + - "with automatic sequencing and logging.", + text = "Version ${GeneralVariables.VERSION} (build ${GeneralVariables.VERSION_CODE})\n" + + "Build date ${GeneralVariables.BUILD_DATE}\n\n" + + "FT8, made easy.\n\n" + + "A standalone FT8 transceiver app for Android. " + + "USB, Bluetooth, and network rig control with " + + "automatic sequencing and logging.", color = TextMuted, fontSize = 14.sp, lineHeight = 20.sp, diff --git a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt index ac65dde7..58e20f47 100644 --- a/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt +++ b/ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/splash/SplashScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay +import com.bg7yoz.ft8cn.GeneralVariables import com.bg7yoz.ft8cn.R import radio.ks3ckc.ft8us.theme.Accent import radio.ks3ckc.ft8us.theme.GeistMonoFamily @@ -150,7 +151,7 @@ fun FT8USplashScreen( Spacer(modifier = Modifier.height(14.dp)) Text( - text = "v1.0 · Forked from FT8CN", + text = "v${GeneralVariables.VERSION} · build ${GeneralVariables.VERSION_CODE}", color = Color(0xFF8A96B1).copy(alpha = 0.5f), fontSize = 10.sp, fontFamily = GeistMonoFamily,