Skip to content

USB Audio: libusb-backed iso capture (fixes car-dash)#83

Merged
patrickrb merged 3 commits into
mainfrom
feat/libusb-phase1
Jun 1, 2026
Merged

USB Audio: libusb-backed iso capture (fixes car-dash)#83
patrickrb merged 3 commits into
mainfrom
feat/libusb-phase1

Conversation

@patrickrb
Copy link
Copy Markdown
Owner

@patrickrb patrickrb commented May 30, 2026

Summary

Replaces Android's broken UsbRequest isochronous path with a libusb-backed native capture for "(USB direct)" audio input. This is the actual fix for the car-dash Android 11 tablet, which has been falling back to the built-in mic since #82's bail-out kicked in (because UsbRequest.queue() throws IllegalStateException: request is not initialized on its first call there).

Two commits — toolchain plumbing landed first, then the libusb capture on top — so the diff stays reviewable.

Why libusb

Android's UsbRequest API in theory supports isochronous transfers since API 26, but in practice the AOSP wrapper's iso path fails silently on some kernels — notably automotive Android skins. UsbRequest.initialize() returns false, the next queue() throws, and the user never sees real audio. Phase 0's diagnostics (#82) caught this in the wild.

libusb talks to USBDEVFS via raw ioctls; it's the most battle-tested USB stack on Linux and ships with explicit Android support (libusb_wrap_sys_device(fd)).

What's in it

Phase 1 — NDK / CMake toolchain

  • Re-enables the previously-commented-out externalNativeBuild { cmake { path } } in app/build.gradle.
  • Drops cppFlags '-JENABLE_XOM=false' inherited from upstream FT8CN. It's a typo (likely intended -D, not -J); clang rejects it once a real native build is wired up. Was a silent noop before because libft8cn.so is shipped prebuilt via jniLibs.srcDirs.
  • Adds src/main/cpp/CMakeLists.txt building a new SHARED library libft8af_usb.so — distinct name from libft8cn.so so the prebuilt FT8 decoder coexists unchanged.
  • UsbAudioNative.java: System.loadLibrary wrapper, isAvailable() guard. Load failure logs and returns false rather than crashing.
  • ComposeMainActivity.onCreate logs the native sentinel right after === APP START === for one-line confirmation that the JNI bridge is live.
  • ndk { abiFilters } locked to the four ABIs we already ship.

Phase 2 — libusb capture

  • Vendors libusb 1.0.27 sources under cpp/libusb/. Non-Android subdirectories (Xcode/, msvc/, doc/, examples/, tests/, CI configs) removed up front to keep the repo lean. What's left is what android/jni/libusb.mk compiles plus the upstream Android config.h. License is LGPL-2.1 (compatible with our MIT app license; upstream credit retained in cpp/libusb/AUTHORS, COPYING, README).
  • CMake builds libusb as a static lib (10 .c files mirroring android/jni/libusb.mk) and links into libft8af_usb.so.
  • usb_audio_capture.cpp: JNI nativeStart / nativeStop. Java passes the fd from UsbDeviceConnection.getFileDescriptor() plus endpoint/interface params; native wraps with libusb_wrap_sys_device(), allocates 4 iso transfers × 8 packets each, and pumps a libusb_handle_events_timeout_completed loop on a worker thread. Each completed packet's int16 PCM is converted to mono float and decimated to the target rate before calling back into Java.
  • UsbAudioDevice.startCapture() prefers the libusb path. On success, the original captureLoop() is bypassed; on failure (handle == 0) it falls back to the UsbRequest loop so devices that were already working don't regress.
  • stopCapture() routes through nativeStop when a native handle is live — blocks until the event thread has cancelled all outstanding URBs.
  • Every transition logs to debug.log: which path was tried, the parameters, and whether it started/stopped cleanly. Easy to read in the in-app Debug screen.

Test plan

  • Phase 1: Pixel 8, build green, libft8af_usb.so packaged for all four ABIs, sentinel line in debug.log after install.
  • Phase 2: build green, libusb compiles for all four ABIs from a clean checkout (no errors, no warnings beyond the existing Kotlin unused-var ones). APK contains libft8af_usb.so 298KB–516KB depending on arch (libusb statically linked).
  • Pixel 8 smoke test after merge: confirm (USB direct) selection still captures audio normally — the libusb path should succeed, not fall back to UsbRequest. Look for UsbAudioDevice: libusb capture started OK in debug.log.
  • Car-dash Android 11 tablet (after Play update): pick (USB direct), run waterfall. Expected outcome: waterfall fills with real RF instead of cabin mic. Diagnostic line in debug.log should read UsbAudioDevice: libusb capture started OK rather than the IllegalStateException + giving up on raw USB pair from Fix USB audio death-loop; show real version in splash & About #82.

If libusb itself can't drive iso on the car-dash

We'll see one of these in debug.log and then know we need a different fix (e.g., try a non-iso alt-setting if the device has one, or a USBDEVFS_SUBMITURB-direct approach):

  • libusb_wrap_sys_device(fd=...): LIBUSB_ERROR_*
  • submit transfer i=0: LIBUSB_ERROR_*
  • iso transfer status=... (non-fatal; resubmitting) repeating forever

🤖 Generated with Claude Code

patrickrb and others added 2 commits May 29, 2026 21:02
Phase 1 of the libusb-based USB audio capture work. No functional
change yet — this just stands up the native build pipeline so Phase 2
can drop in libusb + a real isochronous capture path without also
having to debug the toolchain at the same time.

What this PR does:
- Wires up the previously-commented-out externalNativeBuild { cmake }
  block so AGP actually invokes CMake.
- Drops 'cppFlags -JENABLE_XOM=false' inherited from upstream FT8CN.
  It's a typo (likely intended -D, not -J) that clang rejects when
  a native build is actually wired up. It was a silent noop before
  because libft8cn.so is shipped prebuilt via jniLibs.srcDirs.
- Adds src/main/cpp/CMakeLists.txt building a new SHARED library
  libft8af_usb.so. Distinct name from libft8cn.so so the prebuilt FT8
  decoder coexists unchanged.
- src/main/cpp/usb_audio.cpp: a single JNI entry point returning a
  sentinel build string. Phase 2 will replace it with libusb capture.
- UsbAudioNative.java: System.loadLibrary wrapper, isAvailable()
  guard, and the native nativeBuildString() declaration. Load failure
  is logged and isAvailable() returns false rather than crashing the
  app — so a missing lib turns into a clean diagnostic line, not a
  process abort.
- ComposeMainActivity logs the native sentinel to debug.log right
  after === APP START === so we can confirm the JNI bridge works on
  any device by sharing the log.
- ndk { abiFilters } locked to the four ABIs we already ship to keep
  the prebuilt and CMake-built outputs aligned.

Verified on Pixel 8: build green, libft8af_usb.so packaged for all
four ABIs, debug.log shows the expected sentinel line at startup,
existing libft8cn.so still loaded (app boots normally into the decode
flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the actual USB Audio Class isochronous capture path that
the Phase 1 toolchain plumbing was built for. Together with Phase 1
this is what should finally make the car-dash work, since libusb
talks to USBDEVFS directly and bypasses the broken iso path in
Android's UsbRequest API.

What's in this commit:
- Vendors libusb 1.0.27 sources under cpp/libusb/. Non-Android
  subdirectories (Xcode, msvc, doc, examples, tests, CI configs) were
  removed up front to keep the repo lean. What remains is just what
  the upstream android/jni/libusb.mk compiles plus the included Android
  config.h. License is LGPL-2.1 (compatible with our MIT app license;
  upstream credit retained in cpp/libusb/AUTHORS, COPYING, README).
- CMakeLists builds libusb as a static lib from the Linux/USBFS
  source set (10 .c files mirroring android/jni/libusb.mk) and links
  it into our libft8af_usb.so.
- usb_audio_capture.cpp: JNI entry points nativeStart / nativeStop.
  Java passes the fd from UsbDeviceConnection.getFileDescriptor() plus
  endpoint/interface parameters; native wraps it with
  libusb_wrap_sys_device(), allocates 4 iso transfers × 8 packets each,
  and pumps a libusb_handle_events_timeout_completed loop on a worker
  thread. Each completed packet's int16 PCM is converted to mono float
  and accumulated; once we have decimationRatio samples worth, we
  average-down to the target rate and call back into Java.
- UsbAudioNative.java: callback interface (onAudioData / onCaptureStopped)
  plus native method declarations. Library load failure is logged and
  isAvailable() returns false rather than crashing — keeps non-libusb
  hosts working through the existing UsbRequest fallback.
- UsbAudioDevice.startCapture() now tries the libusb path first. On
  success, captureLoop is bypassed entirely. On failure (handle == 0)
  it falls back to the original UsbRequest loop, so any device that
  was already working keeps working. stopCapture() routes through
  nativeStop when a native handle is live; that call blocks until the
  event thread has cancelled all outstanding URBs.
- All transitions log to debug.log: which path was attempted, the
  parameters used, and whether it started / stopped cleanly. Easy to
  read in the in-app Debug screen.

Build verified for all four ABIs: libft8af_usb.so is 298KB-516KB
depending on arch (libusb statically linked). libft8cn.so still
packaged unchanged from libs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@patrickrb patrickrb changed the title Native: wire up NDK/CMake toolchain (libusb Phase 1) USB Audio: libusb-backed iso capture (fixes car-dash) May 30, 2026
Replaces the hardcoded "Phase 1 stub" string with libusb_get_version().
The startup line in debug.log now reads e.g. "ft8af_usb (libusb
1.0.27.11882)" so we have proof of static linkage in every install,
not just the local one. Useful diagnostic the first time a libusb
build lands on the car-dash through Play.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@patrickrb patrickrb merged commit ed46a61 into main Jun 1, 2026
2 checks passed
@patrickrb patrickrb deleted the feat/libusb-phase1 branch June 1, 2026 15:19
patrickrb added a commit that referenced this pull request Jun 1, 2026
USB Audio: libusb iso OUT for TX (companion to #83)
jonstacks pushed a commit to jonstacks/FT8AF that referenced this pull request Jun 3, 2026
The car-dash TX-abort symptom (PTT keys → 250-300ms → PTT releases,
no audio sent) is still happening despite patrickrb#84 shipping the libusb
nativeWrite. But the full TX output path is logged only to logcat, so
from debug.log there's no way to tell:

  1. Which output branch ran (USB direct vs. AudioTrack).
  2. If USB direct ran, which step failed before reaching writeAudio
     (device lookup, USB permission, open, hasOutput, activateOutput).

Same instrumentation pattern as the input side that surfaced the real
bugs in patrickrb#82 / patrickrb#83.

Changes:
- playFT8Signal: log the branch decision with audioOutputDeviceId and
  the saved USB output VID:PID. Log which branch was taken.
- playViaUsbAudio: fileLog at each failure point and at the success
  pivots (device opened + output activated, before writeAudio,
  writeAudio return value). Every failure path has a distinct
  message so the failure point is obvious from one log line.

After the next car-dash repro we'll know whether the abort is:
- audioOutputDeviceId not being -1 at TX time (picker reset?),
- device lookup failing (UsbManager state),
- permission lost on re-attach,
- activateOutput failing on the car-dash kernel,
- or actually reaching writeAudio and libusb dying inside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant