feat: USB exclusive mode — bit-perfect output to USB DACs#1
Merged
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
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.
What
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, PixelPlay claims the DAC's USB Audio Class interface directly (libusb on the
UsbDeviceConnectionfd — no root) and streams decoded PCM at the track's native sample rate and bit depth, bypassing AudioTrack/AudioFlinger. With the toggle off, playback is byte-for-byte the existing path.Full architecture, bit-perfect policy, and lifecycle documentation:
docs/usb-exclusive-mode.md.How
:usbaudiomodule (the repo's first NDK module): vendored libusb 1.0.30 built as its own shared library (LGPL-2.1 compliance, seeVENDORED.md), a C++ UAC1/UAC2 driver (kernel-driver detach + claim, alt settings, clock/volume control transfers, isochronous OUT streaming with explicit-feedback rate correction, lock-free ring buffer), and a thin JNI bridge. NDK r28 pinned; arm64-v8a + armeabi-v7a matching the ABI splits; 16 KB page alignment verified.UsbDescriptorParser(UAC1/UAC2 topologies fromgetRawDescriptors()),UacCapabilityProber(UAC2 clock RANGE on EP0),FormatNegotiator(alt setting + rate selection: no downmix > no resample > no depth reduction > tightest depth > same clock family).UsbAudioSinkimplements the Media3AudioSinkcontract: lossless left-justified subslot repacking (float→int exact for ≤24-bit material — proven by a round-trip test), ring backpressure, position from the driver's played-frames counter, honest bit-perfect vs converted labeling. No dither, no volume scaling, no effects on the bit-perfect path; downmix/resample only when the DAC can't take the source format.DualPlayerEngine's rebuild-preserving-queue path (same as the Hi-Fi toggle); the only branch point on the normal path isbuildAudioSink(). While exclusive: audio offload forced off, crossfade + ReplayGain suspended (both are volume scaling). Unplug mid-song pauses and falls back without crashing; remembered devices auto-resume on re-attach. Calls/alarms pause playback via the existing manual focus handling; there is no ducking.Verification
:usbaudio— descriptor blobs, RANGE decoding, negotiation table; 40+ new app tests — repack golden vectors, sink backpressure/position/drain, controller transition table). The 5 failing tests (BackupSectionTest,LoadControlBufferProfileTest,AudioMetaUtilsTest,LocalArtworkUriTest,LyricsStateHolderTest) fail identically on the base commit8b91d99— pre-existing, unrelated.:app:assembleDebuggreen; both split APKs containlibusb-1.0.so+libusbaudio_jni.so; arm64 ELF LOAD segments 16 KB-aligned.externalNativeBuild. AGP should auto-provision the pinned NDK (28.2.13676358) on the runner; if provisioning fails, the fix is asdkmanager "ndk;28.2.13676358"step inphone-debug.yml.docs/usb-exclusive-mode.md.Known MVP limitations
Implicit-feedback async endpoints are paced at nominal rate; DSD (DoP) not implemented (hook left in
UacCapabilities.dsdSupport); playback speed is pinned to 1× while exclusive; first attached DAC wins when several are connected.🤖 Generated with Claude Code
https://claude.ai/code/session_01FZMSPh3TgbRAMqgW4q1p6V
Generated by Claude Code