Skip to content

feat: USB exclusive mode — bit-perfect output to USB DACs#1

Merged
Norbeto0 merged 8 commits into
masterfrom
claude/new-session-lpe3cm
Jul 3, 2026
Merged

feat: USB exclusive mode — bit-perfect output to USB DACs#1
Norbeto0 merged 8 commits into
masterfrom
claude/new-session-lpe3cm

Conversation

@Norbeto0

@Norbeto0 Norbeto0 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

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 UsbDeviceConnection fd — 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

  • New :usbaudio module (the repo's first NDK module): vendored libusb 1.0.30 built as its own shared library (LGPL-2.1 compliance, see VENDORED.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.
  • Control plane in Kotlin (fully unit-tested, no hardware needed): UsbDescriptorParser (UAC1/UAC2 topologies from getRawDescriptors()), UacCapabilityProber (UAC2 clock RANGE on EP0), FormatNegotiator (alt setting + rate selection: no downmix > no resample > no depth reduction > tightest depth > same clock family).
  • UsbAudioSink implements the Media3 AudioSink contract: 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.
  • Integration reuses DualPlayerEngine's rebuild-preserving-queue path (same as the Hi-Fi toggle); the only branch point on the normal path is buildAudioSink(). 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.
  • UI: dedicated USB Audio settings screen (state machine card, device capabilities, per-device auto-resume, hardware-volume slider or line-level warning, debug-only driver test tone) and a "USB • 24bit/96kHz" Now Playing badge (en/es/fr strings).

Verification

  • 457 unit tests, zero new failures (30 in :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 commit 8b91d99 — pre-existing, unrelated.
  • :app:assembleDebug green; both split APKs contain libusb-1.0.so + libusbaudio_jni.so; arm64 ELF LOAD segments 16 KB-aligned.
  • CI note: this adds the repo's first externalNativeBuild. AGP should auto-provision the pinned NDK (28.2.13676358) on the runner; if provisioning fails, the fix is a sdkmanager "ndk;28.2.13676358" step in phone-debug.yml.
  • Needs hardware: the isochronous streaming itself requires a physical DAC — an 11-point checklist (test tone per rate, DAC rate-display cross-check for 24/96 FLAC, unplug/replug, rate-switch pops, call/alarm, screen-off longevity, volume behavior) is in 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

claude added 8 commits July 2, 2026 20:54
…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
@Norbeto0 Norbeto0 merged commit 4ee50d7 into master Jul 3, 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.

2 participants