USB Audio: libusb-backed iso capture (fixes car-dash)#83
Open
patrickrb wants to merge 2 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces Android's broken
UsbRequestisochronous 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 (becauseUsbRequest.queue()throwsIllegalStateException: request is not initializedon 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
UsbRequestAPI 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 nextqueue()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
externalNativeBuild { cmake { path } }inapp/build.gradle.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 becauselibft8cn.sois shipped prebuilt viajniLibs.srcDirs.src/main/cpp/CMakeLists.txtbuilding a newSHAREDlibrarylibft8af_usb.so— distinct name fromlibft8cn.soso the prebuilt FT8 decoder coexists unchanged.UsbAudioNative.java:System.loadLibrarywrapper,isAvailable()guard. Load failure logs and returns false rather than crashing.ComposeMainActivity.onCreatelogs 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
cpp/libusb/. Non-Android subdirectories (Xcode/,msvc/,doc/,examples/,tests/, CI configs) removed up front to keep the repo lean. What's left is whatandroid/jni/libusb.mkcompiles plus the upstream Androidconfig.h. License is LGPL-2.1 (compatible with our MIT app license; upstream credit retained incpp/libusb/AUTHORS,COPYING,README)..cfiles mirroringandroid/jni/libusb.mk) and links intolibft8af_usb.so.usb_audio_capture.cpp: JNInativeStart/nativeStop. Java passes the fd fromUsbDeviceConnection.getFileDescriptor()plus endpoint/interface params; native wraps withlibusb_wrap_sys_device(), allocates 4 iso transfers × 8 packets each, and pumps alibusb_handle_events_timeout_completedloop 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 originalcaptureLoop()is bypassed; on failure (handle == 0) it falls back to theUsbRequestloop so devices that were already working don't regress.stopCapture()routes throughnativeStopwhen a native handle is live — blocks until the event thread has cancelled all outstanding URBs.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
libft8af_usb.sopackaged for all four ABIs, sentinel line indebug.logafter install.libft8af_usb.so298KB–516KB depending on arch (libusb statically linked).(USB direct)selection still captures audio normally — the libusb path should succeed, not fall back toUsbRequest. Look forUsbAudioDevice: libusb capture started OKindebug.log.(USB direct), run waterfall. Expected outcome: waterfall fills with real RF instead of cabin mic. Diagnostic line indebug.logshould readUsbAudioDevice: libusb capture started OKrather than theIllegalStateException+giving up on raw USBpair 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.logand 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