Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions engine/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

<application>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ class AaudioAudioSink(
/** Channel count of the most recently configured format. */
private var currentChannelCount: Int = 0

/**
* The sample rate from the very last [configure] call, regardless of whether AAudio
* is active. This is different from [currentSampleRate], which only updates when an
* AAudio stream is successfully created. We need this separate value to detect rate
* changes even on the non-AAudio path and to know when to flush the delegate.
*/
private var lastConfiguredSampleRate: Int = 0

/**
* Volume last requested by ExoPlayer. Stored so the AudioTrack volume can be
* un-muted correctly if AAudio is later disabled between configure calls.
Expand Down Expand Up @@ -87,6 +95,10 @@ class AaudioAudioSink(
val sr = inputFormat.sampleRate.takeIf { it > 0 } ?: return
val ch = inputFormat.channelCount.takeIf { it > 0 } ?: return

val previousRate = lastConfiguredSampleRate
lastConfiguredSampleRate = sr
val sampleRateChanged = previousRate > 0 && sr != previousRate

if (!AudioPreferences.isAaudioEnabled()) {
/**
* AAudio is off. If a stale stream exists from a previous session,
Expand All @@ -95,6 +107,21 @@ class AaudioAudioSink(
if (aaudioStream != null) {
releaseAaudioStream()
}

/**
* Even without AAudio, we need to make sure the DefaultAudioSink tears down
* its AudioTrack when the sample rate changes. Without this, ExoPlayer's
* gapless path keeps the same AudioTrack running and internally resamples the
* new track's audio to fit the old rate. The USB DAC never sees a rate change
* and its indicator light stays stuck on the previous rate's color.
*
* Flushing the delegate here signals it to discard the stale buffer and
* recreate the AudioTrack at the new rate on the next handleBuffer() call.
*/
if (sampleRateChanged) {
super.flush()
Log.i(TAG, "Non-AAudio path: sample rate changed ($previousRate → $sr Hz) — flushed delegate to force AudioTrack recreation")
}
return
}

Expand All @@ -117,6 +144,27 @@ class AaudioAudioSink(
currentChannelCount = ch
isStreamActive = true

/**
* When the sample rate changes, the muted DefaultAudioSink AudioTrack is
* still open at the OLD rate. AudioFlinger locks the USB HAL output to that
* old rate, so the newly-created AAudio stream at the new rate either fails
* to get exclusive access or has its audio resampled to the old rate by
* AudioFlinger before it reaches the DAC. Either way the DAC's indicator
* light never updates.
*
* Flushing the delegate here forces it to close the stale AudioTrack and
* recreate it at the new sample rate on the next handleBuffer() call. Once
* the old AudioTrack is gone, AudioFlinger can re-negotiate the HAL rate
* with the USB DAC and the indicator light will switch correctly.
*
* The delegate is muted (volume = 0) so there is no audio in its buffer
* that matters discarding it has zero audible impact.
*/
if (sampleRateChanged) {
super.flush()
Log.i(TAG, "AAudio path: sample rate changed ($previousRate → $sr Hz) — flushed delegate to free USB HAL rate lock")
}

/**
* Mute the delegate AudioTrack immediately so only the AAudio stream
* produces audible output. Without this, both the AudioTrack and the
Expand Down Expand Up @@ -231,6 +279,7 @@ class AaudioAudioSink(
override fun reset() {
super.reset()
releaseAaudioStream()
resetRateTracking()
}

/** Releases the native stream, then the delegate. */
Expand Down Expand Up @@ -309,6 +358,16 @@ class AaudioAudioSink(
Log.i(TAG, "AAudio stream released")
}

/**
* Resets all format tracking so the next [configure] call is treated as a fresh start.
* Called from [reset] so that after a full player teardown the rate-change detection
* starts clean and does not accidentally flush the delegate on the very first configure
* of a new playback session.
*/
private fun resetRateTracking() {
lastConfiguredSampleRate = 0
}

/**
* Mutes the delegate [DefaultAudioSink] (volume = 0) so only the AAudio stream
* is audible. No-op if already muted.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioMixerAttributes
import android.media.AudioTrack
import android.os.Build
import android.os.Bundle
Expand Down Expand Up @@ -716,6 +717,112 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
)
player.addListener(playerListener)
player.addAnalyticsListener(analyticsListener)

}

/**
* Tells AudioFlinger to use bit-perfect mixing for the currently connected USB DAC at
* the given sample rate. This is the key call that makes the FiiO KA1 (or any USB DAC)
* actually switch its indicator LED — AudioFlinger re-negotiates the USB audio endpoint
* at exactly [sampleRate] Hz instead of silently resampling to whatever rate the HAL
* was already running at.
*
* The method is a no-op when:
* - The Android version is below 14 (API 34), because [AudioMixerAttributes] doesn't exist.
* - No USB output device is active (wired headphones, Bluetooth, or built-in speaker don't
* need explicit bit-perfect negotiation the same way).
* - [sampleRate] is zero or negative (format not yet known).
*
* Any previously registered attributes for the same device are automatically replaced
* because [AudioManager.setPreferredMixerAttributes] is idempotent per device.
*/
@Suppress("NewApi")
private fun applyBitPerfectMixerAttributes(sampleRate: Int, pcmEncoding: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
if (sampleRate <= 0) return

val usbDevice = currentOutputDevice ?: return
if (usbDevice.type != AudioDeviceInfo.TYPE_USB_DEVICE &&
usbDevice.type != AudioDeviceInfo.TYPE_USB_HEADSET) {

Log.d(TAG, "Active output device '${usbDevice.productName}' is not USB, skipping bit-perfect mixer configuration.")
return
}

try {
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager

/**
* AudioManager.setPreferredMixerAttributes requires three things:
* - The AudioAttributes that identify this playback stream (matches what ExoPlayer uses).
* - The specific output device to configure.
* - The AudioMixerAttributes describing the desired mixer behavior.
*
* Using MIXER_BEHAVIOR_BIT_PERFECT tells AudioFlinger to serve this stream without
* resampling — the USB audio endpoint is re-negotiated to the exact sample rate we
* provide, which is what flips the FiiO KA1's LED to the right color.
*
* The encoding in the AudioFormat MUST exactly match what the AudioTrack is opened
* with — AudioFlinger rejects a bit-perfect request if the format doesn't agree.
* We map the ExoPlayer C.ENCODING_* constant to the corresponding AudioFormat
* constant here rather than hardcoding ENCODING_PCM_FLOAT, which would break
* 16-bit and 24-bit tracks.
*/
val afEncoding = when (pcmEncoding) {
C.ENCODING_PCM_FLOAT, C.ENCODING_PCM_32BIT -> AudioFormat.ENCODING_PCM_FLOAT
C.ENCODING_PCM_24BIT -> AudioFormat.ENCODING_PCM_24BIT_PACKED
else -> AudioFormat.ENCODING_PCM_16BIT
}

val streamAttributes = android.media.AudioAttributes.Builder()
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
.build()

val format = AudioFormat.Builder()
.setEncoding(afEncoding)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build()

val mixerAttrs = AudioMixerAttributes.Builder(format)
.setMixerBehavior(AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT)
.build()

audioManager.setPreferredMixerAttributes(streamAttributes, usbDevice, mixerAttrs)
val encodingLabel = when (afEncoding) {
AudioFormat.ENCODING_PCM_FLOAT -> "Float32"
AudioFormat.ENCODING_PCM_24BIT_PACKED -> "PCM24"
else -> "PCM16"
}
Log.i(TAG, "Bit-perfect mixer set: ${sampleRate}Hz / $encodingLabel on '${usbDevice.productName}'")
} catch (e: Exception) {
Log.w(TAG, "Could not set bit-perfect mixer attributes: ${e.message}")
}
}

/**
* Removes the bit-perfect mixer attributes that were set for [device] so AudioFlinger
* goes back to its normal mixing policy. Called when the device is disconnected or when
* the service shuts down, to avoid leaving stale routing hints in the system.
*/
@Suppress("NewApi")
private fun clearBitPerfectMixerAttributes(device: AudioDeviceInfo?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
device ?: return
try {
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager

val streamAttributes = android.media.AudioAttributes.Builder()
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
.build()

audioManager.clearPreferredMixerAttributes(streamAttributes, device)
Log.i(TAG, "Bit-perfect mixer attributes cleared for '${device.productName}'")
} catch (e: Exception) {
Log.w(TAG, "Could not clear bit-perfect mixer attributes: ${e.message}")
}
}

/**
Expand Down Expand Up @@ -1173,6 +1280,16 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
) {
currentAudioInputFormat = format
Log.d(TAG, "Audio input format changed: ${format.sampleMimeType} @ ${format.sampleRate}Hz")

/**
* This is the right moment to re-negotiate the USB DAC's sample rate. The format
* carries the actual source sample rate for the incoming track, so we pass it
* straight to [applyBitPerfectMixerAttributes]. On Android 14+ this triggers
* AudioFlinger to reconfigure the USB audio endpoint at the new rate — which is
* exactly what tells the FiiO KA1 (and other USB DACs) to switch its LED color.
*/
applyBitPerfectMixerAttributes(format.sampleRate, format.pcmEncoding)

buildAndPushSnapshot()
}
}
Expand All @@ -1187,10 +1304,32 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
override fun onAudioDevicesAdded(addedDevices: Array<AudioDeviceInfo>) {
currentOutputDevice = detectActiveOutputDevice()
Log.d(TAG, "Audio device added: ${addedDevices.firstOrNull()?.productName}")

/**
* Re-apply bit-perfect attributes whenever a device is added — specifically so
* that plugging in a USB DAC mid-session immediately enables bit-perfect routing
* at whatever rate the current track is using.
*/
val sampleRate = currentAudioInputFormat?.sampleRate ?: 0
val pcmEncoding = currentAudioInputFormat?.pcmEncoding ?: C.ENCODING_PCM_16BIT
applyBitPerfectMixerAttributes(sampleRate, pcmEncoding)

buildAndPushSnapshot()
}

override fun onAudioDevicesRemoved(removedDevices: Array<AudioDeviceInfo>) {
/**
* Clear the bit-perfect routing hint for any removed USB device before we update
* [currentOutputDevice]. If we updated first, we'd lose the reference to the device
* that was just unplugged and couldn't pass it to [clearBitPerfectMixerAttributes].
*/
removedDevices.forEach { removedDevice ->
if (removedDevice.type == AudioDeviceInfo.TYPE_USB_DEVICE ||
removedDevice.type == AudioDeviceInfo.TYPE_USB_HEADSET) {
clearBitPerfectMixerAttributes(removedDevice)
}
}

currentOutputDevice = detectActiveOutputDevice()
Log.d(TAG, "Audio device removed: ${removedDevices.firstOrNull()?.productName}")
buildAndPushSnapshot()
Expand Down Expand Up @@ -1341,6 +1480,10 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)

// Remove any bit-perfect routing hint we set for the USB DAC so AudioFlinger
// goes back to its default mixing policy after the service is gone.
clearBitPerfectMixerAttributes(currentOutputDevice)

// Clear the snapshot so observers know the pipeline is no longer active.
AudioPipelineManager.updateSnapshot(null)

Expand Down Expand Up @@ -1504,8 +1647,13 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
}
val stereoExpandPercent = (EqualizerPreferences.getStereoWidth() * 100).roundToInt()

// Buffer and latency estimation from actual AudioTrack minimum buffer size
val (buffersStr, latencyEstimateMs) = computeBufferInfo(dspInputFormat)
// Buffer and latency estimation using the effective sample rate (the one we already
// resolved above). This matters during track transitions: `dspInputFormat` briefly holds
// the previous track's rate (44100Hz) while ExoPlayer fires onAudioInputFormatChanged
// before the audio processor chain reconfigures. Using `dspSampleRateHz` here ensures
// the latency estimate is computed at the actual new rate (e.g. 96000Hz) rather than
// the stale 44100Hz, so the native DSP delay ring buffer is seeded correctly.
val (buffersStr, latencyEstimateMs) = computeBufferInfo(dspInputFormat, dspSampleRateHz)

// Forward the current pipeline latency to the native DSP engine so the FFT
// visualizer pre-delays its input by exactly this amount. This call covers all
Expand Down Expand Up @@ -1675,11 +1823,21 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
* DSP sample rate, then estimates end-to-end latency as twice the buffer duration
* plus a fixed 15 ms hardware/driver overhead.
*
* @param dspInputFormat The [AudioProcessor.AudioFormat] currently active in [NativeDspAudioProcessor].
* [effectiveSampleRate] takes priority over [dspInputFormat].sampleRate when provided.
* This matters during track transitions when [dspInputFormat] may still hold the previous
* track's rate while the resolved "effective" rate already reflects the new track.
*
* @param dspInputFormat The [AudioProcessor.AudioFormat] currently active in [NativeDspAudioProcessor].
* @param effectiveSampleRate Override sample rate in Hz; 0 means "use [dspInputFormat].sampleRate".
* @return A pair of (human-readable buffer string, estimated latency in ms).
*/
private fun computeBufferInfo(dspInputFormat: AudioProcessor.AudioFormat): Pair<String, Int> {
val sr = dspInputFormat.sampleRate.takeIf { it > 0 } ?: 44100
private fun computeBufferInfo(
dspInputFormat: AudioProcessor.AudioFormat,
effectiveSampleRate: Int = 0,
): Pair<String, Int> {
val sr = effectiveSampleRate.takeIf { it > 0 }
?: dspInputFormat.sampleRate.takeIf { it > 0 }
?: 44100
val ch = dspInputFormat.channelCount.takeIf { it > 0 } ?: 2

val channelConfig = if (ch == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
Expand Down Expand Up @@ -2058,4 +2216,6 @@ class FelicityPlayerService : MediaLibraryService(), SharedPreferences.OnSharedP
/** Custom session command sent when the user taps the favorite button in the notification. */
const val COMMAND_TOGGLE_FAVORITE = "app.simple.felicity.TOGGLE_FAVORITE"
}
}
}


8 changes: 7 additions & 1 deletion repository/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,10 @@
-keep class org.jaudiotagger.** { *; }

# Also keep anything that implements them, just to be safe
-keepclassmembers class org.jaudiotagger.** { *; }
-keepclassmembers class org.jaudiotagger.** { *; }

# TagLibMetadata is instantiated directly from JNI (native C++ code) by calling
# its constructor via reflection-like JNI lookup. R8 has no way to detect this
# usage during shrinking, so without this rule it renames or removes the class
# and its constructor, causing a NoSuchMethodError at runtime in release builds.
-keep class app.simple.felicity.repository.metadata.TagLibMetadata { *; }
1 change: 1 addition & 0 deletions repository/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

Loading