Bit-Perfect USB DAC Output on Android
Bypass Android's audio stack and stream bit-perfect audio to a USB DAC.
Introduction
30 Second Summary
When you plug a USB DAC into your Android phone and play a high-resolution audio file, you expect to hear every detail the artist recorded. Instead, Android's audio system quietly resamples everything to 48 kHz and mixes it with system sounds before it reaches your DAC.
In this project, you will integrate a bit-perfect USB audio driver into PixelPlayer, a 6,000-star open-source Android music player. You will modify its Media3 ExoPlayer pipeline so audio flows directly to your USB DAC, completely bypassing Android's audio processing stack.
What You'll Build
You'll press play on a hi-res FLAC file and see your DAC's display switch to the source sample rate, confirming every sample arrived exactly as encoded.
By the end of this project, you'll have:
- A bit-perfect USB audio path that sends hi-res files to your DAC exactly as encoded, with your DAC's display confirming the source sample rate instead of Android's default 48 kHz.
- Three automatic decoding paths that preserve full audio quality across every format, from native C++ FLAC decoding to FFmpeg float processing.
- Instant USB device detection that claims your DAC before Android's kernel driver can bind, with automatic sample rate switching from 44.1 kHz through 384 kHz.
- Secret Mission: Add a DAC status indicator to the Now Playing screen showing connection state, sample rate, and bit depth.
What do I need for this project?
This project requires a Linux machine, a USB DAC, and a physical Android phone with USB-C. All software tools are installed from scratch during the project.
Before We Start
Before any hands-on work, let's lock in what you're about to build and why it matters to you.
Set Up Android Studio and Clone Both Projects
Integrating decent-player into PixelPlayer requires both projects to live as sibling directories on your machine. Gradle composite builds reference the decent-player libraries by relative file path, so the folder layout matters.
In this step, you will install Android Studio Quail 2 on Linux, clone both repositories side by side, and verify that PixelPlayer compiles cleanly.
In this step, get ready to:
- Install Android Studio Quail 2 and complete the Setup Wizard.
- Clone PixelPlayer and decent-player as sibling directories.
- Open PixelPlayer in Android Studio and verify it builds with zero errors.
Install Android Studio Quail 2
Android Studio is the IDE you will use to build, run, and debug PixelPlayer on your Android phone. This project needs Quail 2 (version 2026.1.2) or newer because it ships with the Gradle and SDK tooling that PixelPlayer's build scripts expect.
- Check whether Android Studio is already installed on your machine. If you have it, open it and check the version number on the splash screen or via the Help menu, then About.
✔️ I have Android Studio 2026.1.2 or newer
You are all set. Skip ahead to the cloning substep below.
ⓧ I have an older version of Android Studio
On Linux, Android Studio is a standalone directory. You can keep your existing installation and extract the new version alongside it.
- Download the Linux archive android-studio-quail2-patch1-linux.tar.gz (1.5 GB) from the Android Studio download page.
- Once the download finishes, extract it to /opt/ by running this command:
sudo tar -xzf ~/Downloads/android-studio-quail2-patch1-linux.tar.gz -C /opt/
- Launch Android Studio by running:
/opt/android-studio/bin/studio
The Setup Wizard appears on first launch.
- Select Do not import settings.
- Click OK.
- Accept the default settings at each wizard step.
- Agree to the SDK license agreements.
- Click Finish.
The wizard downloads the Android SDK, build tools, and emulator. This may take 5-10 minutes depending on your internet speed.
Seeing errors about missing 32-bit libraries?
64-bit Ubuntu requires 32-bit compatibility libraries for Android Studio. Install them by running:
sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2-1.0:i386
After the install finishes, launch Android Studio again with /opt/android-studio/bin/studio.
ⓧ I don't have Android Studio installed
- On 64-bit Ubuntu, install the required 32-bit compatibility libraries first by running:
sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2-1.0:i386
What does this command do?
Android Studio ships some 32-bit native binaries internally. This command installs the 32-bit versions of core system libraries so those binaries can run on your 64-bit system.
- Download the Linux archive android-studio-quail2-patch1-linux.tar.gz (1.5 GB) from the Android Studio download page.
- Once the download finishes, extract it to /opt/ by running this command:
sudo tar -xzf ~/Downloads/android-studio-quail2-patch1-linux.tar.gz -C /opt/
- Launch Android Studio by running:
/opt/android-studio/bin/studio
The Setup Wizard appears on first launch.
- Select Do not import settings.
- Click OK.
- Accept the default settings at each wizard step.
- Agree to the SDK license agreements.
- Click Finish.
The wizard downloads the Android SDK, build tools, and emulator. This may take 5-10 minutes depending on your internet speed.
After the Setup Wizard finishes, you should see the Welcome to Android Studio screen. This confirms the IDE and SDK are installed correctly.
Setup Wizard stuck or failing to download?
The wizard requires an internet connection to download SDK components. Check your connection and try again. If your network uses a proxy, configure it in the wizard's proxy settings dialog.
Help me troubleshoot the Android Studio Setup Wizard on Linux
Clone PixelPlayer and decent-player as sibling directories
Gradle composite builds resolve library modules by relative file paths. Both repositories must sit side by side in the same parent directory for the build to find them.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone both repositories as siblings by running these commands:
# Clone PixelPlayer (6k-star Android music player)
git clone https://github.com/PixelPlayerHQ/PixelPlayer.git
# Clone decent-player (MIT-licensed USB audio driver libraries)
git clone https://github.com/Ma145/decent-player.git
What do these commands do?
The first clone downloads PixelPlayer's full source code, including its Jetpack Compose UI, Media3 ExoPlayer integration, and Gradle build scripts.
The second clone downloads decent-player, which contains the three MIT-licensed library modules you will wire into PixelPlayer: the USB audio driver, the Media3 audio sink wrapper, and the native FLAC decoder.
- Confirm both directories exist by running:
ls
You should see both PixelPlayer and decent-player listed in the output.
Don't see both directories?
Make sure each git clone command completed without errors. If you see a permissions or network error, check your internet connection and try the clone again.
Help me troubleshoot the git clone for PixelPlayer and decent-player
Open PixelPlayer in Android Studio and verify the build
With both repositories in place, you can open PixelPlayer and confirm it compiles. The first Gradle sync downloads all of PixelPlayer's dependencies and may take 5-10 minutes.
- In the Android Studio welcome screen, click Open.
- Navigate to ~/Desktop/PixelPlayer and click OK.
- Wait for the Gradle sync notification bar to appear at the top of the editor. Android Studio automatically begins syncing.
- Track progress in the Build tool window at the bottom.
What is Gradle sync?
Gradle sync reads the project's build scripts and downloads every dependency they declare (libraries, plugins, SDK components). Until sync finishes, Android Studio cannot compile the project. A spinning progress indicator appears in the toolbar while it runs.
- Once sync completes with no errors, click Build in the top menu bar.
- Click Make Project.
Before you look at the result, what do you expect to see? The project has no modifications yet. Do you think it will compile successfully?
The build should complete with zero errors. You will see BUILD SUCCESSFUL in the Build tool window. This confirms that Android Studio, the SDK, and PixelPlayer's dependencies are all correctly configured.
Build failed with errors?
If the build fails with an SDK-related error, open File in the top menu, then Settings. Navigate to Appearance & Behavior, then System Settings, then Android SDK. Verify that at least one SDK Platform is installed. The SDK Tools tab should show Android SDK Build-Tools and Android SDK Platform-Tools as installed.
If you see a JDK error, open File, then Settings, then Build, Execution, Deployment, then Build Tools, then Gradle. Confirm the Gradle JDK is set. Android Studio Quail 2 auto-provisions a compatible JDK, so clicking Sync Now in the notification bar usually resolves this.
Help me troubleshoot the PixelPlayer build in Android Studio
Your development environment is ready and PixelPlayer compiles from source. Next up, you will run the app on your phone and trace its audio pipeline to find exactly where ExoPlayer is created.
Run PixelPlayer and Find Its Audio Pipeline
In the last step, you installed Android Studio and cloned both repositories as sibling directories. PixelPlayer compiles cleanly, so your development environment is ready.
Before you can integrate the USB audio driver, you need to see the app running and pinpoint the exact place in the codebase where ExoPlayer is created. That single location is where you will inject the custom audio sink in a later step.
In this step, get ready to:
- Run PixelPlayer on your physical Android phone and play a song.
- Find the player service file where ExoPlayer is constructed.
- Review the Gradle files you will modify in the next step.
Connect your phone and run PixelPlayer
PixelPlayer needs a physical Android phone to test audio playback. The Android emulator cannot route audio to a real USB DAC, so you will use your own device for the rest of this project.
- Connect your Android phone to your Linux machine via USB cable.
- On your phone, make sure USB Debugging is enabled in Developer Options.
How to enable USB Debugging
If you do not see Developer Options in your phone's Settings, go to Settings > About Phone and tap Build Number seven times.
Then go back to Settings > Developer Options and toggle on USB Debugging. When you connect your phone to your computer, confirm the prompt on the phone to allow the connection.
- In Android Studio, select your connected phone from the target device dropdown in the top toolbar.
- Click the green Run button (play icon) to build and deploy the app to your phone.
- When PixelPlayer launches on your phone, grant the storage permission it requests.
- Let it scan your music library.
Phone not showing in the device dropdown?
- Confirm that USB Debugging is toggled on in Developer Options.
- Check for a prompt on your phone asking you to authorize the computer for debugging. Tap Allow.
- Try a different USB cable. Some cables are charge-only and do not support data transfer.
- help me get my Android phone to appear in Android Studio's device list on Linux
Before you play a song, make a prediction. Do you expect the audio to sound any different from your phone's built-in music player?
- Play any song from your music library to confirm playback works.
The song plays through your phone's speaker or wired headphones. It sounds identical to any other music player on your phone.
Why does it sound the same?
Right now, PixelPlayer sends audio through Android's standard AudioFlinger pipeline. AudioFlinger resamples everything to 48 kHz and mixes it through the system volume, regardless of the source format.
A 24-bit/96 kHz FLAC file arrives at the output as 16-bit/48 kHz. That is the gap this project closes.
Locate the ExoPlayer construction point
ExoPlayer is the engine behind PixelPlayer's audio playback. Somewhere in the codebase, an ExoPlayer.Builder call creates the player instance. That is the single insertion point for the USB audio sink.
- In Android Studio's Project panel on the left, navigate to app/src/main/java/com/theveloper/pixelplay/data/service/.
- Open the player service file in this directory.
- Press Ctrl+Shift+F to open Find in Files.
- Search for ExoPlayer.Builder to find where the player is constructed.
- Search for DefaultRenderersFactory or buildAudioSink to check whether a custom renderers factory already exists.
- Note the file name, the class name, and the method where the player is built.
What are you looking for?
The ExoPlayer.Builder call is where you will inject the custom RenderersFactory and LoadControl in Step 4. If you also find a DefaultRenderersFactory or buildAudioSink override, that means the codebase already customizes the audio sink and you will need to work with that existing setup.
You should see the ExoPlayer.Builder call highlighted in the search results, inside a method in the player service file.
Search returned no results?
- Make sure the search scope is set to In Project in the Find in Files dialog, not a single file or directory.
- Double-check the capitalization. ExoPlayer.Builder is case-sensitive.
- Try searching for just ExoPlayer to cast a wider net.
- help me find where ExoPlayer is created in PixelPlayer's codebase
Review the Gradle files you will edit next
In the next step, you will add the decent-player library modules to PixelPlayer's Gradle build. Before editing, take a look at the two files you will modify so you know what already exists.
- Open app/build.gradle.kts (the app-level build file, not the project-level one).
- Find the lines that reference androidx.media3 and note the version number listed.
- Open settings.gradle.kts at the project root.
- Review the existing include statements so you know where to add the new ones.
Why note the Media3 version?
The decent-player libraries expect a specific Media3 version. If PixelPlayer uses a different version, you may see Gradle conflicts when you add the new dependencies. Knowing the current version now saves debugging time later.
You should see one or more androidx.media3 dependencies in app/build.gradle.kts, each with a version number. Keep both files open for the next step.
You have mapped out the injection point and know which files need editing. In the next step, you will link the decent-player libraries into PixelPlayer's Gradle build.
Add decent-player Libraries as Gradle Dependencies
You have PixelPlayer running and you know exactly where ExoPlayer is created in the codebase. Now it is time to give PixelPlayer access to the USB audio driver.
The decent-player libraries are standalone Gradle modules that live in the sibling directory you cloned earlier. Until you register them in PixelPlayer's build files and declare them as dependencies, classes like UsbAudioSink and the native driver do not exist in the compilation classpath.
In this step, get ready to:
- Register three decent-player library modules in settings.gradle.kts using Gradle composite builds.
- Add implementation dependencies in app/build.gradle.kts so PixelPlayer can import the USB audio classes.
- Sync and build to verify all three modules link correctly.
Register decent-player modules in settings.gradle.kts
Gradle composite builds let you include modules from a completely separate project by mapping their directory paths. This is how you link the decent-player libraries without publishing them to a package repository first.
- Return to settings.gradle.kts in Android Studio (the one at the project root, not inside app/).
- Find the existing include statements near the top of the file.
- Add the following lines after the existing include statements to register the three decent-player library modules:
include(":decent-usb-audio-driver")
include(":decent-usb-audio-wrapper-media3")
include(":decent-media3-decoder-flac")
project(":decent-usb-audio-driver").projectDir =
file("../decent-player/libs/decent-usb-audio-driver")
project(":decent-usb-audio-wrapper-media3").projectDir =
file("../decent-player/libs/decent-usb-audio-wrapper-media3")
project(":decent-media3-decoder-flac").projectDir =
file("../decent-player/libs/decent-media3-decoder-flac")
What does this code do?
The three include() lines tell Gradle that three new modules exist in this project. Without them, Gradle does not know these libraries are part of the build.
The projectDir lines map each module name to a physical directory on disk. The ../decent-player/libs/ paths point up one level and into the sibling decent-player directory you cloned earlier.
- Save settings.gradle.kts.
Add implementation dependencies to app/build.gradle.kts
Registering the modules makes Gradle aware they exist. Now you need to tell PixelPlayer's app module to actually depend on them.
- Switch to app/build.gradle.kts in Android Studio.
- Find the dependencies block (look for the section with existing implementation lines, including the androidx.media3 entries you noted earlier).
- Add these three lines inside the dependencies block:
implementation(project(":decent-usb-audio-driver"))
implementation(project(":decent-usb-audio-wrapper-media3"))
implementation(project(":decent-media3-decoder-flac"))
What do these lines do?
Each implementation(project(...)) line declares a compile-time dependency on one of the modules you registered in settings.gradle.kts. When Gradle builds the app, it compiles these libraries and packages them into the APK.
- decent-usb-audio-driver is the core USB Audio Class 2.0 driver with native C++ and JNI bindings.
- decent-usb-audio-wrapper-media3 is the drop-in Media3 AudioSink wrapper that intercepts ExoPlayer's audio output.
- decent-media3-decoder-flac is a native FLAC decoder that preserves the zero-float integer path for bit-perfect FLAC playback.
- Save app/build.gradle.kts.
✔️ Awesome, I've got everything!
Great. Double check you have saved both settings.gradle.kts and app/build.gradle.kts before moving on.
ⓧ I'd like to double check the full code
Here are the exact additions for each file. Make sure your code matches character for character.
In settings.gradle.kts, you should see these lines after the existing include statements:
include(":decent-usb-audio-driver")
include(":decent-usb-audio-wrapper-media3")
include(":decent-media3-decoder-flac")
project(":decent-usb-audio-driver").projectDir =
file("../decent-player/libs/decent-usb-audio-driver")
project(":decent-usb-audio-wrapper-media3").projectDir =
file("../decent-player/libs/decent-usb-audio-wrapper-media3")
project(":decent-media3-decoder-flac").projectDir =
file("../decent-player/libs/decent-media3-decoder-flac")
In app/build.gradle.kts, you should see these lines inside the dependencies block:
implementation(project(":decent-usb-audio-driver"))
implementation(project(":decent-usb-audio-wrapper-media3"))
implementation(project(":decent-media3-decoder-flac"))
Sync Gradle and build the project
Both files are saved. Before you sync, think about what Gradle needs to do. It has to find the three module directories, resolve their own dependencies against PixelPlayer's existing dependency tree, and compile everything together. Do you think the sync will succeed on the first try, or could something conflict?
- Click Sync Now in the yellow notification bar that appears at the top of the editor.
- Wait for the sync to complete. You can watch progress in the Build tool window at the bottom of Android Studio.
Seeing a version conflict error?
If Gradle reports a conflict between PixelPlayer's androidx.media3 version and what the decent-player wrapper expects, you need to align them.
- Open decent-player/libs/decent-usb-audio-wrapper-media3/build.gradle.kts and note the Media3 version it references.
- Compare that with the Media3 version in PixelPlayer's app/build.gradle.kts. Update whichever side makes sense so both use the same version.
- Click Sync Now again after making the change.
help me resolve a Media3 version conflict between PixelPlayer and decent-player
- Once sync completes with zero errors, run Build > Make Project from the top menu bar.
The build should complete successfully. You can confirm this in the Build tool window, where the last line reads BUILD SUCCESSFUL.
What just happened?
Gradle reached into the sibling decent-player/ directory, compiled the three library modules (including their native C++ code via NDK), and linked them into PixelPlayer's classpath. Classes like UsbAudioSink and UsbAudioPermissionHelper are now available to import. The app itself is unchanged so far. You will wire them in next.
Build failed?
- Check that your PixelPlayer/ and decent-player/ folders are true siblings in the same parent directory. The ../decent-player/ paths only resolve correctly when both repos sit side by side.
- Verify the module names in settings.gradle.kts match exactly. A typo in :decent-usb-audio-driver or the file path will cause Gradle to report a missing project.
- Make sure the three implementation(project(...)) lines are inside the dependencies { } block, not outside it.
help me debug my Gradle composite build setup
The three library modules are compiled and linked. PixelPlayer can now see the USB audio classes, but it is not using them yet. Next up, you will wire UsbAudioSink directly into the ExoPlayer pipeline so audio can bypass the entire Android audio stack.
Wire UsbAudioSink into PixelPlayer's Audio Pipeline
The three decent-player libraries are now linked into PixelPlayer's build. The classes exist in the classpath, but nothing uses them yet.
PixelPlayer still creates its ExoPlayer with the default audio sink, which sends every audio stream through Android's AudioFlinger resampling pipeline. In this step, you will override the audio sink creation to wrap it in UsbAudioSink, the ForwardingAudioSink that intercepts audio and routes it to a USB DAC when one is connected.
In this step, get ready to:
- Add USB audio imports and a custom renderers factory to the player service.
- Modify the ExoPlayer builder to use the new factory and a wrapped LoadControl.
- Verify the UsbAudioSink appears in Logcat while audio plays normally through the phone speaker.
Add imports and create the renderers factory
To override how ExoPlayer builds its audio output, you need a custom DefaultRenderersFactory that intercepts the buildAudioSink() call. You will build this factory method in the player service file, along with the imports it depends on.
- In Android Studio, open the player service file you identified in Step 2 (inside app/src/main/java/com/theveloper/pixelplay/data/service/).
- Add the following imports at the top of the file, below the existing import statements:
// USB audio integration imports
import com.decent.usbaudio.media3.UsbAudioSink
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.DefaultLoadControl
- Add this property inside the service class, near the top with the other property declarations:
// Holds a reference to the USB audio sink for LoadControl and lifecycle
private var currentUsbSink: UsbAudioSink? = null
What do these imports bring in?
- UsbAudioSink is the ForwardingAudioSink wrapper from decent-player that intercepts audio and routes it to a USB DAC.
- DefaultRenderersFactory is ExoPlayer's factory for creating renderers. You will subclass it to override how the audio sink is built.
- DefaultAudioSink is the standard audio output that UsbAudioSink wraps as a delegate.
- The currentUsbSink property keeps a reference so you can connect it to the LoadControl and the player later.
- Now add the following method to the service class. This is the factory that builds the bit-perfect audio pipeline:
private fun createBitPerfectRenderersFactory(): DefaultRenderersFactory {
val factory = object : DefaultRenderersFactory(this) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableOffload: Boolean
): AudioSink {
// Detect if native FLAC decoder is available at runtime
val hasLibFlac = try {
Class.forName("androidx.media3.decoder.flac.LibflacAudioRenderer")
true
} catch (_: ClassNotFoundException) { false }
// Build standard sink: float on without libFLAC (FFmpeg), off with (raw int)
val delegate = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(!hasLibFlac)
.setAudioCapabilities(AudioCapabilities.getCapabilities(context))
.build()
// Wrap in UsbAudioSink for bit-perfect USB output
return UsbAudioSink(delegate, context).also {
currentUsbSink = it
}
}
}
// Prefer FFmpeg over Android's MediaCodec (which truncates 24-bit to 16-bit)
factory.setExtensionRendererMode(
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
)
return factory
}
What does this method do?
This method creates a DefaultRenderersFactory with one key override: how the audio sink is built.
- Class.forName("androidx.media3.decoder.flac.LibflacAudioRenderer") checks at runtime whether the native FLAC decoder library is available. You added this library in Step 3.
- When libFLAC is present, enableFloatOutput is set to false. FLAC files stay as raw integer PCM with zero float math in the entire pipeline.
- When libFLAC is absent, enableFloatOutput is true. FFmpeg delivers float32 for all formats, and the driver reconverts via x2^N scaling (mathematically lossless for 16-bit and 24-bit).
- EXTENSION_RENDERER_MODE_PREFER forces FFmpeg for non-FLAC formats. Without this, Android's built-in MediaCodec decoder truncates 24-bit audio to 16-bit.
- Save the player service file.
- Run Build > Make Project to verify the new method compiles without errors.
Build failing after adding the method?
- Check that all six import statements are present at the top of the file. A missing import for AudioCapabilities or AudioSink causes an unresolved reference error.
- Make sure Context in the buildAudioSink override resolves to android.content.Context. PixelPlayer likely already imports this.
- If you see a version mismatch error, check that PixelPlayer's Media3 version in app/build.gradle.kts aligns with what decent-player expects. Open decent-player/libs/decent-usb-audio-wrapper-media3/build.gradle.kts to find the expected version.
Why won't my renderers factory compile?
Modify the ExoPlayer builder to use the new factory
The factory method exists but nothing calls it yet. You need to plug it into the existing ExoPlayer construction and also wrap the LoadControl so ExoPlayer backs off disk reads when the native C++ FLAC engine is handling playback directly.
- In the player service file, find the existing code that builds the ExoPlayer instance. Use Edit > Find (Ctrl+F) and search for ExoPlayer.Builder to locate it.
- Replace the existing player construction with the following code:
// Wrap LoadControl to pause ExoPlayer reads during native engine playback
val loadControl = UsbAudioSink.wrapLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(5000, 15000, 2000, 3000)
.build()
) { currentUsbSink?.isNativeEngineActive == true }
val player = ExoPlayer.Builder(this)
.setRenderersFactory(createBitPerfectRenderersFactory())
.setLoadControl(loadControl)
.build()
// Register listener for track transitions, seek, and engine lifecycle
currentUsbSink?.attachToPlayer(player)
What does this code do?
- UsbAudioSink.wrapLoadControl() wraps the default LoadControl with a check that pauses ExoPlayer's file reads when the native C++ FLAC engine is handling decode and USB output directly. This avoids SD card I/O contention between ExoPlayer and the native engine.
- The buffer durations (5000, 15000, 2000, 3000 ms) are the values from decent-player's integration guide, tuned for USB audio latency.
- attachToPlayer(player) registers an internal Player.Listener that handles track transitions, seek position restore, and NativeAudioEngine lifecycle automatically.
- Save the player service file.
If PixelPlayer already has a custom LoadControl
If the existing ExoPlayer builder already configures a custom LoadControl, wrap that existing one with UsbAudioSink.wrapLoadControl() instead of creating a new DefaultLoadControl. The wrapper is transparent and preserves the original buffering behavior.
Seeing unresolved reference errors?
- Make sure DefaultLoadControl is imported. It should be covered by the import androidx.media3.exoplayer.DefaultLoadControl you added earlier.
- Verify that the createBitPerfectRenderersFactory() method is inside the same class as the ExoPlayer builder code. It must be a member of the service class.
Help me fix ExoPlayer builder errors after adding UsbAudioSink integration.
Build, run, and verify in Logcat
The UsbAudioSink is now wired into the pipeline. Before you run, think about what you expect to happen. No USB DAC is connected yet, so the sink should initialize but fall back to the phone's standard audio output.
- Connect your Android phone via USB and run the app (Run > Run 'app').
- Play any song in PixelPlayer.
Before you check the logs, predict: do you think you will see USB-related log messages even though no DAC is plugged in?
- Open Logcat in Android Studio by clicking View > Tool Windows > Logcat.
- In the Logcat query field, type package:mine to filter to PixelPlayer's logs.
- Search within the results for UsbAudio by adding it to the query (e.g. package:mine UsbAudio).
You should see initialization log entries from the UsbAudioSink wrapper. Audio plays through the phone speaker as normal because no DAC is connected. The wrapper is now in the chain, ready to route to a USB DAC when one appears.
No UsbAudio entries in Logcat?
- Try broadening the search. Use package:mine tag~:UsbAudio|AudioSink|NativeAudioEngine to catch all related log tags.
- Make sure you actually played a song. The audio sink is only initialized when ExoPlayer starts playback.
- Verify the build you are running includes your changes. Click Run > Run 'app' again to force a fresh install.
Why don't I see UsbAudio messages in Logcat?
✔️ Awesome, I've got everything!
Great. Make sure you have saved the player service file.
ⓧ I'd like to double check the full code
Scroll back up through this step and confirm you have each of these code blocks present in your player service file, in order:
- The six USB audio import statements at the top of the file.
- The currentUsbSink property declaration inside the service class.
- The createBitPerfectRenderersFactory() method with the buildAudioSink() override, libFLAC detection, and EXTENSION_RENDERER_MODE_PREFER.
- The modified ExoPlayer builder with UsbAudioSink.wrapLoadControl(), setRenderersFactory(), and attachToPlayer().
The UsbAudioSink is now part of every playback session. In the next step, you will add USB device detection and permission handling so PixelPlayer can actually claim a DAC and send audio through the bit-perfect pipeline.
Add USB DAC Detection and Permission Handling
In the previous step, you wired UsbAudioSink into PixelPlayer's audio pipeline. The pipeline is ready to route audio through a USB DAC, but it has no way to actually find one.
Android's kernel snd-usb-audio driver claims USB audio devices within milliseconds of connection. Without explicit manifest declarations and a permission handler in the Activity, PixelPlayer never gets a chance to intercept the DAC. Audio continues flowing through the standard Android path. In this step, you'll register PixelPlayer as a USB audio device handler and add the permission flow so your bit-perfect pipeline can reach real hardware.
In this step, get ready to:
- Declare USB host support and a device-attached intent filter in the Android manifest.
- Create a USB device filter that matches any Audio Class device.
- Add USB permission handling to the main Activity so PixelPlayer claims DACs before the kernel driver binds.
Add USB host declarations to the manifest
Android apps must declare USB host support and register for device-attachment events in their manifest. Without these declarations, the system never offers PixelPlayer as a handler when a USB DAC is plugged in.
- In Android Studio, open app/src/main/AndroidManifest.xml from the Project panel.
- Find the <manifest> tag. Add the following line inside it, before the <application> tag:
<!-- Add inside <manifest> tag, before <application> -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
- Now scroll down to find the main <activity> declaration (the one with the android.intent.action.MAIN intent filter).
- Add the following intent filter and meta-data inside that <activity> tag, after the existing intent filters:
<!-- Add inside the main <activity> declaration -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_audio_device_filter" />
What do these declarations do?
The uses-feature line tells Android that PixelPlayer can act as a USB host. Setting required="false" means the app still installs on devices without USB host hardware, but it can use the port when available.
The intent filter registers PixelPlayer for the USB_DEVICE_ATTACHED event. The meta-data element points to a device filter XML file that tells Android which kinds of USB devices this app cares about. You will create that filter file next.
- Save AndroidManifest.xml.
Create the USB device filter
The manifest references a filter file at @xml/usb_audio_device_filter that does not exist yet. You need to create it so Android knows which USB devices PixelPlayer handles.
- In the Project panel, right-click the res folder inside app/src/main/.
- Select New > Android Resource Directory.
- Set Resource type to xml in the dropdown and click OK.
- Right-click the new xml folder, select New > File, and name it usb_audio_device_filter.xml.
- Paste the following content into the new file:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Match any USB Audio Class device (class 1 = Audio) -->
<usb-device class="1" />
</resources>
Why class 1?
USB Audio Class 2.0 devices all share USB device class 1 (Audio). By filtering on class alone instead of specific vendor and product IDs, PixelPlayer handles any standard-compliant USB DAC. No matter what brand or model your DAC is, it will match this filter.
- Save usb_audio_device_filter.xml.
- Verify both files are correct by running Build > Make Project. The build should complete with zero errors.
Build failing after adding the filter?
- Check that usb_audio_device_filter.xml is inside app/src/main/res/xml/, not directly in res/. The xml subdirectory is required.
- Verify the file name matches exactly what the manifest references: usb_audio_device_filter (no typos, underscores not hyphens).
- Make sure the meta-data element in the manifest uses @xml/usb_audio_device_filter (with the @xml/ prefix).
Still stuck? Ask for help: My build fails after adding the USB device filter XML. What could be wrong?
✔️ Awesome, I've got everything!
Great. Make sure both AndroidManifest.xml and usb_audio_device_filter.xml are saved.
ⓧ I'd like to double check the full code
Here are the additions you made to AndroidManifest.xml:
<!-- Add inside <manifest> tag, before <application> -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<!-- Add inside the main <activity> declaration -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_audio_device_filter" />
And here is the full usb_audio_device_filter.xml file:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<usb-device class="1" />
</resources>
Handle USB permissions in the main Activity
The manifest now tells Android to send USB device events to PixelPlayer. The Activity needs to handle those events by detecting the audio device, checking permissions, and claiming the USB device before the kernel driver binds.
- Open PixelPlayer's main Activity file. You can find the class name in the android:name attribute of the main <activity> declaration you just edited in the manifest.
- Add this import at the top of the file, with the other imports:
// USB permission helper from decent-player
import com.decent.usbaudio.UsbAudioPermissionHelper
- Find the onCreate() method. Add the following line after super.onCreate():
// Detect USB audio device in the launch intent and request permission
UsbAudioPermissionHelper.handleIntent(applicationContext, intent)
- Add the following method override to the Activity class, outside of onCreate():
// Handle USB device connections while the Activity is already running
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
UsbAudioPermissionHelper.handleIntent(applicationContext, intent)
}
What does UsbAudioPermissionHelper do?
When Android delivers a USB_DEVICE_ATTACHED intent, handleIntent() extracts the USB device from the intent, checks whether PixelPlayer already has permission, and requests it if not. Once permission is granted, it calls openDevice() to claim the device before the kernel's snd-usb-audio driver can bind.
You call it in both onCreate() and onNewIntent() to cover two scenarios. onCreate() handles a DAC that was already connected when PixelPlayer launches. onNewIntent() handles a DAC plugged in while the app is already running.
- Save the main Activity file.
✔️ Awesome, I've got everything!
Great. Double check that you have the import, the onCreate() addition, and the onNewIntent() override all saved.
ⓧ I'd like to double check the full code
Here are all the additions you made to the main Activity:
// --- Add this import ---
import com.decent.usbaudio.UsbAudioPermissionHelper
// --- Add this line inside onCreate(), after super.onCreate() ---
UsbAudioPermissionHelper.handleIntent(applicationContext, intent)
// --- Add this method override ---
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
UsbAudioPermissionHelper.handleIntent(applicationContext, intent)
}
Verify bit-perfect USB output
Everything is in place. Time to test the complete pipeline with your real USB DAC.
- Build and install the updated app on your phone by clicking Run > Run 'app'.
- Wait for the app to launch on your phone, then disconnect the USB cable from the computer.
- Connect your USB DAC to your phone's USB-C port.
Before you look at the screen, think about what you expect to happen. Will PixelPlayer start playing through the DAC automatically, or will Android need to do something first?
Android presents a system dialog asking if PixelPlayer should handle this USB device. This is the permission flow you just wired up. The dialog text varies by Android version, but it will name PixelPlayer as the requesting app.
- Tap OK (or Allow, depending on your Android version) to grant USB permission.
- Open PixelPlayer and play a hi-res FLAC file (24-bit/96 kHz or higher if you have one).
- Check your DAC's display. It should show the source sample rate (for example, 96 kHz) instead of the default 48 kHz that Android's standard pipeline would send.
What if my DAC doesn't have a display?
Reconnect your phone to the computer via USB after testing. In Android Studio, open Logcat (View > Tool Windows > Logcat), filter by the app's package name, and search for messages showing the negotiated sample rate and bit depth. These logs confirm the bit-perfect path is active.
How do I know it's bit-perfect?
When the DAC shows the source file's native sample rate (96 kHz, 192 kHz, etc.) instead of 48 kHz, audio is arriving without resampling. Android's standard AudioFlinger pipeline always resamples everything to 48 kHz before output. If you see the original rate on your DAC, the bit-perfect path is working.
Not seeing the permission dialog?
- Make sure you installed the latest build. If you edited the manifest after the last install, you need to run the app again via Run > Run 'app' before disconnecting.
- Confirm that the intent filter for USB_DEVICE_ATTACHED is inside the main <activity> tag, not inside the <application> tag directly.
- Verify your phone supports USB host mode. Most phones with USB-C do, but some budget devices may not.
- Try unplugging and replugging the DAC. Some DACs need a second connection attempt after the app is freshly installed.
Need more help? Help me troubleshoot why the USB permission dialog is not appearing when I connect my DAC.
DAC still showing 48 kHz?
- Check that you granted USB permission when the dialog appeared. If you denied it, unplug and replug the DAC to trigger the dialog again.
- Make sure you're playing a hi-res FLAC file, not a compressed format. A 44.1 kHz MP3 will show 44.1 kHz on the DAC, which is correct bit-perfect behavior but less visually distinctive than 96 kHz.
- Verify that the UsbAudioSink integration from Step 4 is still intact. Search Logcat for messages containing "UsbAudio" to confirm the sink is active.
Still not working? My USB DAC is connected and PixelPlayer has permission, but the DAC still shows 48 kHz output instead of the file's native sample rate.
Secret mission
Add a DAC Status Indicator to the Now Playing Screen
Your USB DAC is delivering bit-perfect audio, but the Now Playing screen gives zero visual feedback about what is happening under the hood. In this mission, you will build a reactive status card that displays the live connection state, sample rate, bit depth, and active audio path directly on the Now Playing screen using Jetpack Compose and StateFlow.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local Linux machine with no cloud resources or paid services. There are no ongoing costs.
Decide whether to keep your resources as they are, free up some memory, or delete everything entirely.
Resources you used:
- Android Studio Quail 2 installation (extracted on your Linux machine).
- Android SDK and build tools (installed by the Setup Wizard).
- PixelPlayer/ repository (cloned to your workspace directory).
- decent-player/ repository (cloned to your workspace directory).
Keep everything running
No action needed. Choose this if you are still actively experimenting with PixelPlayer's USB audio pipeline or planning to add more features.
- Your cloned repositories, Android Studio installation, and all code modifications remain on disk.
- Everything is local, so nothing is costing you money while it sits idle.
Pause - I'll come back to this later
Close Android Studio to free up memory. Your repositories and all code modifications remain on disk for when you return.
- Close Android Studio by selecting File > Exit.
- Disconnect your USB DAC from your phone if it is still connected.
- When you return, reopen Android Studio and open the PixelPlayer/ project. Everything will be right where you left it.
Delete - I don't want to use this again
Remove all project files, repositories, and tools to start fresh.
- Before closing Android Studio, note the SDK location. Select File > Settings > Languages & Frameworks > Android SDK and copy the path shown under Android SDK Location.
- Close Android Studio by selecting File > Exit.
- Open a terminal and navigate to the directory where you cloned both repositories in Step 1. Remove them by running:
rm -rf PixelPlayer/ decent-player/
- Navigate to the directory where you extracted Android Studio (the location you chose in Step 1, e.g. /opt/). Remove it by running:
rm -rf android-studio/
- Remove the Android SDK directory using the path you copied from Android Studio's settings:
rm -rf ~/Android/Sdk
Adjust the SDK path
The path above is the default SDK location on most Linux setups. If your SDK path from the settings page was different, use that path instead.
- Confirm everything is removed by running ls in each location where you deleted files.
Nice Work!
Nice Work!
You just took a polished, 6k-star Android music player and rewired its entire audio output path. Your copy of PixelPlayer now sends audio straight from ExoPlayer to your USB DAC via native C++ isochronous transfers, bypassing every layer of Android's audio stack.
You've learned how to:
- Navigate a production MVVM codebase and integrate three third-party native C++/JNI library modules into an Android app using Gradle composite builds.
- Wire a custom ForwardingAudioSink into the Media3 ExoPlayer pipeline that routes audio through a bit-perfect USB path, completely bypassing AudioFlinger, AudioTrack, AAudio, ALSA, and the kernel snd-usb-audio driver.
- Add USB device detection and permission handling that claims DACs within milliseconds of connection, and verify bit-perfect output by confirming the source sample rate on your DAC's display.
- Secret Mission: Build a reactive DAC status indicator using Jetpack Compose and StateFlow that displays live sample rate, bit depth, and audio path information on the Now Playing screen.
Ready to quiz yourself?