Skip to content
Merged
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
13 changes: 13 additions & 0 deletions app/src/main/java/com/lastwave/app/playback/ExclusivePcmGain.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
19 changes: 6 additions & 13 deletions app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Int> {
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)
}
}
}
}