From 33d30242c1ca6d28ed618dcd2197e7b15b44f40d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 06:42:17 +0000 Subject: [PATCH 1/2] Add bit-perfect audio playback support for Android 14+ Implements bit-perfect audio playback for USB DACs on Android 14+ devices, addressing issue #157. This feature allows audio to be sent directly to external USB DACs without resampling, mixing, or processing by Android's audio system, preserving maximum audio quality for high-resolution files. Changes: - Add BitPerfectManager to detect USB DAC connections and configure bit-perfect mode using AudioMixerAttributes API - Add MODIFY_AUDIO_SETTINGS permission required for audio configuration - Add user preference toggle in Playback settings - Integrate with existing playback architecture via dependency injection - Automatically activates when USB DAC is connected and preference is enabled - Requires Android 14 (API 34) or higher The implementation monitors USB device attach/detach events and queries supported mixer attributes to configure the optimal bit-perfect playback mode for connected DACs. --- .../appinitializers/PlaybackInitializer.kt | 2 + .../src/main/res/values/strings_settings.xml | 4 + .../src/main/res/xml/preferences_playback.xml | 7 + android/playback/src/main/AndroidManifest.xml | 3 + .../playback/BitPerfectManager.kt | 236 ++++++++++++++++++ .../playback/di/PlaybackModule.kt | 9 + .../persistence/PlaybackPreferenceManager.kt | 8 + 7 files changed, 269 insertions(+) create mode 100644 android/playback/src/main/java/com/simplecityapps/playback/BitPerfectManager.kt diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt b/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt index a4b1afd5f..ed4160ddc 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import androidx.core.content.ContextCompat import com.simplecityapps.mediaprovider.repository.songs.SongRepository +import com.simplecityapps.playback.BitPerfectManager import com.simplecityapps.playback.NoiseManager import com.simplecityapps.playback.PlaybackManager import com.simplecityapps.playback.PlaybackService @@ -47,6 +48,7 @@ constructor( @Suppress("unused") private val castSessionManager: CastSessionManager, @Suppress("unused") private val mediaSessionManager: MediaSessionManager, @Suppress("unused") private val noiseManager: NoiseManager, + @Suppress("unused") private val bitPerfectManager: BitPerfectManager, @AppCoroutineScope private val appCoroutineScope: CoroutineScope ) : AppInitializer, QueueChangeCallback, diff --git a/android/app/src/main/res/values/strings_settings.xml b/android/app/src/main/res/values/strings_settings.xml index ff0970c93..6304454a8 100644 --- a/android/app/src/main/res/values/strings_settings.xml +++ b/android/app/src/main/res/values/strings_settings.xml @@ -138,6 +138,10 @@ Keep shuffle mode When a new queue is selected, shuffle mode won\'t be disabled + + Bit-perfect audio + + Send audio directly to USB DACs without processing (Android 14+) Day/Night diff --git a/android/app/src/main/res/xml/preferences_playback.xml b/android/app/src/main/res/xml/preferences_playback.xml index 953d3f140..7e1f76865 100644 --- a/android/app/src/main/res/xml/preferences_playback.xml +++ b/android/app/src/main/res/xml/preferences_playback.xml @@ -9,4 +9,11 @@ android:title="@string/pref_disable_shuffle_on_queue_title" app:iconSpaceReserved="false" /> + + \ No newline at end of file diff --git a/android/playback/src/main/AndroidManifest.xml b/android/playback/src/main/AndroidManifest.xml index 658c03799..ec1dfe841 100644 --- a/android/playback/src/main/AndroidManifest.xml +++ b/android/playback/src/main/AndroidManifest.xml @@ -7,6 +7,9 @@ + + + { + Log.d(TAG, "USB device attached") + checkAndConfigureBitPerfect() + } + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + Log.d(TAG, "USB device detached") + disableBitPerfect() + } + AudioManager.ACTION_HEADSET_PLUG -> { + // Also monitor headset plug events which can indicate USB DAC connection + val state = intent.getIntExtra("state", -1) + if (state == 1) { + Log.d(TAG, "Headset plugged in") + checkAndConfigureBitPerfect() + } else if (state == 0) { + Log.d(TAG, "Headset unplugged") + disableBitPerfect() + } + } + } + } + } + + init { + playbackWatcher.addCallback(this) + registerReceivers() + + // Check if a USB DAC is already connected + checkAndConfigureBitPerfect() + } + + private fun registerReceivers() { + val filter = IntentFilter().apply { + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + addAction(AudioManager.ACTION_HEADSET_PLUG) + } + context.registerReceiver(usbReceiver, filter) + } + + fun cleanup() { + context.safelyUnregisterReceiver(usbReceiver) + disableBitPerfect() + } + + private fun checkAndConfigureBitPerfect() { + if (!playbackPreferenceManager.bitPerfectEnabled) { + Log.d(TAG, "Bit-perfect mode disabled in preferences") + return + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Log.d(TAG, "Bit-perfect mode requires Android 14+") + return + } + + findUsbAudioDevice()?.let { device -> + configureBitPerfect(device) + } ?: run { + Log.d(TAG, "No USB audio device found") + disableBitPerfect() + } + } + + private fun findUsbAudioDevice(): AudioDeviceInfo? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return null + } + + val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + return devices.firstOrNull { device -> + device.type == AudioDeviceInfo.TYPE_USB_DEVICE || + device.type == AudioDeviceInfo.TYPE_USB_HEADSET + } + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun configureBitPerfect(device: AudioDeviceInfo) { + try { + Log.d(TAG, "Configuring bit-perfect mode for device: ${device.productName}") + + // Query supported mixer attributes for the USB device + val supportedAttributes = audioManager.getSupportedMixerAttributes(device) + + if (supportedAttributes.isEmpty()) { + Log.w(TAG, "No supported mixer attributes for this device") + disableBitPerfect() + return + } + + // Find a bit-perfect compatible configuration + // Prefer high sample rates and bit depths + val bitPerfectAttribute = supportedAttributes + .filter { it.mixerBehavior == AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT } + .maxByOrNull { attr -> + // Score by sample rate * channel count + (attr.format.sampleRate ?: 0) * (attr.format.channelCount ?: 0) + } + + if (bitPerfectAttribute == null) { + Log.w(TAG, "Device doesn't support bit-perfect mode") + disableBitPerfect() + return + } + + // Create AudioAttributes for media playback + val audioAttributes = android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + + // Set the preferred mixer attributes + val result = audioManager.setPreferredMixerAttributes( + audioAttributes, + device, + bitPerfectAttribute + ) + + if (result == AudioManager.SUCCESS) { + isActive = true + currentUsbDevice = device + Log.i(TAG, "Bit-perfect mode activated: ${bitPerfectAttribute.format.sampleRate}Hz, " + + "${bitPerfectAttribute.format.channelCount}ch, " + + "encoding=${bitPerfectAttribute.format.encoding}") + + // Notify listeners that bit-perfect mode is active + notifyBitPerfectStateChanged(true, device) + } else { + Log.e(TAG, "Failed to set preferred mixer attributes") + disableBitPerfect() + } + + } catch (e: Exception) { + Log.e(TAG, "Error configuring bit-perfect mode", e) + disableBitPerfect() + } + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun disableBitPerfect() { + if (!isActive) { + return + } + + try { + val audioAttributes = android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + + currentUsbDevice?.let { device -> + audioManager.clearPreferredMixerAttributes(audioAttributes, device) + Log.i(TAG, "Bit-perfect mode deactivated") + } + } catch (e: Exception) { + Log.e(TAG, "Error clearing preferred mixer attributes", e) + } finally { + isActive = false + currentUsbDevice = null + notifyBitPerfectStateChanged(false, null) + } + } + + private fun notifyBitPerfectStateChanged(active: Boolean, device: AudioDeviceInfo?) { + // TODO: Implement notification to UI layer + // This could broadcast an intent or use a callback mechanism + // to notify the UI that bit-perfect mode is active/inactive + Log.d(TAG, "Bit-perfect state changed: active=$active, device=${device?.productName}") + } + + fun isActive(): Boolean = isActive + + fun getCurrentDevice(): AudioDeviceInfo? = currentUsbDevice + + // PlaybackWatcherCallback Implementation + + override fun onPlaybackStateChanged(playbackState: PlaybackState) { + // When playback starts, re-check and configure if needed + when (playbackState) { + is PlaybackState.Playing -> { + if (!isActive && playbackPreferenceManager.bitPerfectEnabled) { + checkAndConfigureBitPerfect() + } + } + else -> { + // Keep bit-perfect active even when paused, so the configuration + // is ready when playback resumes + } + } + } + + override fun onPlaybackEnded() { + // Don't disable bit-perfect when a track ends, keep it active for the next track + } +} diff --git a/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt index 20d241eef..42914c6c1 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt @@ -15,6 +15,7 @@ import com.simplecityapps.mediaprovider.repository.genres.GenreRepository import com.simplecityapps.mediaprovider.repository.playlists.PlaylistRepository import com.simplecityapps.mediaprovider.repository.songs.SongRepository import com.simplecityapps.playback.AudioEffectSessionManager +import com.simplecityapps.playback.BitPerfectManager import com.simplecityapps.playback.NoiseManager import com.simplecityapps.playback.PlaybackManager import com.simplecityapps.playback.PlaybackNotificationManager @@ -225,6 +226,14 @@ class PlaybackModule { playbackWatcher: PlaybackWatcher ): NoiseManager = NoiseManager(context, playbackManager, playbackWatcher) + @Singleton + @Provides + fun provideBitPerfectManager( + @ApplicationContext context: Context, + playbackPreferenceManager: PlaybackPreferenceManager, + playbackWatcher: PlaybackWatcher + ): BitPerfectManager = BitPerfectManager(context, playbackPreferenceManager, playbackWatcher) + @Singleton @Provides fun providePlaybackNotificationManager( diff --git a/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt b/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt index 1ce7aaefc..175a3ed44 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt @@ -96,6 +96,14 @@ class PlaybackPreferenceManager( return sharedPreferences.get("equalizer_enabled", false) } + var bitPerfectEnabled: Boolean + set(value) { + sharedPreferences.put("bit_perfect_enabled", value) + } + get() { + return sharedPreferences.get("bit_perfect_enabled", false) + } + var replayGainMode: ReplayGainMode set(value) { sharedPreferences.put("replaygain_mode", value.ordinal) From c575b6e375243fbc1e55298ddf7ac93e73b4f9f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 06:49:38 +0000 Subject: [PATCH 2/2] Apply ktlint formatting to BitPerfectManager --- .../com/simplecityapps/playback/BitPerfectManager.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/android/playback/src/main/java/com/simplecityapps/playback/BitPerfectManager.kt b/android/playback/src/main/java/com/simplecityapps/playback/BitPerfectManager.kt index e41ea3309..5b5b381bd 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/BitPerfectManager.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/BitPerfectManager.kt @@ -4,10 +4,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import android.media.AudioDeviceInfo -import android.media.AudioFormat import android.media.AudioManager import android.media.AudioMixerAttributes import android.os.Build @@ -111,7 +109,7 @@ class BitPerfectManager( val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) return devices.firstOrNull { device -> device.type == AudioDeviceInfo.TYPE_USB_DEVICE || - device.type == AudioDeviceInfo.TYPE_USB_HEADSET + device.type == AudioDeviceInfo.TYPE_USB_HEADSET } } @@ -160,9 +158,12 @@ class BitPerfectManager( if (result == AudioManager.SUCCESS) { isActive = true currentUsbDevice = device - Log.i(TAG, "Bit-perfect mode activated: ${bitPerfectAttribute.format.sampleRate}Hz, " + + Log.i( + TAG, + "Bit-perfect mode activated: ${bitPerfectAttribute.format.sampleRate}Hz, " + "${bitPerfectAttribute.format.channelCount}ch, " + - "encoding=${bitPerfectAttribute.format.encoding}") + "encoding=${bitPerfectAttribute.format.encoding}" + ) // Notify listeners that bit-perfect mode is active notifyBitPerfectStateChanged(true, device) @@ -170,7 +171,6 @@ class BitPerfectManager( Log.e(TAG, "Failed to set preferred mixer attributes") disableBitPerfect() } - } catch (e: Exception) { Log.e(TAG, "Error configuring bit-perfect mode", e) disableBitPerfect()