USB exclusive mode — bit-perfect output to USB DACs#2572
Open
Norbeto0 wants to merge 17 commits into
Open
Conversation
…1.0.30 New Gradle library module hosting the USB Audio Class driver for the upcoming USB exclusive (bit-perfect) output mode: - CMake/NDK build (r28 LTS, 16 KB-aligned, arm64-v8a + armeabi-v7a to match the app's ABI splits) - libusb 1.0.30 vendored as an unmodified pinned source subset (the same file list upstream's android/jni/libusb.mk compiles), built as a separate shared library for LGPL-2.1 compliance; see VENDORED.md - Thin JNI bridge exposing only nativeGetVersion() for now; the library is loaded lazily so the normal playback path never touches it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
- UsbDeviceManager (@singleton): enumerates USB Audio Class devices (AudioStreaming interface class 0x01/0x02) at startup and on ATTACHED/DETACHED broadcasts; owns the UsbManager.requestPermission() round-trip via a mutable, package-explicit PendingIntent and surfaces results as a SharedFlow - UsbDeviceInfo with a stable vendor:product:serial identity key (serial is unreadable pre-permission on API 29+, handled) - Preferences: usb_exclusive_mode_enabled toggle, remembered-devices JSON map (label + auto-resume per device), max-volume warning ack - Manifest: optional android.hardware.usb.host feature - JUnit5 tests for the interface filter, key derivation and the remembered-device serialization Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
…ation Pure-Kotlin, hardware-independent core of the USB audio driver: - UsbDescriptorParser walks getRawDescriptors() blobs into a UacTopology: terminals, clock sources, feature units (volume/mute capabilities) and every playback alt setting with wire format, iso endpoint sync type and explicit-feedback endpoint. Handles UAC1 (rates in Type I format descriptors) and UAC2 (clock topology); returns sealed ParseResult and never throws on malformed input - UacCapabilityProber resolves UAC2 sample rates via the clock source's RANGE request through an injected control-transfer function (plain EP0 class request, testable with canned payloads) - FormatNegotiator picks alt setting + rate for a source format with the priority: no downmix > no resample > no depth reduction > tightest depth > same-clock-family rate; float32 input negotiates as 24-bit (lossless for int sources up to 24-bit) - 30 JUnit5 tests: byte-exact UAC1 dongle / UAC2 async DAC / composite blobs, truncation/garbage robustness, RANGE decoding, negotiation table Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
The transfer plane of the USB DAC driver, in C++ on libusb: - UacDevice: wraps the UsbDeviceConnection fd (libusb_wrap_sys_device + NO_DEVICE_DISCOVERY — no root), auto-detaches the kernel audio driver with bounded retries on BUSY, claims AC+AS interfaces, programs alt settings, sample rate (UAC2 clock SET CUR / UAC1 endpoint SET_CUR) and feature-unit volume/mute in native 1/256 dB units - IsoStream: 8 pre-allocated iso OUT transfers × 8 packets kept in flight from a lock-free SPSC ring buffer; packet sizing by Q16.16 fractional accumulator (nominal rate, corrected by the explicit feedback endpoint on async DACs — Q10.14 full-speed / Q16.16 high-speed, sanity-windowed ±25%); underruns zero-fill and count xruns; pause keeps silent packets flowing so the DAC clock stays locked; stop cancels and drops to alt 0 to release iso bandwidth. Separate played vs consumed frame counters give the sink an accurate position and a clean flush rebase point - JNI bridge with a mutex-guarded session object; PCM crosses via direct ByteBuffers, non-blocking with backpressure (0 = ring full) - UsbAudioSession (Kotlin): owns handle + connection, typed configure/ write/position/volume API, safe after close so a racing detach can't crash the playback thread Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
- PcmRepacker: lossless left-justified subslot packing (16→24/32 zero- pad, float→int exact for ≤24-bit material), truncation without dither only when the DAC offers less depth, mono duplication / silence fill - UsbAudioSink implements the full Media3 AudioSink contract: PCM 16/24/32/float accepted directly (encoded formats rejected so the FFmpeg/MediaCodec renderers keep decoding), negotiation on configure, ring-backpressure via handleBuffer=false (never blocks), position from the driver's played-frames counter with flush rebasing and monotonic clamping, drain semantics from buffered frames, and no volume scaling/speed/effects on the bit-perfect path by design. Conversion paths (5.1→stereo downmix, Sonic 16-bit resample) engage only when the DAC can't take the source format. A dead session (unplug) swallows buffers and notifies the controller instead of crashing playback - Native stream now starts paused (silence primes the DAC clock, no pops) and only counts true mid-stream underruns - 20 JUnit5 tests: golden repack vectors incl. the float round-trip losslessness proof, backpressure, position/flush math, drain, dead- session handling, format negotiation through the sink Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
- UsbExclusiveModeController (@singleton): owns the state machine (Disabled/NoDevice/DeviceDetected/PermissionPending/PermissionDenied/ Ready/Active/Error) and the session lifecycle — combines the mode preference with attached devices and permission events; on grant it opens the device, parses descriptors, probes capabilities and establishes the UsbAudioSession; auto-remembers granted devices; detach or driver death closes the session exactly once and emits sessionLost - DualPlayerEngine.setUsbExclusiveMode(): rebuilds players preserving queue/position (setHiFiMode pattern); buildAudioSink() branches to UsbAudioSink while a session is set (normal path byte-for-byte unchanged otherwise); audio offload forced off and crossfade prepare skipped while exclusive - TransitionController treats exclusive mode like crossfade-disabled (plain gapless advance) via the engine's usbExclusiveActive flow - MusicService: collects controller state to engage/release the engine, pauses on unplug and auto-resumes when a remembered device with auto-resume re-attaches; ReplayGain (player-volume scaling) is gated off while exclusive is active - Controller transition-table tests (Turbine + MockK) incl. deny, detach-while-ready, disable-while-ready, unopenable device, Ready↔Active via sink callbacks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
- UsbAudioSettingsScreen (route usb_audio_settings, entry row in Playback settings next to the Hi-Fi toggle): exclusive-mode master toggle, live state card (detection/permission/ready/active/error with permission retry), connected-device card with UAC version and the probed sample rates/bit depths, per-device auto-resume switch, hardware-volume slider when the DAC exposes a feature unit or a fixed line-level warning with one-time acknowledgement otherwise, and a debug-only 440 Hz test tone that drives the raw driver directly (bring-up aid, bypasses ExoPlayer) - Now Playing file-info chip shows the actual DAC output while exclusive mode is active — "USB • 24bit/96kHz", with source→output rate shown when the DAC forced a conversion — plumbed through FullPlayerSlice like the existing route/bluetooth labels - Strings in en/es/fr Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
…ecklist Also updates PlayerViewModelTest for the new UsbExclusiveModeController constructor parameter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
…lock selectors First hardware test (FiiO BTR17) failed with 'reports no playable formats'. Two root causes, both fixed: - Rate probing ran before the native claim: the UAC2 clock RANGE request went through the Java UsbDeviceConnection while the kernel audio driver still owned the AudioControl interface, and usbfs rejects interface-recipient control transfers in that state. The session is now created and claims AC+AS (detaching the kernel driver) first, and all capability probing goes through a new libusb-backed nativeControlTransferIn - XMOS-style dual-clock trees were not modeled: the BTR17's streaming terminal references a CLOCK_SELECTOR feeding separate 44.1k/48k family clock sources. The parser now reads CLOCK_SELECTOR and CLOCK_MULTIPLIER descriptors, clock paths resolve transitively with per-pin mapping, the prober unions rates across all reachable sources, and configure() routes the selector to the right pin before programming the frequency Also: probe/claim failures are now recoverable with a Retry button in the USB Audio screen, and the controller logs the parsed topology plus a hex dump of the raw descriptors when probing fails, so the next field report is diagnosable from logcat alone. 6 new tests: selector parsing/path resolution, rate union with per- source pin mapping, dead-clock fallback, claim-before-probe ordering, recoverable claim failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
…ess resume; true volume slider Hardware testing on the BTR17 confirmed bit-perfect output up to 768 kHz but surfaced an audible crack when skipping songs. The cracks were step discontinuities in the iso driver: - flush() cut the waveform mid-sample (skip/seek click) - the stream consumed while a new track's first buffers trickled in, interleaving zero-gaps with audio (start-of-track crackle) - reconfigure cancelled in-flight transfers abruptly The driver's fill path is now a small state machine (PREFILL/APPROACH/PLAYING/DECAY/SILENCE): every silence-to-data and data-to-silence transition is bridged by a ~2 ms synthesized ramp, playback holds until ~40 ms is buffered after a start/flush (with a 50 ms deadline so short tails still play), flush discards exactly the bytes present at call time (race-free position marker, consumer-side skip), and reconfigure/teardown let the decay play out before cancelling. Real PCM samples remain bit-exact — the ramps exist only in gaps that were previously hard steps. Also: - UsbAudioSink now tracks play state and resumes a freshly configured stream itself, fixing a latent stall on gapless cross-format transitions where ExoPlayer never re-calls play() - the hardware volume slider initializes from the DAC's actual volume (new GET CUR support) instead of a hardcoded position Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
…ease Builds the chosen ref with the nightly-style keystore (shared cache key, so betas and nightlies stay update-compatible) and attaches the split release APKs to a tagged GitHub Release. Verified locally first: the first R8-minified build containing :usbaudio succeeds, both .so files are packaged, and the JNI surface survives shrinking unrenamed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
The workflow-dispatch API and tag pushes are unavailable from this environment, so the release workflow also fires on pushes touching .github/release-tag.txt and reads the tag from that file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
DACs with no UAC feature unit previously played at fixed full scale — dangerous straight into sensitive IEMs. They now get a software gain stage: - PcmRepacker: single Q16 fixed-point multiply in the 32-bit domain before subslot packing; unity gain (0 dB) skips the multiply and stays the untouched bit-perfect path. FormatNegotiator gains preferDeepestDepth so attenuated 16-bit material lands in a 24/32-bit subslot and keeps its low-order bits. - Safe by default: first connect engages −30 dB; levels above −10 dB stay locked behind an explicit loudness acknowledgement; the chosen level is remembered per device (DataStore). - Volume keys work in exclusive mode: the media-session player is wrapped in a ForwardingPlayer advertising remote device volume (30 steps) that maps onto the hardware feature unit when present, else the software gain range, with mute support on both paths. - Honest labeling: the Now Playing badge appends the attenuation (USB • 16bit/44.1kHz • −30dB) whenever samples are scaled — "bit-perfect" is only ever claimed at unity. - Purist escape hatch: a "Fixed line-level output" toggle bypasses the gain stage entirely and restores full-scale output with the old warning card. Settings screen gets the software dB slider (−60..0), the unlock card, and the fixed-output toggle (en/es/fr strings). Covered by new unit tests: repack gain goldens, deepest-depth negotiation, controller ack-gating/persistence/volume-key mapping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
The wrapped ExoPlayer is built without setDeviceVolumeControlEnabled(true), so it never advertises COMMAND_GET/SET/ADJUST_DEVICE_VOLUME. The media session downgrades a remote-playback player without those commands to VOLUME_CONTROL_FIXED and reports its volume as 0 — the DeviceInfo override alone left the volume keys dead. UsbDeviceVolumePlayer now augments getAvailableCommands()/isCommandAvailable() with the three commands its overrides actually implement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
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.
Adds an opt-in USB Exclusive Mode (Settings → Playback → USB Audio), comparable to USB Audio Player PRO / HiBy: when enabled and a USB DAC is attached, PixelPlayer claims the DAC's USB Audio Class interface directly (libusb on the
UsbDeviceConnectionfd — no root required) and streams decoded PCM at the track's native sample rate and bit depth, bypassing AudioTrack/AudioFlinger entirely.