Skip to content

USB exclusive mode — bit-perfect output to USB DACs#2572

Open
Norbeto0 wants to merge 17 commits into
PixelPlayerHQ:masterfrom
Norbeto0:claude/new-session-lpe3cm
Open

USB exclusive mode — bit-perfect output to USB DACs#2572
Norbeto0 wants to merge 17 commits into
PixelPlayerHQ:masterfrom
Norbeto0:claude/new-session-lpe3cm

Conversation

@Norbeto0

@Norbeto0 Norbeto0 commented Jul 9, 2026

Copy link
Copy Markdown

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 UsbDeviceConnection fd — no root required) and streams decoded PCM at the track's native sample rate and bit depth, bypassing AudioTrack/AudioFlinger entirely.

claude added 17 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
…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
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
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