Skip to content

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

Open
patrickrb wants to merge 2 commits into
mainfrom
feat/libusb-phase1
Open

USB Audio: libusb-backed iso capture (fixes car-dash)#83
patrickrb wants to merge 2 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
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