Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 69 additions & 5 deletions ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/MicRecorder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -174,21 +187,72 @@ 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);
}
}

@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();
}
});
Expand Down
17 changes: 15 additions & 2 deletions ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/UsbAudioDevice.java
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,23 @@ private void captureLoop(int targetRate, int ratio, int packetSize,
ArrayList<Float> 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];
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading