diff --git a/app/src/main/java/com/lastwave/app/playback/ExclusivePcmGain.kt b/app/src/main/java/com/lastwave/app/playback/ExclusivePcmGain.kt new file mode 100644 index 000000000..5a45ea2e8 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/playback/ExclusivePcmGain.kt @@ -0,0 +1,13 @@ +package com.lastwave.app.playback + +/** Software gain shared by all PCM formats entering the exclusive USB queue. */ +internal object ExclusivePcmGain { + fun apply(samples: FloatArray, gain: Float) { + if (!gain.isFinite()) return + val normalized = gain.coerceIn(0f, 1f) + if (normalized >= 1f - 1e-6f) return + for (index in samples.indices) { + samples[index] = (samples[index] * normalized).coerceIn(-1f, 1f) + } + } +} diff --git a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt index 11fe20ffa..7ddb1e622 100644 --- a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt +++ b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt @@ -360,11 +360,7 @@ class ExclusiveUsbOutput @Inject constructor( val floats = FloatArray(size / Float.SIZE_BYTES) buffer.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(floats) buffer.position(buffer.limit()) - if (gain < 1f - 1e-6f && gain >= 0f) { - for (i in floats.indices) { - floats[i] = (floats[i] * gain).coerceIn(-1f, 1f) - } - } + ExclusivePcmGain.apply(floats, gain) QueuedPcm(floats, null, 0, size) } else { val encoding = writeRawEncoding(sourceEncoding) @@ -1027,8 +1023,9 @@ class ExclusiveUsbOutput @Inject constructor( RAW_PCM16 -> { var i = 0 while (i + 1 < bytes.size) { - val sample = (bytes[i].toInt() and 0xFF) or (bytes[i + 1].toInt() shl 8) - val scaled = (sample.toShort() * gain + if (sample >= 0) 0.5f else -0.5f).toInt() + val sample = ((bytes[i].toInt() and 0xFF) or (bytes[i + 1].toInt() shl 8)) + .toShort().toInt() + val scaled = (sample * gain + if (sample >= 0) 0.5f else -0.5f).toInt() .coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()) bytes[i] = (scaled and 0xFF).toByte() bytes[i + 1] = ((scaled shr 8) and 0xFF).toByte() diff --git a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt index dad30b5e9..252bbab19 100644 --- a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt +++ b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt @@ -3337,19 +3337,12 @@ class MusicPlayer @Inject constructor( // The signal path stays honest automatically — the resampler check // fails, so a converted track can never report gold. val usbRates = exclusiveUsbOutput.supportedHardwareRatesHz() - val platformRates = dac?.sampleRatesHz.orEmpty() - val dacRates = when { - usbRates.isNotEmpty() && platformRates.isNotEmpty() -> - usbRates.filter { it in platformRates }.ifEmpty { usbRates } - usbRates.isNotEmpty() -> usbRates - platformRates.isNotEmpty() -> platformRates - else -> { - val known = exclusiveUsbOutput.lastHardwareRateHz() - if (known > 0) listOf(known) else emptyList() - } - } - val fallbackHz = if (exclusiveWanted && dac != null) { - selectExclusiveRateFallback(effectiveRateHz, dacRates) + // Android AudioDeviceInfo rates describe the mixer route and may be + // incomplete for usbdevfs exclusive access. Only an open USB descriptor + // query may pre-select an exclusive fallback; otherwise let configure() + // attempt the source clock and verify it with the DAC handshake. + val fallbackHz = if (exclusiveWanted && dac != null && usbRates.isNotEmpty()) { + selectExclusiveRateFallback(effectiveRateHz, usbRates) } else { null } diff --git a/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt b/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt index e30591645..626cc3cce 100644 --- a/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt +++ b/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt @@ -1268,20 +1268,14 @@ class NativeProcessingAudioSink( // owns the worst case — never feed unconverted bytes. if (!setupExclusiveConverter(source, target)) return false } - // Volume parity with the raw exclusive path (keys must work here - // too); unity otherwise. Scaled once per fresh chunk — stashed - // chunks are never re-scaled on retry. - val session = exclusiveUsb - val gain = session?.softwareGain() ?: lastVolume + // The exclusive USB writer owns software gain for both raw and + // resampled PCM. Applying it here too attenuates fallback PCM twice. processor.queueInput(buffer.duplicate()) buffer.position(buffer.limit()) var guard = 0 while (guard++ < 32) { val out = processor.getOutput() if (out === AudioProcessor.EMPTY_BUFFER || !out.hasRemaining()) break - if (gain < 1f - 1e-6f && gain >= 0f) { - scalePcmInPlace(out, out.position(), out.limit(), C.ENCODING_PCM_FLOAT, gain) - } if (!writeConvertedToUsb(out, presentationTimeUs)) { pendingUsbFloat = out pendingUsbPtsUs = presentationTimeUs diff --git a/app/src/test/java/com/lastwave/app/playback/ExclusivePcmGainTest.kt b/app/src/test/java/com/lastwave/app/playback/ExclusivePcmGainTest.kt new file mode 100644 index 000000000..2599a9ff8 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/playback/ExclusivePcmGainTest.kt @@ -0,0 +1,27 @@ +package com.lastwave.app.playback + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ExclusivePcmGainTest { + @Test + fun fallbackFloatPcmReceivesSoftwareGainAtExclusiveWriterOnce() { + val convertedPcm = floatArrayOf(0.5f, -0.5f, 1.0f, -1.0f) + + // Fallback conversion feeds this shared exclusive-writer stage. The + // converter itself must leave samples unscaled to avoid gain squared. + ExclusivePcmGain.apply(convertedPcm, 0.5f) + + assertThat(convertedPcm.toList()).containsExactly(0.25f, -0.25f, 0.5f, -0.5f).inOrder() + } + + @Test + fun unityGainPreservesConvertedFloatSamplesExactly() { + val convertedPcm = floatArrayOf(0.25f, -0.75f, 1.0f) + val original = convertedPcm.copyOf() + + ExclusivePcmGain.apply(convertedPcm, 1f) + + assertThat(convertedPcm.toList()).containsExactlyElementsIn(original.toList()).inOrder() + } +} diff --git a/app/src/test/java/com/lastwave/app/playback/UsbSupportedSampleRatesTest.kt b/app/src/test/java/com/lastwave/app/playback/UsbSupportedSampleRatesTest.kt new file mode 100644 index 000000000..d5d9e7b00 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/playback/UsbSupportedSampleRatesTest.kt @@ -0,0 +1,34 @@ +package com.lastwave.app.playback + +import com.decent.usbaudio.supportedStandardRatesInRange +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UsbSupportedSampleRatesTest { + @Test + fun uac1ContinuousFormatRangeIncludes44100And48000() { + val rates = supportedStandardRatesInRange(8_000, 192_000, 0) + + assertThat(rates).containsAtLeast(44_100, 48_000) + } + + @Test + fun uac2ContinuousClockRangeIncludes44100And48000() { + val rates = supportedStandardRatesInRange(8_000, 384_000, 0) + + assertThat(rates).containsAtLeast(44_100, 48_000) + } + + @Test + fun uac2DiscreteResolutionIncludesOnlyAlignedRatesWithinRange() { + val rates = supportedStandardRatesInRange(8_000, 96_000, 100) + + assertThat(rates).containsAtLeast(44_100, 48_000, 88_200, 96_000) + assertThat(rates).doesNotContain(176_400) + } + + @Test + fun malformedRangeDoesNotAdvertiseRates() { + assertThat(supportedStandardRatesInRange(96_000, 8_000, 0)).isEmpty() + } +} diff --git a/audio/decent-usb-audio-driver/src/main/kotlin/com/decent/usbaudio/UsbAudioDevice.kt b/audio/decent-usb-audio-driver/src/main/kotlin/com/decent/usbaudio/UsbAudioDevice.kt index c2511f2b0..15569d3fd 100644 --- a/audio/decent-usb-audio-driver/src/main/kotlin/com/decent/usbaudio/UsbAudioDevice.kt +++ b/audio/decent-usb-audio-driver/src/main/kotlin/com/decent/usbaudio/UsbAudioDevice.kt @@ -738,9 +738,6 @@ class UsbAudioDevice private constructor(private val context: Context) { if (detectedId > 0) add(detectedId) addAll(listOf(0x05, 0x09, 0x0A, 0x0B, 0x0C, 0x28, 0x29, 0x06, 0x07, 0x08, 0x10, 0x20)) }.distinct() - val standardRates = intArrayOf( - 44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000, 705600, 768000 - ) val rangeBuf = ByteArray(258) val stopOnFirstHit = parsedClockSourceIds.size <= 1 for (csId in candidateIds) { @@ -764,25 +761,7 @@ class UsbAudioDevice private constructor(private val context: Context) { val dMax = readLeInt32(rangeBuf, base + 4) val dRes = readLeInt32(rangeBuf, base + 8) if (dMin in 8000..1536000 && dMax >= dMin) { - if (dMin == dMax || dRes <= 0) { - csRates += dMin - } else if (dRes == 44100 || (dMin % 44100 == 0 && dMax % 44100 == 0)) { - for (std in standardRates) { - if (std % 44100 == 0 && std in dMin..dMax) csRates += std - } - } else if (dRes == 48000 || (dMin % 48000 == 0 && dMax % 48000 == 0)) { - for (std in standardRates) { - if (std % 48000 == 0 && std in dMin..dMax) csRates += std - } - } else { - csRates += dMin - csRates += dMax - for (std in standardRates) { - if (std % 48000 == 0 && std in dMin..dMax && ((std - dMin) % dRes == 0)) { - csRates += std - } - } - } + csRates += supportedStandardRatesInRange(dMin, dMax, dRes) } } if (csRates.isNotEmpty()) { @@ -816,9 +795,7 @@ class UsbAudioDevice private constructor(private val context: Context) { val upper = readLeInt24(raw, i + 11) if (lower in 8000..1536000) rates += lower if (upper in 8000..1536000) rates += upper - for (std in standardRates) { - if (std % 48000 == 0 && std in lower..upper) rates += std - } + rates += supportedStandardRatesInRange(lower, upper, 0) } else if (samFreqType > 0) { for (k in 0 until samFreqType) { val off = i + 8 + k * 3 @@ -1161,3 +1138,26 @@ class UsbAudioDevice private constructor(private val context: Context) { } } + +/** + * Expands a UAC clock/format rate interval to common sample rates that the + * interval actually permits. A zero resolution means the interval is + * continuous; a positive resolution admits rates aligned from [minimumHz]. + */ +fun supportedStandardRatesInRange(minimumHz: Int, maximumHz: Int, resolutionHz: Int): Set { + if (minimumHz <= 0 || maximumHz < minimumHz) return emptySet() + val standardRates = intArrayOf( + 44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000, 705600, 768000, + ) + return buildSet { + add(minimumHz) + add(maximumHz) + for (rate in standardRates) { + if (rate in minimumHz..maximumHz && + (resolutionHz <= 0 || (rate - minimumHz) % resolutionHz == 0) + ) { + add(rate) + } + } + } +}