diff --git a/audio-dsp-core/build.gradle.kts b/audio-dsp-core/build.gradle.kts new file mode 100644 index 00000000..04657aa3 --- /dev/null +++ b/audio-dsp-core/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("java-library") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.serialization") +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + api(project(":core:model")) + implementation(libs.kotlinx.serialization.json) + testImplementation(libs.junit) +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/AudioDspPlanCompiler.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/AudioDspPlanCompiler.kt new file mode 100644 index 00000000..5bea489c --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/AudioDspPlanCompiler.kt @@ -0,0 +1,92 @@ +package com.miruplay.tv.audio + +import com.miruplay.tv.model.AudioDspChannelRule +import com.miruplay.tv.model.AudioDspChannelTarget +import com.miruplay.tv.model.AudioDspPhaseMode +import com.miruplay.tv.model.AudioDspPreset +import com.miruplay.tv.model.AudioDspLimiter +import kotlin.math.pow + +data class CompiledDspPlan( + val sampleRateHz: Int, + val layout: ChannelLayout, + val phaseMode: AudioDspPhaseMode, + val outputMode: com.miruplay.tv.model.AudioDspOutputMode, + val biquadsByChannel: List>, + val firTapsByChannel: List, + val groupDelayFrames: Int, + val preampLinear: Float = 1f, + val channelGainLinear: FloatArray = FloatArray(layout.channelCount) { 1f }, + val limiter: AudioDspLimiter = AudioDspLimiter(), +) { + val outputChannelCount: Int + get() = if (outputMode == com.miruplay.tv.model.AudioDspOutputMode.AUTO_PRESERVE || layout.channelCount <= 2) { + layout.channelCount + } else { + 2 + } +} + +object AudioDspPlanCompiler { + private const val RESPONSE_BINS = 512 + + fun compile(preset: AudioDspPreset, layout: ChannelLayout, sampleRateHz: Int): CompiledDspPlan { + require(sampleRateHz > 0) { "sample rate must be positive" } + val normalized = preset.normalized() + val chains = layout.channels.map { channel -> + val rule = normalized.rules.firstOrNull { it.target.matches(channel, layout) } + ?: AudioDspChannelRule() + rule.bands.filter { it.enabled }.map { BiquadDesigner.design(it, sampleRateHz) } + } + val channelGainLinear = layout.channels.map { channel -> + val rule = normalized.rules.firstOrNull { it.target.matches(channel, layout) } + ?: AudioDspChannelRule() + 10.0.pow(rule.outputGainDb.toDouble() / 20.0).toFloat() + }.toFloatArray() + val fir = if (normalized.phaseMode == AudioDspPhaseMode.LINEAR) { + val frequencyGrid = FloatArray(RESPONSE_BINS) { index -> + index.toFloat() / (RESPONSE_BINS - 1) * sampleRateHz / 2f + } + chains.map { chain -> + val targetDb = frequencyGrid.map { frequency -> + var gain = 1.0 + chain.forEach { gain *= it.magnitudeAt(frequency.toDouble().coerceAtLeast(1.0), sampleRateHz.toDouble()) } + (20.0 * kotlin.math.log10(gain.coerceAtLeast(1e-12))).toFloat() + }.toFloatArray() + LinearPhaseFirDesigner.design(targetDb, sampleRateHz, normalized.firQuality.taps) + } + } else { + List(layout.channelCount) { FloatArray(0) } + } + return CompiledDspPlan( + sampleRateHz = sampleRateHz, + layout = layout, + phaseMode = normalized.phaseMode, + outputMode = normalized.outputMode, + biquadsByChannel = if (normalized.phaseMode == AudioDspPhaseMode.LINEAR) { + List(layout.channelCount) { emptyList() } + } else { + chains + }, + firTapsByChannel = fir, + groupDelayFrames = if (normalized.phaseMode == AudioDspPhaseMode.LINEAR) (normalized.firQuality.taps - 1) / 2 else 0, + preampLinear = 10.0.pow(normalized.preampDb.toDouble() / 20.0).toFloat(), + channelGainLinear = channelGainLinear, + limiter = normalized.limiter, + ) + } + + private fun AudioDspChannelTarget.matches(channel: Channel, layout: ChannelLayout): Boolean = when (this) { + AudioDspChannelTarget.ALL -> true + AudioDspChannelTarget.FRONT -> channel == Channel.L || channel == Channel.R || channel == Channel.C + AudioDspChannelTarget.CENTER_LFE -> channel == Channel.C || channel == Channel.LFE + AudioDspChannelTarget.SURROUND, AudioDspChannelTarget.SURROUND_5_1, AudioDspChannelTarget.SURROUND_7_1 -> + channel == Channel.LS || channel == Channel.RS || channel == Channel.LB || channel == Channel.RB + AudioDspChannelTarget.LEFT -> channel == Channel.L + AudioDspChannelTarget.RIGHT -> channel == Channel.R + AudioDspChannelTarget.CENTER -> channel == Channel.C + AudioDspChannelTarget.LFE -> channel == Channel.LFE + AudioDspChannelTarget.LEFT_SURROUND -> channel == Channel.LS || channel == Channel.LB + AudioDspChannelTarget.RIGHT_SURROUND -> channel == Channel.RS || channel == Channel.RB + } +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/BiquadDesigner.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/BiquadDesigner.kt new file mode 100644 index 00000000..25898e9f --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/BiquadDesigner.kt @@ -0,0 +1,78 @@ +package com.miruplay.tv.audio + +import com.miruplay.tv.model.AudioDspBand +import com.miruplay.tv.model.AudioDspFilterType +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +data class BiquadCoefficients( + val b0: Double, + val b1: Double, + val b2: Double, + val a1: Double, + val a2: Double, +) { + fun magnitudeAt(frequencyHz: Double, sampleRateHz: Double): Double { + val omega = 2.0 * Math.PI * frequencyHz / sampleRateHz + val cosW = cos(omega) + val sinW = sin(omega) + val nReal = b0 + b1 * cosW + b2 * cos(2.0 * omega) + val nImag = -b1 * sinW - b2 * sin(2.0 * omega) + val dReal = 1.0 + a1 * cosW + a2 * cos(2.0 * omega) + val dImag = -a1 * sinW - a2 * sin(2.0 * omega) + return sqrt((nReal * nReal + nImag * nImag) / (dReal * dReal + dImag * dImag)) + } +} + +object BiquadDesigner { + fun design(band: AudioDspBand, sampleRateHz: Int): BiquadCoefficients { + val normalized = band.normalized() + val omega = 2.0 * Math.PI * normalized.frequencyHz / sampleRateHz + val alpha = sin(omega) / (2.0 * normalized.q) + val cosW = cos(omega) + val gain = 10.0.pow(normalized.gainDb / 40.0) + val beta = 2.0 * sqrt(gain) * alpha + val raw = when (normalized.type) { + AudioDspFilterType.PEAKING -> doubleArrayOf( + 1.0 + alpha * gain, -2.0 * cosW, 1.0 - alpha * gain, + 1.0 + alpha / gain, -2.0 * cosW, 1.0 - alpha / gain, + ) + AudioDspFilterType.LOW_SHELF -> doubleArrayOf( + gain * ((gain + 1.0) - (gain - 1.0) * cosW + beta), + 2.0 * gain * ((gain - 1.0) - (gain + 1.0) * cosW), + gain * ((gain + 1.0) - (gain - 1.0) * cosW - beta), + (gain + 1.0) + (gain - 1.0) * cosW + beta, + -2.0 * ((gain - 1.0) + (gain + 1.0) * cosW), + (gain + 1.0) + (gain - 1.0) * cosW - beta, + ) + AudioDspFilterType.HIGH_SHELF -> doubleArrayOf( + gain * ((gain + 1.0) + (gain - 1.0) * cosW + beta), + -2.0 * gain * ((gain - 1.0) + (gain + 1.0) * cosW), + gain * ((gain + 1.0) + (gain - 1.0) * cosW - beta), + (gain + 1.0) - (gain - 1.0) * cosW + beta, + 2.0 * ((gain - 1.0) - (gain + 1.0) * cosW), + (gain + 1.0) - (gain - 1.0) * cosW - beta, + ) + AudioDspFilterType.LOW_PASS -> doubleArrayOf( + (1.0 - cosW) / 2.0, 1.0 - cosW, (1.0 - cosW) / 2.0, + 1.0 + alpha, -2.0 * cosW, 1.0 - alpha, + ) + AudioDspFilterType.HIGH_PASS -> doubleArrayOf( + (1.0 + cosW) / 2.0, -(1.0 + cosW), (1.0 + cosW) / 2.0, + 1.0 + alpha, -2.0 * cosW, 1.0 - alpha, + ) + AudioDspFilterType.NOTCH -> doubleArrayOf( + 1.0, -2.0 * cosW, 1.0, + 1.0 + alpha, -2.0 * cosW, 1.0 - alpha, + ) + AudioDspFilterType.BAND_PASS -> doubleArrayOf( + alpha, 0.0, -alpha, + 1.0 + alpha, -2.0 * cosW, 1.0 - alpha, + ) + } + val a0 = raw[3] + return BiquadCoefficients(raw[0] / a0, raw[1] / a0, raw[2] / a0, raw[4] / a0, raw[5] / a0) + } +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/ChannelLayout.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/ChannelLayout.kt new file mode 100644 index 00000000..6ffbeec4 --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/ChannelLayout.kt @@ -0,0 +1,60 @@ +package com.miruplay.tv.audio + +enum class Channel { L, R, C, LFE, LS, RS, LB, RB, MONO, UNKNOWN } + +enum class ChannelLayoutId { MONO, STEREO, SURROUND_5_1, SURROUND_7_1, UNKNOWN } + +enum class InputOrder { CANONICAL, AAC_5_1, WAV_5_1, UNKNOWN } + +data class ChannelLayout( + val id: ChannelLayoutId, + val channels: List, + val defaultInputOrder: InputOrder = InputOrder.UNKNOWN, +) { + val channelCount: Int get() = channels.size + + fun normalizeInterleaved(samples: FloatArray, inputOrder: InputOrder): FloatArray { + if (inputOrder == InputOrder.CANONICAL || channelCount < 6) return samples.copyOf() + val frames = samples.size / channelCount + if (frames * channelCount != samples.size) return samples.copyOf() + val order = when (inputOrder) { + InputOrder.AAC_5_1 -> intArrayOf(0, 1, 2, 5, 3, 4) + InputOrder.WAV_5_1 -> intArrayOf(0, 1, 2, 3, 4, 5) + else -> return samples.copyOf() + } + return FloatArray(samples.size) { index -> + val frame = index / channelCount + val outputChannel = index % channelCount + samples[frame * channelCount + order[outputChannel]] + } + } + + companion object { + const val ANDROID_5_1_MASK = 4 or 8 or 16 or 32 or 64 or 128 + const val ANDROID_7_1_MASK = ANDROID_5_1_MASK or 2_048 or 4_096 + + fun from(channelCount: Int, channelMask: Int?): ChannelLayout { + val known = when (channelMask) { + ANDROID_5_1_MASK -> ChannelLayoutId.SURROUND_5_1 + ANDROID_7_1_MASK -> ChannelLayoutId.SURROUND_7_1 + else -> when (channelCount) { + 1 -> ChannelLayoutId.MONO + 2 -> ChannelLayoutId.STEREO + 6 -> ChannelLayoutId.SURROUND_5_1 + 8 -> ChannelLayoutId.SURROUND_7_1 + else -> ChannelLayoutId.UNKNOWN + } + } + val channels = when (known) { + ChannelLayoutId.MONO -> listOf(Channel.MONO) + ChannelLayoutId.STEREO -> listOf(Channel.L, Channel.R) + ChannelLayoutId.SURROUND_5_1 -> listOf(Channel.L, Channel.R, Channel.C, Channel.LFE, Channel.LS, Channel.RS) + ChannelLayoutId.SURROUND_7_1 -> listOf( + Channel.L, Channel.R, Channel.C, Channel.LFE, Channel.LS, Channel.RS, Channel.LB, Channel.RB, + ) + ChannelLayoutId.UNKNOWN -> List(channelCount.coerceAtLeast(0)) { Channel.UNKNOWN } + } + return ChannelLayout(known, channels) + } + } +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/FrequencyResponse.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/FrequencyResponse.kt new file mode 100644 index 00000000..a1c51a46 --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/FrequencyResponse.kt @@ -0,0 +1,47 @@ +package com.miruplay.tv.audio + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.log10 +import kotlin.math.sin +import kotlin.math.sqrt + +data class ResponseCurve( + val frequenciesHz: FloatArray, + val magnitudeDb: FloatArray, + val phaseRadians: FloatArray, +) + +object FrequencyResponse { + fun sample(plan: CompiledDspPlan, frequenciesHz: FloatArray): ResponseCurve { + val magnitude = FloatArray(frequenciesHz.size) + val phase = FloatArray(frequenciesHz.size) + val channel = 0 + val chains = plan.biquadsByChannel.getOrNull(channel) ?: emptyList() + val fir = plan.firTapsByChannel.getOrNull(channel) ?: FloatArray(0) + val staticGain = plan.preampLinear * plan.channelGainLinear.getOrElse(channel) { 1f } + for (index in frequenciesHz.indices) { + val frequency = frequenciesHz[index].toDouble() + val omega = 2.0 * Math.PI * frequency / plan.sampleRateHz + var gain = staticGain.toDouble() + var phaseRadians = 0.0 + if (fir.isNotEmpty()) { + var real = 0.0 + var imag = 0.0 + fir.forEachIndexed { tap, coefficient -> + real += coefficient * cos(omega * tap) + imag -= coefficient * sin(omega * tap) + } + gain *= sqrt(real * real + imag * imag) + phaseRadians = atan2(imag, real) + } else { + chains.forEach { biquad -> + gain *= biquad.magnitudeAt(frequency.coerceAtLeast(1.0), plan.sampleRateHz.toDouble()) + } + } + magnitude[index] = (20.0 * log10(gain.coerceAtLeast(1e-12))).toFloat() + phase[index] = phaseRadians.toFloat() + } + return ResponseCurve(frequenciesHz.copyOf(), magnitude, phase) + } +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesigner.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesigner.kt new file mode 100644 index 00000000..169e68aa --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesigner.kt @@ -0,0 +1,44 @@ +package com.miruplay.tv.audio + +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin + +object LinearPhaseFirDesigner { + fun design(targetMagnitudeDb: FloatArray, sampleRateHz: Int, taps: Int): FloatArray { + require(taps > 1 && taps and (taps - 1) == 0) { "FIR taps must be a power of two" } + require(targetMagnitudeDb.isNotEmpty()) { "FIR target cannot be empty" } + val center = (taps - 1) / 2.0 + val half = taps / 2 + val spectrumReal = DoubleArray(taps) + val spectrumImag = DoubleArray(taps) + for (k in 0..half) { + val magnitude = interpolatedMagnitude(targetMagnitudeDb, k.toDouble() / half) + val phase = -2.0 * Math.PI * k * center / taps + spectrumReal[k] = magnitude * cos(phase) + spectrumImag[k] = magnitude * sin(phase) + if (k in 1 until half) { + val mirror = taps - k + spectrumReal[mirror] = spectrumReal[k] + spectrumImag[mirror] = -spectrumImag[k] + } + } + return FloatArray(taps) { n -> + var value = 0.0 + for (k in 0 until taps) { + val phase = 2.0 * Math.PI * k * n / taps + value += spectrumReal[k] * cos(phase) - spectrumImag[k] * sin(phase) + } + (value / taps).toFloat() + } + } + + private fun interpolatedMagnitude(targetMagnitudeDb: FloatArray, normalizedFrequency: Double): Double { + val position = normalizedFrequency.coerceIn(0.0, 1.0) * (targetMagnitudeDb.lastIndex) + val lower = position.toInt().coerceIn(0, targetMagnitudeDb.lastIndex) + val upper = (lower + 1).coerceAtMost(targetMagnitudeDb.lastIndex) + val fraction = position - lower + val db = targetMagnitudeDb[lower] + (targetMagnitudeDb[upper] - targetMagnitudeDb[lower]) * fraction + return 10.0.pow(db.toDouble() / 20.0) + } +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinkedLimiter.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinkedLimiter.kt new file mode 100644 index 00000000..1f3774c9 --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinkedLimiter.kt @@ -0,0 +1,33 @@ +package com.miruplay.tv.audio + +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.min +import kotlin.math.pow + +class LinkedLimiter( + ceilingDb: Float = -1f, + private val releaseMs: Float = 100f, + private val sampleRateHz: Int = 48_000, +) { + private val ceiling = 10.0.pow(ceilingDb.toDouble() / 20.0).toFloat().coerceIn(0.01f, 1f) + private var gain = 1f + + fun process(interleaved: FloatArray, channels: Int): FloatArray { + require(channels > 0) { "channels must be positive" } + if (interleaved.isEmpty()) return interleaved.copyOf() + val output = FloatArray(interleaved.size) + val frames = interleaved.size / channels + val releaseCoefficient = 1f - exp( + -1f / (releaseMs.coerceAtLeast(1f) * 0.001f * sampleRateHz.coerceAtLeast(1)), + ) + for (frame in 0 until frames) { + val offset = frame * channels + val peak = (0 until channels).maxOf { index -> abs(interleaved[offset + index]) } + val target = if (peak > ceiling) ceiling / peak else 1f + gain = if (target < gain) target else min(1f, gain + (target - gain) * releaseCoefficient) + for (channel in 0 until channels) output[offset + channel] = interleaved[offset + channel] * gain + } + return output + } +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/StreamingDspProcessor.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/StreamingDspProcessor.kt new file mode 100644 index 00000000..33d98b68 --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/StreamingDspProcessor.kt @@ -0,0 +1,127 @@ +package com.miruplay.tv.audio + +import kotlin.math.max + +class StreamingDspProcessor( + initialPlan: CompiledDspPlan, + private val crossfadeFrames: Int = (initialPlan.sampleRateHz * 20 / 1_000).coerceAtLeast(1), +) { + private var activePlan = initialPlan + private var activeState = FilterState(initialPlan) + private var pendingPlan: CompiledDspPlan? = null + private var pendingState: FilterState? = null + private var crossfadeProgress = 0 + private var limiter = activePlan.limiter.takeIf { it.enabled }?.let { + LinkedLimiter(it.ceilingDb, it.releaseMs, activePlan.sampleRateHz) + } + + fun queuePlan(plan: CompiledDspPlan) { + require(plan.layout.channelCount == activePlan.layout.channelCount) { "input channel count cannot change during playback" } + require(plan.outputChannelCount == activePlan.outputChannelCount) { "output channel count cannot change during playback" } + pendingPlan = plan + pendingState = FilterState(plan) + crossfadeProgress = 0 + } + + fun process(interleavedPcm: FloatArray, frameCount: Int): FloatArray { + val inputChannels = activePlan.layout.channelCount + val outputChannels = activePlan.outputChannelCount + require(frameCount >= 0 && interleavedPcm.size == frameCount * inputChannels) { + "PCM buffer does not match the active channel layout" + } + if (frameCount == 0) return FloatArray(0) + val output = FloatArray(frameCount * outputChannels) + for (frame in 0 until frameCount) { + val inputOffset = frame * inputChannels + val outputOffset = frame * outputChannels + val oldFrame = routeFrame(activeState.processFrame(interleavedPcm, inputOffset), activePlan) + val nextState = pendingState + if (nextState == null) { + oldFrame.copyInto(output, outputOffset) + limiter?.process(oldFrame, outputChannels)?.copyInto(output, outputOffset) + continue + } + val newFrame = routeFrame(nextState.processFrame(interleavedPcm, inputOffset), pendingPlan ?: activePlan) + crossfadeProgress += 1 + val amount = (crossfadeProgress.toFloat() / crossfadeFrames).coerceIn(0f, 1f) + for (channel in 0 until outputChannels) { + output[outputOffset + channel] = oldFrame[channel] * (1f - amount) + newFrame[channel] * amount + } + limiter?.process(output.copyOfRange(outputOffset, outputOffset + outputChannels), outputChannels) + ?.copyInto(output, outputOffset) + if (amount >= 1f) { + activePlan = pendingPlan ?: activePlan + activeState = nextState + pendingPlan = null + pendingState = null + crossfadeProgress = 0 + limiter = activePlan.limiter.takeIf { it.enabled }?.let { + LinkedLimiter(it.ceilingDb, it.releaseMs, activePlan.sampleRateHz) + } + } + } + return output + } + + fun endOfStream(): FloatArray { + val channels = activePlan.layout.channelCount + val frames = max(firTailFrames(activePlan), pendingPlan?.let(::firTailFrames) ?: 0) + if (frames == 0) return FloatArray(0) + return process(FloatArray(frames * channels), frames) + } + + private fun routeFrame(source: FloatArray, plan: CompiledDspPlan): FloatArray = when (plan.outputMode) { + com.miruplay.tv.model.AudioDspOutputMode.AUTO_PRESERVE -> source + com.miruplay.tv.model.AudioDspOutputMode.STEREO_DOWNMIX -> SurroundDownmix.standard(source, plan.layout) + com.miruplay.tv.model.AudioDspOutputMode.HRTF_BINAURAL -> SurroundDownmix.hrtf(source, plan.layout) + } + + private class FilterState(private val plan: CompiledDspPlan) { + private val biquadStates = plan.biquadsByChannel.map { chain -> + Array(chain.size) { BiquadState() } + } + private val firHistory = plan.firTapsByChannel.map { taps -> FloatArray(taps.size) } + private var firCursor = 0 + + fun processFrame(input: FloatArray, offset: Int): FloatArray { + val result = FloatArray(plan.layout.channelCount) + for (channel in result.indices) { + var value = input[offset + channel].toDouble() + plan.biquadsByChannel[channel].forEachIndexed { index, coefficients -> + value = biquadStates[channel][index].process(value, coefficients) + } + val taps = plan.firTapsByChannel[channel] + if (taps.isNotEmpty()) { + val history = firHistory[channel] + history[firCursor] = value.toFloat() + var filtered = 0.0 + for (tap in taps.indices) { + filtered += taps[tap] * history[(firCursor - tap + history.size) % history.size] + } + value = filtered + } + value *= plan.preampLinear.toDouble() * plan.channelGainLinear.getOrElse(channel) { 1f }.toDouble() + result[channel] = value.toFloat() + } + if (firHistory.any { it.isNotEmpty() }) { + firCursor = (firCursor + 1) % firHistory.first { it.isNotEmpty() }.size + } + return result + } + } + + private fun firTailFrames(plan: CompiledDspPlan): Int = + plan.firTapsByChannel.maxOfOrNull { (it.size - 1).coerceAtLeast(0) } ?: 0 + + private class BiquadState { + private var z1 = 0.0 + private var z2 = 0.0 + + fun process(input: Double, coefficients: BiquadCoefficients): Double { + val output = coefficients.b0 * input + z1 + z1 = coefficients.b1 * input - coefficients.a1 * output + z2 + z2 = coefficients.b2 * input - coefficients.a2 * output + return output + } + } +} diff --git a/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/SurroundDownmix.kt b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/SurroundDownmix.kt new file mode 100644 index 00000000..6091d7d3 --- /dev/null +++ b/audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/SurroundDownmix.kt @@ -0,0 +1,53 @@ +package com.miruplay.tv.audio + +object SurroundDownmix { + fun standard(source: FloatArray, layout: ChannelLayout): FloatArray { + if (layout.channelCount <= 2) return source.copyOf(layout.channelCount) + var left = 0.0 + var right = 0.0 + layout.channels.forEachIndexed { index, channel -> + val sample = source.getOrElse(index) { 0f }.toDouble() + when (channel) { + Channel.L -> left += sample + Channel.R -> right += sample + Channel.C -> { + left += sample * 0.707 + right += sample * 0.707 + } + Channel.LFE -> { + left += sample * 0.316 + right += sample * 0.316 + } + Channel.LS, Channel.LB -> left += sample * 0.707 + Channel.RS, Channel.RB -> right += sample * 0.707 + Channel.UNKNOWN -> { + // Preserve audio for uncommon layouts whose channel mask is unavailable. + left += sample * 0.5 + right += sample * 0.5 + } + else -> Unit + } + } + return floatArrayOf(left.coerceIn(-1.0, 1.0).toFloat(), right.coerceIn(-1.0, 1.0).toFloat()) + } + + fun hrtf(source: FloatArray, layout: ChannelLayout): FloatArray { + if (layout.channelCount <= 2) return source.copyOf(layout.channelCount) + var left = 0.0 + var right = 0.0 + layout.channels.forEachIndexed { index, channel -> + val sample = source.getOrElse(index) { 0f }.toDouble() + when (channel) { + Channel.L -> { left += sample; right += sample * 0.08 } + Channel.R -> { right += sample; left += sample * 0.08 } + Channel.C -> { left += sample * 0.72; right += sample * 0.72 } + Channel.LFE -> { left += sample * 0.22; right += sample * 0.22 } + Channel.LS, Channel.LB -> { left += sample * 0.82; right += sample * 0.24 } + Channel.RS, Channel.RB -> { right += sample * 0.82; left += sample * 0.24 } + Channel.UNKNOWN -> { left += sample * 0.5; right += sample * 0.5 } + else -> Unit + } + } + return floatArrayOf(left.coerceIn(-1.0, 1.0).toFloat(), right.coerceIn(-1.0, 1.0).toFloat()) + } +} diff --git a/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/BiquadDesignerTest.kt b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/BiquadDesignerTest.kt new file mode 100644 index 00000000..6c1310c2 --- /dev/null +++ b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/BiquadDesignerTest.kt @@ -0,0 +1,31 @@ +package com.miruplay.tv.audio + +import com.miruplay.tv.model.AudioDspBand +import com.miruplay.tv.model.AudioDspFilterType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs + +class BiquadDesignerTest { + @Test + fun `zero gain peaking band is identity`() { + val coefficients = BiquadDesigner.design( + AudioDspBand(type = AudioDspFilterType.PEAKING, frequencyHz = 1_000f, gainDb = 0f, q = 1f), + 48_000, + ) + + assertEquals(1.0, coefficients.magnitudeAt(1_000.0, 48_000.0), 1e-9) + } + + @Test + fun `six decibel peaking band raises center frequency`() { + val coefficients = BiquadDesigner.design( + AudioDspBand(type = AudioDspFilterType.PEAKING, frequencyHz = 1_000f, gainDb = 6f, q = 1f), + 48_000, + ) + + val db = 20.0 * kotlin.math.log10(coefficients.magnitudeAt(1_000.0, 48_000.0)) + assertTrue(abs(db - 6.0) < 0.1) + } +} diff --git a/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/ChannelLayoutTest.kt b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/ChannelLayoutTest.kt new file mode 100644 index 00000000..1e5045aa --- /dev/null +++ b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/ChannelLayoutTest.kt @@ -0,0 +1,24 @@ +package com.miruplay.tv.audio + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChannelLayoutTest { + @Test + fun `known 5 point 1 mask is normalized to ITU order`() { + val layout = ChannelLayout.from(6, ChannelLayout.ANDROID_5_1_MASK) + + assertEquals(ChannelLayoutId.SURROUND_5_1, layout.id) + assertEquals(listOf(Channel.L, Channel.R, Channel.C, Channel.LFE, Channel.LS, Channel.RS), layout.channels) + } + + @Test + fun `aac 5 point 1 order is remapped without changing unknown order`() { + val samples = floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f) + val normalized = ChannelLayout.from(6, null).normalizeInterleaved(samples, InputOrder.AAC_5_1) + + assertArrayEquals(floatArrayOf(1f, 2f, 3f, 6f, 4f, 5f), normalized, 0f) + assertEquals(InputOrder.UNKNOWN, ChannelLayout.from(6, null).defaultInputOrder) + } +} diff --git a/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/DownmixTest.kt b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/DownmixTest.kt new file mode 100644 index 00000000..ca1d28cc --- /dev/null +++ b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/DownmixTest.kt @@ -0,0 +1,86 @@ +package com.miruplay.tv.audio + +import com.miruplay.tv.model.AudioDspOutputMode +import com.miruplay.tv.model.AudioDspPreset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DownmixTest { + @Test + fun `channel count fallback recognizes common surround layouts without an android mask`() { + assertEquals(ChannelLayoutId.SURROUND_5_1, ChannelLayout.from(6, null).id) + assertEquals(ChannelLayoutId.SURROUND_7_1, ChannelLayout.from(8, null).id) + } + + @Test + fun `mono downmix does not upmix the source`() { + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset("mono", "Mono", outputMode = AudioDspOutputMode.STEREO_DOWNMIX), + ChannelLayout.from(1, null), + 48_000, + ) + + assertEquals(1, plan.outputChannelCount) + assertEquals(1, StreamingDspProcessor(plan).process(floatArrayOf(0.5f), 1).size) + } + + @Test + fun `standard downmix reduces 5 point 1 to stereo with bounded lfe`() { + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset("downmix", "Downmix", outputMode = AudioDspOutputMode.STEREO_DOWNMIX), + ChannelLayout.from(6, ChannelLayout.ANDROID_5_1_MASK), + 48_000, + ) + val output = StreamingDspProcessor(plan).process( + floatArrayOf(1f, 0f, 0f, 1f, 0f, 0f), + frameCount = 1, + ) + + assertEquals(2, output.size) + assertTrue(output[0] > 0.9f) + assertTrue(output[0] < 1.1f) + } + + @Test + fun `hrtf route always exposes two output channels`() { + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset("hrtf", "HRTF", outputMode = AudioDspOutputMode.HRTF_BINAURAL), + ChannelLayout.from(8, ChannelLayout.ANDROID_7_1_MASK), + 48_000, + ) + + assertEquals(2, plan.outputChannelCount) + assertEquals(2, StreamingDspProcessor(plan).process(FloatArray(8), 1).size) + } + + @Test + fun `unknown multichannel layout keeps audio audible when downmixed`() { + val layout = ChannelLayout.from(4, null) + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset("downmix-unknown", "Downmix unknown", outputMode = AudioDspOutputMode.STEREO_DOWNMIX), + layout, + 48_000, + ) + + val output = StreamingDspProcessor(plan).process(FloatArray(4) { 1f }, 1) + + assertEquals(2, output.size) + assertTrue(output.all { it > 0f }) + } + + @Test + fun `unknown multichannel layout keeps audio audible in hrtf mode`() { + val layout = ChannelLayout.from(4, null) + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset("hrtf-unknown", "HRTF unknown", outputMode = AudioDspOutputMode.HRTF_BINAURAL), + layout, + 48_000, + ) + + val output = StreamingDspProcessor(plan).process(FloatArray(4) { 1f }, 1) + + assertEquals(2, output.size) + assertTrue(output.all { it > 0f }) + } +} diff --git a/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesignerTest.kt b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesignerTest.kt new file mode 100644 index 00000000..0c6444ec --- /dev/null +++ b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesignerTest.kt @@ -0,0 +1,94 @@ +package com.miruplay.tv.audio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import com.miruplay.tv.model.AudioDspBand +import com.miruplay.tv.model.AudioDspFilterType +import com.miruplay.tv.model.AudioDspPhaseMode + +class LinearPhaseFirDesignerTest { + @Test + fun `fir is symmetric and has a centered impulse`() { + val taps = LinearPhaseFirDesigner.design( + targetMagnitudeDb = FloatArray(256), + sampleRateHz = 48_000, + taps = 256, + ) + + for (i in taps.indices) assertEquals(taps[i], taps[taps.lastIndex - i], 1e-6f) + val peak = taps.indices.maxBy { kotlin.math.abs(taps[it]) } + assertTrue(peak == 127 || peak == 128) + } + + @Test + fun `compiler uses one common tap count for every channel`() { + val preset = com.miruplay.tv.model.AudioDspPreset( + id = "linear", + name = "Linear", + phaseMode = com.miruplay.tv.model.AudioDspPhaseMode.LINEAR, + ) + val plan = AudioDspPlanCompiler.compile(preset, ChannelLayout.from(2, null), 48_000) + + assertEquals(2, plan.firTapsByChannel.size) + assertEquals(plan.firTapsByChannel[0].toList(), plan.firTapsByChannel[1].toList()) + } + + @Test + fun `linear phase plan does not retain the minimum phase biquad chain`() { + val preset = com.miruplay.tv.model.AudioDspPreset( + id = "linear-peq", + name = "Linear PEQ", + phaseMode = AudioDspPhaseMode.LINEAR, + rules = listOf( + com.miruplay.tv.model.AudioDspChannelRule( + bands = listOf(AudioDspBand(AudioDspFilterType.PEAKING, 1_000f, 6f, 1f)), + ), + ), + ) + + val plan = AudioDspPlanCompiler.compile(preset, ChannelLayout.from(2, null), 48_000) + + assertTrue(plan.biquadsByChannel.all { it.isEmpty() }) + } + + @Test + fun `linear phase preview reports the baked fir response`() { + val preset = com.miruplay.tv.model.AudioDspPreset( + id = "linear-peq", + name = "Linear PEQ", + phaseMode = AudioDspPhaseMode.LINEAR, + rules = listOf( + com.miruplay.tv.model.AudioDspChannelRule( + bands = listOf(AudioDspBand(AudioDspFilterType.PEAKING, 1_000f, 6f, 1f)), + ), + ), + ) + + val plan = AudioDspPlanCompiler.compile(preset, ChannelLayout.from(2, null), 48_000) + val curve = FrequencyResponse.sample(plan, floatArrayOf(1_000f)) + + assertTrue("magnitude=${curve.magnitudeDb.single()}", curve.magnitudeDb.single() > 1f) + assertTrue(kotlin.math.abs(curve.phaseRadians.single()) > 0.01f) + } + + @Test + fun `linear fir preserves the peq magnitude at the center frequency`() { + val preset = com.miruplay.tv.model.AudioDspPreset( + id = "linear-peq", + name = "Linear PEQ", + phaseMode = AudioDspPhaseMode.LINEAR, + firQuality = com.miruplay.tv.model.AudioDspFirQuality.LOW, + rules = listOf( + com.miruplay.tv.model.AudioDspChannelRule( + bands = listOf(AudioDspBand(AudioDspFilterType.PEAKING, 1_000f, 6f, 1f)), + ), + ), + ) + + val plan = AudioDspPlanCompiler.compile(preset, ChannelLayout.from(2, null), 48_000) + val curve = FrequencyResponse.sample(plan, floatArrayOf(1_000f)) + + assertEquals(6f, curve.magnitudeDb.single(), 0.25f) + } +} diff --git a/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinkedLimiterTest.kt b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinkedLimiterTest.kt new file mode 100644 index 00000000..bf30a165 --- /dev/null +++ b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinkedLimiterTest.kt @@ -0,0 +1,16 @@ +package com.miruplay.tv.audio + +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.pow + +class LinkedLimiterTest { + @Test + fun `linked limiter keeps every channel below ceiling`() { + val limiter = LinkedLimiter(ceilingDb = -1f) + val output = limiter.process(floatArrayOf(2f, 0.5f, -1.5f, 0.25f), channels = 2) + val ceiling = 10.0.pow(-1.0 / 20.0).toFloat() + + assertTrue(output.maxOf { kotlin.math.abs(it) } <= ceiling + 1e-5f) + } +} diff --git a/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/StreamingDspProcessorTest.kt b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/StreamingDspProcessorTest.kt new file mode 100644 index 00000000..60b35117 --- /dev/null +++ b/audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/StreamingDspProcessorTest.kt @@ -0,0 +1,140 @@ +package com.miruplay.tv.audio + +import com.miruplay.tv.model.AudioDspBand +import com.miruplay.tv.model.AudioDspFilterType +import com.miruplay.tv.model.AudioDspLimiter +import com.miruplay.tv.model.AudioDspPhaseMode +import com.miruplay.tv.model.AudioDspPreset +import com.miruplay.tv.model.AudioDspChannelRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamingDspProcessorTest { + @Test + fun `identity processing preserves interleaved channel count`() { + val layout = ChannelLayout.from(2, null) + val plan = AudioDspPlanCompiler.compile(AudioDspPreset("flat", "Flat"), layout, 48_000) + val input = floatArrayOf(0.1f, -0.2f, 0.3f, -0.4f) + + val output = StreamingDspProcessor(plan).process(input, frameCount = 2) + + assertEquals(input.size, output.size) + assertTrue(output.zip(input.toList()).all { (a, b) -> kotlin.math.abs(a - b) < 1e-6f }) + } + + @Test + fun `linear phase processing flushes a complete common tail`() { + val layout = ChannelLayout.from(2, null) + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset("linear", "Linear", phaseMode = AudioDspPhaseMode.LINEAR), + layout, + 48_000, + ) + val processor = StreamingDspProcessor(plan) + val output = processor.process(floatArrayOf(1f, 1f), frameCount = 1) + val tail = processor.endOfStream() + + assertEquals(2, output.size) + assertEquals((plan.firTapsByChannel.first().size - 1) * 2, tail.size) + } + + @Test + fun `plan replacement crossfades without an oversized sample step`() { + val layout = ChannelLayout.from(2, null) + val flat = AudioDspPlanCompiler.compile(AudioDspPreset("flat", "Flat"), layout, 48_000) + val boosted = AudioDspPlanCompiler.compile( + AudioDspPreset( + "boosted", + "Boosted", + rules = listOf( + com.miruplay.tv.model.AudioDspChannelRule( + bands = listOf(AudioDspBand(AudioDspFilterType.PEAKING, 1_000f, 6f, 1f)), + ), + ), + ), + layout, + 48_000, + ) + val processor = StreamingDspProcessor(flat, crossfadeFrames = 8) + processor.process(FloatArray(16), 8) + processor.queuePlan(boosted) + val output = processor.process(FloatArray(32) { 0.2f }, 16) + + assertTrue(output.toList().zipWithNext().maxOf { kotlin.math.abs(it.second - it.first) } < 0.2f) + } + + @Test + fun `preamp channel gain and limiter are applied to output`() { + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset( + "gain", + "Gain", + preampDb = 6f, + rules = listOf(AudioDspChannelRule(outputGainDb = 6f)), + limiter = AudioDspLimiter(enabled = true, ceilingDb = -6f), + ), + ChannelLayout.from(1, null), + 48_000, + ) + + val output = StreamingDspProcessor(plan).process(floatArrayOf(1f), 1) + + assertEquals(1, output.size) + assertTrue(output[0] <= 0.51f) + assertTrue(output[0] > 0.49f) + } + + @Test + fun `linear fir flush includes the complete convolution tail`() { + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset("linear", "Linear", phaseMode = AudioDspPhaseMode.LINEAR), + ChannelLayout.from(2, null), + 48_000, + ) + val processor = StreamingDspProcessor(plan) + processor.process(floatArrayOf(1f, 1f), 1) + + assertEquals((plan.firTapsByChannel.first().size - 1) * 2, processor.endOfStream().size) + } + + @Test + fun `linear fir applies the configured gain to a steady tone`() { + val sampleRate = 48_000 + val frames = 20_000 + val plan = AudioDspPlanCompiler.compile( + AudioDspPreset( + "linear-peq", + "Linear PEQ", + phaseMode = AudioDspPhaseMode.LINEAR, + firQuality = com.miruplay.tv.model.AudioDspFirQuality.LOW, + rules = listOf( + AudioDspChannelRule( + bands = listOf(AudioDspBand(AudioDspFilterType.PEAKING, 1_000f, 6f, 1f)), + ), + ), + ), + ChannelLayout.from(2, null), + sampleRate, + ) + val input = FloatArray(frames * 2) { index -> + kotlin.math.sin(2.0 * Math.PI * 1_000.0 * (index / 2) / sampleRate).toFloat() + } + + val output = StreamingDspProcessor(plan).process(input, frames) + val start = 12_000 + val length = 4_000 + val correlation = output.copyOfRange(start * 2, (start + length) * 2) + .filterIndexed { index, _ -> index % 2 == 0 } + val amplitude = 2.0 * kotlin.math.sqrt( + correlation.withIndex().sumOf { (index, value) -> + value * kotlin.math.cos(2.0 * Math.PI * 1_000.0 * index / sampleRate) + }.let { cosine -> cosine * cosine } + + correlation.withIndex().sumOf { (index, value) -> + value * kotlin.math.sin(2.0 * Math.PI * 1_000.0 * index / sampleRate) + }.let { sine -> sine * sine } + ) / length + + assertTrue("amplitude=$amplitude", amplitude in 1.7..2.2) + } +} diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/AudioDspModels.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/AudioDspModels.kt new file mode 100644 index 00000000..ad037271 --- /dev/null +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/AudioDspModels.kt @@ -0,0 +1,222 @@ +package com.miruplay.tv.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class AudioDspPhaseMode(val storageValue: String) { + MINIMUM("minimum"), + LINEAR("linear"); + + companion object { + fun fromStorageValue(value: String?): AudioDspPhaseMode = + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: MINIMUM + } +} + +@Serializable +enum class AudioDspOutputMode(val storageValue: String) { + AUTO_PRESERVE("auto_preserve"), + STEREO_DOWNMIX("stereo_downmix"), + HRTF_BINAURAL("hrtf_binaural"); + + companion object { + fun fromStorageValue(value: String?): AudioDspOutputMode = + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: AUTO_PRESERVE + } +} + +@Serializable +enum class AudioDspFirQuality(val storageValue: String, val taps: Int) { + LOW("low", 1024), + MEDIUM("medium", 2048), + HIGH("high", 4096); + + companion object { + fun fromStorageValue(value: String?): AudioDspFirQuality = + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: MEDIUM + } +} + +@Serializable +enum class AudioDspFilterType(val storageValue: String) { + PEAKING("peaking"), + LOW_SHELF("low_shelf"), + HIGH_SHELF("high_shelf"), + LOW_PASS("low_pass"), + HIGH_PASS("high_pass"), + NOTCH("notch"), + BAND_PASS("band_pass"); + + companion object { + fun fromStorageValue(value: String?): AudioDspFilterType = + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: PEAKING + } +} + +@Serializable +enum class AudioDspChannelTarget(val storageValue: String) { + ALL("all"), + FRONT("front"), + CENTER_LFE("center_lfe"), + SURROUND("surround"), + SURROUND_5_1("surround_5_1"), + SURROUND_7_1("surround_7_1"), + LEFT("left"), + RIGHT("right"), + CENTER("center"), + LFE("lfe"), + LEFT_SURROUND("left_surround"), + RIGHT_SURROUND("right_surround"); + + companion object { + fun fromStorageValue(value: String?): AudioDspChannelTarget = + entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: ALL + } +} + +@Serializable +data class AudioDspBand( + val type: AudioDspFilterType = AudioDspFilterType.PEAKING, + val frequencyHz: Float = 1_000f, + val gainDb: Float = 0f, + val q: Float = 1f, + val enabled: Boolean = true, +) { + fun normalized(): AudioDspBand = copy( + frequencyHz = frequencyHz.coerceIn(MIN_FREQUENCY_HZ, MAX_FREQUENCY_HZ), + gainDb = gainDb.coerceIn(MIN_GAIN_DB, MAX_GAIN_DB), + q = q.coerceIn(MIN_Q, MAX_Q), + ) + + companion object { + const val MIN_FREQUENCY_HZ = 10f + const val MAX_FREQUENCY_HZ = 24_000f + const val MIN_GAIN_DB = -24f + const val MAX_GAIN_DB = 24f + const val MIN_Q = 0.1f + const val MAX_Q = 20f + } +} + +@Serializable +data class AudioDspChannelRule( + val target: AudioDspChannelTarget = AudioDspChannelTarget.ALL, + val bands: List = emptyList(), + val outputGainDb: Float = 0f, +) { + fun normalized(): AudioDspChannelRule = copy( + bands = bands.take(MAX_BANDS_PER_RULE).map(AudioDspBand::normalized), + outputGainDb = outputGainDb.coerceIn(AudioDspBand.MIN_GAIN_DB, AudioDspBand.MAX_GAIN_DB), + ) + + companion object { + const val MAX_BANDS_PER_RULE = 32 + } +} + +@Serializable +data class AudioDspLimiter( + val enabled: Boolean = false, + val ceilingDb: Float = -1f, + val releaseMs: Float = 100f, +) { + fun normalized(): AudioDspLimiter = copy( + ceilingDb = ceilingDb.coerceIn(-24f, 0f), + releaseMs = releaseMs.coerceIn(1f, 2_000f), + ) +} + +@Serializable +data class AudioDspPreset( + val id: String, + val name: String, + val preampDb: Float = 0f, + val phaseMode: AudioDspPhaseMode = AudioDspPhaseMode.MINIMUM, + val firQuality: AudioDspFirQuality = AudioDspFirQuality.MEDIUM, + val outputMode: AudioDspOutputMode = AudioDspOutputMode.AUTO_PRESERVE, + val rules: List = emptyList(), + val limiter: AudioDspLimiter = AudioDspLimiter(), +) { + fun normalized(): AudioDspPreset = copy( + id = id.trim().ifEmpty { AudioDspConfig.DEFAULT_PRESET_ID }, + name = name.trim().ifEmpty { "Neutral" }, + preampDb = preampDb.coerceIn(-24f, 12f), + rules = rules.map(AudioDspChannelRule::normalized), + limiter = limiter.normalized(), + ) +} + +@Serializable +data class AudioDspConfig( + val schemaVersion: Int = CURRENT_SCHEMA_VERSION, + val enabled: Boolean = false, + val selectedPresetId: String = DEFAULT_PRESET_ID, + val presets: List = listOf(neutralPreset()), +) { + fun normalized(): AudioDspConfig { + val normalizedPresets = presets + .map(AudioDspPreset::normalized) + .distinctBy(AudioDspPreset::id) + .ifEmpty { listOf(neutralPreset()) } + val selected = normalizedPresets.firstOrNull { it.id == selectedPresetId }?.id + ?: normalizedPresets.first().id + return copy( + schemaVersion = CURRENT_SCHEMA_VERSION, + selectedPresetId = selected, + presets = normalizedPresets, + ) + } + + fun validationErrors(): List = buildList { + if (schemaVersion != CURRENT_SCHEMA_VERSION) add("schemaVersion must be $CURRENT_SCHEMA_VERSION") + if (presets.isEmpty()) add("presets must not be empty") + if (presets.isNotEmpty() && presets.none { it.id == selectedPresetId }) { + add("selectedPresetId must reference an existing preset") + } + val ids = mutableSetOf() + presets.forEachIndexed { presetIndex, preset -> + val id = preset.id.trim() + if (id.isBlank()) add("presets[$presetIndex].id must not be blank") + if (!ids.add(id)) add("presets[$presetIndex].id is duplicate") + if (preset.preampDb !in -24f..12f) add("presets[$presetIndex].preampDb is out of range") + if (!preset.preampDb.isFinite()) add("presets[$presetIndex].preampDb must be finite") + if (preset.limiter.ceilingDb !in -24f..0f || !preset.limiter.ceilingDb.isFinite()) { + add("presets[$presetIndex].limiter.ceilingDb is out of range") + } + if (preset.limiter.releaseMs !in 1f..2_000f || !preset.limiter.releaseMs.isFinite()) { + add("presets[$presetIndex].limiter.releaseMs is out of range") + } + preset.rules.forEachIndexed { ruleIndex, rule -> + if (rule.bands.size > AudioDspChannelRule.MAX_BANDS_PER_RULE) { + add("presets[$presetIndex].rules[$ruleIndex] has too many bands") + } + if (rule.outputGainDb !in AudioDspBand.MIN_GAIN_DB..AudioDspBand.MAX_GAIN_DB || !rule.outputGainDb.isFinite()) { + add("presets[$presetIndex].rules[$ruleIndex].outputGainDb is out of range") + } + rule.bands.forEachIndexed { bandIndex, band -> + if (band.frequencyHz !in AudioDspBand.MIN_FREQUENCY_HZ..AudioDspBand.MAX_FREQUENCY_HZ || !band.frequencyHz.isFinite()) { + add("presets[$presetIndex].rules[$ruleIndex].bands[$bandIndex].frequencyHz is out of range") + } + if (band.gainDb !in AudioDspBand.MIN_GAIN_DB..AudioDspBand.MAX_GAIN_DB || !band.gainDb.isFinite()) { + add("presets[$presetIndex].rules[$ruleIndex].bands[$bandIndex].gainDb is out of range") + } + if (band.q !in AudioDspBand.MIN_Q..AudioDspBand.MAX_Q || !band.q.isFinite()) { + add("presets[$presetIndex].rules[$ruleIndex].bands[$bandIndex].q is out of range") + } + } + } + } + } + + companion object { + const val CURRENT_SCHEMA_VERSION = 1 + const val DEFAULT_PRESET_ID = "neutral" + + fun neutral(): AudioDspConfig = AudioDspConfig() + + private fun neutralPreset(): AudioDspPreset = AudioDspPreset( + id = DEFAULT_PRESET_ID, + name = "Neutral", + ) + } +} diff --git a/core/model/src/test/kotlin/com/miruplay/tv/model/AudioDspModelsTest.kt b/core/model/src/test/kotlin/com/miruplay/tv/model/AudioDspModelsTest.kt new file mode 100644 index 00000000..0901f3d7 --- /dev/null +++ b/core/model/src/test/kotlin/com/miruplay/tv/model/AudioDspModelsTest.kt @@ -0,0 +1,107 @@ +package com.miruplay.tv.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioDspModelsTest { + private val json = Json { encodeDefaults = true } + + @Test + fun `neutral config is disabled and has one neutral preset`() { + val config = AudioDspConfig.neutral() + + assertFalse(config.enabled) + assertEquals(AudioDspConfig.DEFAULT_PRESET_ID, config.selectedPresetId) + assertEquals(1, config.presets.size) + assertTrue(config.presets.single().rules.isEmpty()) + } + + @Test + fun `unknown storage values fall back to safe enum values`() { + assertEquals(AudioDspPhaseMode.MINIMUM, AudioDspPhaseMode.fromStorageValue("future")) + assertEquals(AudioDspOutputMode.AUTO_PRESERVE, AudioDspOutputMode.fromStorageValue("future")) + assertEquals(AudioDspFirQuality.MEDIUM, AudioDspFirQuality.fromStorageValue("future")) + assertEquals(AudioDspFilterType.PEAKING, AudioDspFilterType.fromStorageValue("future")) + } + + @Test + fun `normalized clamps malformed band and preamp values`() { + val config = AudioDspConfig( + enabled = true, + selectedPresetId = "p", + presets = listOf( + AudioDspPreset( + id = "p", + name = "Bad", + preampDb = 99f, + rules = listOf( + AudioDspChannelRule( + bands = listOf( + AudioDspBand( + type = AudioDspFilterType.PEAKING, + frequencyHz = 1f, + gainDb = -99f, + q = 99f, + ), + ), + ), + ), + ), + ), + ).normalized() + + val band = config.presets.single().rules.single().bands.single() + assertEquals(12f, config.presets.single().preampDb) + assertEquals(10f, band.frequencyHz) + assertEquals(-24f, band.gainDb) + assertEquals(20f, band.q) + } + + @Test + fun `validation rejects duplicate ids and unsafe channel and limiter values`() { + val config = AudioDspConfig( + selectedPresetId = "missing", + presets = listOf( + AudioDspPreset( + id = "movie", + name = "Movie", + rules = listOf(AudioDspChannelRule(outputGainDb = 25f)), + limiter = AudioDspLimiter(enabled = true, ceilingDb = 1f, releaseMs = 0f), + ), + AudioDspPreset(id = "movie", name = "Duplicate"), + ), + ) + + val errors = config.validationErrors() + + assertTrue(errors.any { it.contains("selectedPresetId") }) + assertTrue(errors.any { it.contains("duplicate") }) + assertTrue(errors.any { it.contains("outputGainDb") }) + assertTrue(errors.any { it.contains("ceilingDb") }) + assertTrue(errors.any { it.contains("releaseMs") }) + } + + @Test + fun `json round trip preserves 5 point 1 channel target`() { + val original = AudioDspConfig( + presets = listOf( + AudioDspPreset( + id = "movie", + name = "Movie", + rules = listOf( + AudioDspChannelRule(target = AudioDspChannelTarget.SURROUND_5_1), + ), + ), + ), + selectedPresetId = "movie", + ) + + val decoded = json.decodeFromString( + json.encodeToString(AudioDspConfig.serializer(), original), + ) + assertEquals(AudioDspChannelTarget.SURROUND_5_1, decoded.presets.single().rules.single().target) + } +} diff --git a/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt b/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt index b24bb078..61a97eec 100644 --- a/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt +++ b/data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt @@ -7,6 +7,7 @@ import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend import com.miruplay.tv.model.SubtitleLanguagePreference +import com.miruplay.tv.model.AudioDspConfig import com.miruplay.tv.repository.PlaybackPreferencesRepository import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -70,6 +71,20 @@ class PlaybackPreferencesManager @Inject constructor( prefs.edit().putString(KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES, serialized).apply() } + var audioDspConfig: AudioDspConfig + get() { + val stored = prefs.getString(KEY_AUDIO_DSP_CONFIG, null) + return runCatching { + stored?.takeIf(String::isNotBlank) + ?.let { json.decodeFromString(it) } + ?.normalized() + ?: AudioDspConfig.neutral() + }.getOrElse { AudioDspConfig.neutral() } + } + set(value) { + prefs.edit().putString(KEY_AUDIO_DSP_CONFIG, json.encodeToString(value.normalized())).apply() + } + override suspend fun getEndAction(): PlaybackEndAction = endAction @@ -105,12 +120,19 @@ class PlaybackPreferencesManager @Inject constructor( formatAwareToneMappingPreferences = preferences } + override suspend fun getAudioDspConfig(): AudioDspConfig = audioDspConfig + + override suspend fun setAudioDspConfig(config: AudioDspConfig) { + audioDspConfig = config + } + companion object { private const val KEY_END_ACTION = "end_action" private const val KEY_EPISODE_VERSION_SELECTION_POLICY = "episode_version_selection_policy" private const val KEY_PREFERRED_SUBTITLE_LANGUAGE = "preferred_subtitle_language" private const val KEY_SUBTITLE_BACKGROUND_TRANSPARENT = "subtitle_background_transparent" private const val KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES = "format_aware_tone_mapping_preferences" + private const val KEY_AUDIO_DSP_CONFIG = "audio_dsp_config" } } diff --git a/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt b/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt index 76b0e1cd..84f57510 100644 --- a/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt +++ b/data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt @@ -11,6 +11,8 @@ import com.miruplay.tv.model.SubtitleLanguagePreference import com.miruplay.tv.model.ToneMappingCurvePreset import com.miruplay.tv.model.ToneMappingRuleSet import com.miruplay.tv.model.VideoRenderRuleKey +import com.miruplay.tv.model.AudioDspConfig +import com.miruplay.tv.model.AudioDspPreset import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -76,6 +78,24 @@ class PlaybackPreferencesManagerTest { assertEquals(true, manager.getSubtitleBackgroundTransparent()) } + @Test + fun `manager persists normalized audio dsp config`() = runBlocking { + assertEquals(false, manager.getAudioDspConfig().enabled) + + manager.setAudioDspConfig( + AudioDspConfig( + enabled = true, + selectedPresetId = "movie", + presets = listOf(AudioDspPreset("movie", "Movie", preampDb = 99f)), + ), + ) + + val restored = manager.getAudioDspConfig() + assertEquals(true, restored.enabled) + assertEquals("movie", restored.selectedPresetId) + assertEquals(12f, restored.presets.single().preampDb) + } + @Test fun `manager persists and reloads customized format-aware tone mapping preferences`() = runBlocking { val updated = FormatAwareToneMappingPreferences( diff --git a/docs/superpowers/plans/2026-08-03-audio-dsp.md b/docs/superpowers/plans/2026-08-03-audio-dsp.md new file mode 100644 index 00000000..890fc447 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-audio-dsp.md @@ -0,0 +1,361 @@ +# Native Audio DSP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver a native PEQ, linear-phase FIR, multichannel-preserving, and optional HRTF downmix pipeline across all MiruPlay playback backends with full WebUI control and TV switch/preset controls. + +**Architecture:** Store a versioned DSP config in `core:model` and persist it through the existing playback preferences repository. A new pure Kotlin `audio-dsp-core` module validates plans, designs RBJ/FIR coefficients, models channel layouts, and supplies a reference processor used by JVM tests. Exo receives a Media3 `AudioProcessor` chain; mpv and rebuilt IJK use native FFmpeg audio filter graphs; HRTF uses the Apache-2.0 Resonance Audio/SADIE renderer and a shared 5.1/7.1 layout contract. + +**Tech Stack:** Kotlin 2.0/JVM and Android, Media3 1.8.0, kotlinx.serialization, JUnit 4, MockK/Turbine where existing tests require them, FFmpeg/libavfilter, Resonance Audio (Apache-2.0), SADIE HRIR assets (Apache-2.0), NanoHTTPD, Vue 3, Element Plus, Vite, and the existing Gradle modules. + +## Global Constraints + +- The feature is disabled by default; disabling restores the pre-DSP passthrough/offload/tunnel policy. +- Enabling DSP always selects decoded PCM and never silently emits encoded direct output. +- `AUTO_PRESERVE` keeps a negotiated multichannel PCM layout; an incapable sink uses the configured stereo or HRTF fallback and exposes the effective route. +- No stereo-to-surround upmix or arbitrary user matrix editor is included in this release. +- Linear-phase FIR uses one tap count/group delay across active channels; HRTF spatial phase remains intentional. +- PEQ values are validated before persistence and invalid API payloads leave the active and stored plans unchanged. +- Existing local/WebDAV/SMB and AndroidIO source resolution remains untouched. +- IJK native artifacts remain available for `arm64-v8a` and `armeabi-v7a`, with source revision, hashes, licenses, and build inputs recorded. +- Tests follow red-green-refactor; each new behavior has a failing test run before implementation. + +--- + +### Task 1: Add the versioned DSP contract and module scaffold + +**Files:** +- Create: `audio-dsp-core/build.gradle.kts` +- Modify: `settings.gradle.kts` +- Modify: `core/model/src/main/kotlin/com/miruplay/tv/model/AudioDspModels.kt` +- Test: `core/model/src/test/kotlin/com/miruplay/tv/model/AudioDspModelsTest.kt` + +**Interfaces:** +- Produces `AudioDspConfig`, `AudioDspPreset`, `AudioDspChannelRule`, `AudioDspBand`, `AudioDspPhaseMode`, `AudioDspOutputMode`, `AudioDspFirQuality`, and `AudioDspChannelTarget` as serializable stable contracts. +- Produces `AudioDspConfig.neutral()` and `AudioDspConfig.normalized()` for persistence and API consumers. + +- [ ] **Step 1: Write the failing model tests** + + Assert that `AudioDspConfig.neutral()` is disabled with one preset, that unknown enum strings normalize to the neutral value, that a malformed band is clamped/rejected according to the documented limits, and that JSON round-tripping preserves a 5.1 channel-group rule. + +- [ ] **Step 2: Run the focused tests to verify the expected failure** + + Run `./gradlew.bat :core:model:test --tests '*AudioDspModelsTest'`. + Expected: compilation failure because the new model types do not exist. + +- [ ] **Step 3: Add the module and model implementation** + + Add `audio-dsp-core` as a Kotlin/JVM module depending only on `:core:model`. Use `@Serializable` enums with explicit storage names. Implement limits of 10 Hz to 24 kHz for frequency, -24 dB to +24 dB for band gain, 0.1 to 20 for Q, -24 dB to +12 dB for preamp, and a maximum of 32 bands per channel rule. Keep selected preset ids stable strings and preserve unknown future fields through the repository JSON configuration. + +- [ ] **Step 4: Run the focused tests to verify they pass** + + Run `./gradlew.bat :core:model:test --tests '*AudioDspModelsTest'`. + Expected: all model tests pass. + +- [ ] **Step 5: Commit** + + `git add settings.gradle.kts audio-dsp-core/build.gradle.kts core/model/src/main/kotlin/com/miruplay/tv/model/AudioDspModels.kt core/model/src/test/kotlin/com/miruplay/tv/model/AudioDspModelsTest.kt` + `git commit -m "feat(audio): add versioned DSP configuration model"` + +### Task 2: Implement channel layouts and PEQ/FIR design math + +**Files:** +- Create: `audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/ChannelLayout.kt` +- Create: `audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/BiquadDesigner.kt` +- Create: `audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/FrequencyResponse.kt` +- Create: `audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesigner.kt` +- Create: `audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/AudioDspPlanCompiler.kt` +- Test: `audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/ChannelLayoutTest.kt` +- Test: `audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/BiquadDesignerTest.kt` +- Test: `audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesignerTest.kt` + +**Interfaces:** +- `ChannelLayout.from(channelCount: Int, channelMask: Int?): ChannelLayout` +- `ChannelLayout.normalizeInterleaved(samples: FloatArray, inputOrder: ChannelOrder): FloatArray` +- `BiquadDesigner.design(band: AudioDspBand, sampleRateHz: Int): BiquadCoefficients` +- `FrequencyResponse.sample(plan: CompiledDspPlan, frequenciesHz: FloatArray): ResponseCurve` +- `LinearPhaseFirDesigner.design(targetMagnitudeDb: FloatArray, sampleRateHz: Int, taps: Int): FloatArray` +- `AudioDspPlanCompiler.compile(preset: AudioDspPreset, layout: ChannelLayout, sampleRateHz: Int): CompiledDspPlan` + +- [ ] **Step 1: Write failing math tests** + + Test fixed PEQ fixtures: a 0 dB peaking band is an identity response, a +6 dB 1 kHz band raises the 1 kHz response within 0.1 dB, a generated FIR is symmetric, the impulse peak is at `(taps - 1) / 2`, and a 5.1 AAC order is normalized to `L,R,C,LFE,LS,RS` without reordering unknown layouts. + +- [ ] **Step 2: Run tests and observe the expected failures** + + Run `./gradlew.bat :audio-dsp-core:test --tests '*ChannelLayoutTest' --tests '*BiquadDesignerTest' --tests '*LinearPhaseFirDesignerTest'`. + Expected: compilation failure because the design classes do not exist. + +- [ ] **Step 3: Implement the minimum-phase and linear-phase compiler** + + Implement RBJ peaking/shelf/pass/notch coefficients in double precision, response sampling on logarithmic frequency points, an iterative radix-2 real FFT/IFFT, Hann-windowed symmetric FIR generation, and a compiled plan that contains per-output-channel biquad chains plus optional FIR taps. Reject non-power-of-two tap counts and layouts whose channel count exceeds the sink's reported capability. + +- [ ] **Step 4: Run the math tests and verify the expected values** + + Re-run the focused command. Expected: all coefficient, response, symmetry, latency, and channel-layout tests pass. + +- [ ] **Step 5: Commit** + + `git add audio-dsp-core/src/main/kotlin audio-dsp-core/src/test/kotlin` + `git commit -m "feat(audio): compile PEQ and linear phase FIR plans"` + +### Task 3: Add the reference streaming PCM processor and limiter + +**Files:** +- Create: `audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/StreamingDspProcessor.kt` +- Create: `audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinkedLimiter.kt` +- Test: `audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/StreamingDspProcessorTest.kt` +- Test: `audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio/LinkedLimiterTest.kt` + +**Interfaces:** +- `StreamingDspProcessor.process(interleavedPcm: FloatArray, frameCount: Int): FloatArray` +- `StreamingDspProcessor.queuePlan(plan: CompiledDspPlan)` +- `StreamingDspProcessor.endOfStream(): FloatArray` +- `LinkedLimiter.process(interleaved: FloatArray, channels: Int): FloatArray` + +- [ ] **Step 1: Write failing streaming tests** + + Feed an impulse through identity, +6 dB PEQ, and linear FIR plans; assert that channel count is unchanged, FIR delay is bounded by the common group delay, end-of-stream flushes the tail, plan replacement crossfades without a discontinuity above the configured threshold, and a full-scale boosted sample is bounded below 0 dBFS by the linked limiter. + +- [ ] **Step 2: Run the tests and verify red** + + Run `./gradlew.bat :audio-dsp-core:test --tests '*StreamingDspProcessorTest' --tests '*LinkedLimiterTest'`. + Expected: compilation failure because the streaming classes do not exist. + +- [ ] **Step 3: Implement interleaved float processing** + + Use per-channel biquad state and overlap-save/partitioned FFT convolution for FIR taps. Keep one pending plan and perform a 20 ms equal-power crossfade at a frame boundary. Use linked peak detection for the limiter so a multichannel image does not shift when one channel clips. Convert no sample formats in this module; format adapters belong to player-core. + +- [ ] **Step 4: Run the streaming tests and verify green** + + Re-run the focused command and inspect that all tests pass without NaN or allocation-growth failures. + +- [ ] **Step 5: Commit** + + `git add audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/StreamingDspProcessor.kt audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinkedLimiter.kt audio-dsp-core/src/test/kotlin/com/miruplay/tv/audio` + `git commit -m "feat(audio): process multichannel PCM with safe plan swaps"` + +### Task 4: Persist DSP settings and expose the WebAPI contract + +**Files:** +- Modify: `repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt` +- Modify: `data/src/main/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManager.kt` +- Modify: `web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt` +- Modify: `web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/NanoHttpWebControlServer.kt` +- Modify: `web-control/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlService.kt` +- Test: `data/src/test/kotlin/com/miruplay/tv/data/preferences/PlaybackPreferencesManagerTest.kt` +- Test: `web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlAudioDspRouteTest.kt` + +**Interfaces:** +- Repository methods: `getAudioDspConfig`, `setAudioDspConfig`, `getAudioDspEnabled`, `setAudioDspEnabled`, `getAudioDspPresetId`, `setAudioDspPresetId`. +- Routes: `GET /api/audio-dsp`, `PUT /api/audio-dsp`, `POST /api/audio-dsp/preview`. +- DTOs: `AudioDspConfigDto`, `AudioDspCapabilitiesDto`, `AudioDspPreviewRequest`, and `AudioDspPreviewDto`. + +- [ ] **Step 1: Add failing persistence and route tests** + + Assert that a neutral config survives manager round-trip, malformed JSON recovers to neutral, a complete PUT returns the normalized config, invalid bands return HTTP 400 without changing the stored config, and preview returns the same frequency sample count requested by the client. + +- [ ] **Step 2: Run the tests and verify red** + + Run `./gradlew.bat :data:test --tests '*PlaybackPreferencesManagerTest'` and `./gradlew.bat :web-control-core:test --tests '*WebControlAudioDspRouteTest'`. + Expected: compilation failure for missing repository methods, DTOs, and routes. + +- [ ] **Step 3: Implement persistence and service mapping** + + Store one versioned JSON blob beside the existing playback preferences and keep the two TV projection keys nullable-compatible. Extend NanoHTTPD routing with strict serializers, field-level validation errors, and atomic save/apply through `WebControlService`. Keep the existing `/api/settings/playback` response backward compatible while adding the enabled flag and selected preset id. + +- [ ] **Step 4: Run the focused tests and verify green** + + Re-run both focused commands. Expected: round-trip, malformed payload, validation, and preview tests pass. + +- [ ] **Step 5: Commit** + + `git add repository-api data web-control-core/src web-control/src web-control-core/src/test data/src/test` + `git commit -m "feat(audio): persist DSP config and expose WebAPI"` + +### Task 5: Integrate Exo and GL with Media3 PCM processing + +**Files:** +- Modify: `player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt` +- Modify: `player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt` +- Modify: `player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt` +- Create: `player-core/src/main/kotlin/com/miruplay/tv/player/Media3AudioDspProcessor.kt` +- Create: `player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspRuntime.kt` +- Test: `player-core/src/test/kotlin/com/miruplay/tv/player/Media3AudioDspProcessorTest.kt` +- Test: `player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspRuntimeTest.kt` + +**Interfaces:** +- `AudioDspRuntime.currentPlan(): StateFlow` +- `AudioDspRuntime.apply(config: AudioDspConfig, sampleRateHz: Int, layout: ChannelLayout)` +- `Media3AudioDspProcessor` implements Media3 `AudioProcessor` and delegates frame work to `StreamingDspProcessor`. + +- [ ] **Step 1: Write failing processor tests** + + Feed Media3-format PCM buffers and assert format negotiation rejects encoded input, accepts PCM 16-bit/float, keeps a 5.1 channel count when the output mask allows it, produces stereo for HRTF mode, and flushes the FIR tail at end-of-stream. + +- [ ] **Step 2: Run the focused tests and verify red** + + Run `./gradlew.bat :player-core:test --tests '*Media3AudioDspProcessorTest' --tests '*AudioDspRuntimeTest'`. + Expected: compilation failure because the adapter/runtime classes do not exist. + +- [ ] **Step 3: Wire both renderer factories to the custom audio sink** + + Build a `DefaultAudioSink` with a `DefaultAudioProcessorChain` containing the DSP processor and pass the same runtime to standard and experimental renderer factories. When the runtime is disabled, preserve the existing sink construction; when enabled, force PCM and disable offload/passthrough/tunneling. On a channel-capability mismatch, apply the configured matrix/HRTF route and publish `effectiveOutputMode`, `inputChannels`, and `outputChannels`. + +- [ ] **Step 4: Run focused tests and a compile-only player build** + + Run the focused tests and `./gradlew.bat :player-core:compileDebugKotlin`. Expected: processor tests pass and both factories compile. + +- [ ] **Step 5: Commit** + + `git add player-core/src/main/kotlin player-core/src/test/kotlin` + `git commit -m "feat(audio): attach DSP processor to Exo backends"` + +### Task 6: Integrate embedded mpv and rebuild IJK native audio filters + +**Files:** +- Modify: `player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt` +- Modify: `player-mpv-android/src/main/kotlin/is/xyz/mpv/MiruMpvSurfaceView.kt` +- Modify: `player-ijkplayer-android/src/main/kotlin/com/miruplay/tv/player/ijk/android/MiruIjkSurfaceView.kt` +- Modify: `player-ijkplayer-android/build.gradle.kts` +- Modify: `third_party/ijkplayer/README.md` +- Create: `player-core/src/test/kotlin/com/miruplay/tv/player/AudioFilterOptionBuilderTest.kt` +- Modify: `player-mpv-android/src/test/kotlin/is/xyz/mpv/MiruMpvSurfaceViewTest.kt` + +**Interfaces:** +- `buildMpvAudioFilterPlan(plan: CompiledDspPlan, hrirPath: String?): Map` +- `buildIjkAudioFilterOption(plan: CompiledDspPlan, hrirPath: String?): String` +- Both adapters expose an apply/reload method that keeps the last working filter string when native initialization fails. + +- [ ] **Step 1: Write failing option-builder tests** + + Assert minimum-phase PEQ produces ordered FFmpeg biquad filters, linear mode produces `firequalizer` with the expected delay/tap quality, standard downmix preserves ITU coefficients, HRTF names the correct 5.1/7.1 layout, and invalid paths fail closed. + +- [ ] **Step 2: Run tests and verify red** + + Run `./gradlew.bat :player-core:test --tests '*AudioFilterOptionBuilderTest'` and `./gradlew.bat :player-mpv-android:test --tests '*MiruMpvSurfaceViewTest'`. + Expected: compilation failure for the new option builders. + +- [ ] **Step 3: Add mpv filter-plan application** + + Extend `SessionOptions` with a validated audio filter string and optional HRTF asset path. Apply it through `MPVLib.setOptionString("af", ...)` before `loadfile`; use `lavfi-complex` only when the HRTF graph needs an additional HRIR input. Reapply the graph at a safe pause/reload boundary and retain the existing `ao=audiotrack,opensles` output. + +- [ ] **Step 4: Rebuild and pin IJK with audio filtering** + + Clone the documented debugly/ijkplayer revision in a build scratch directory, enable the selected FFmpeg `avfilter` audio components, build release AARs for both ABIs with the project JDK/NDK toolchain, extract only the classes jar and two native libraries, verify hashes, and update the provenance/notice file. Add `af`/`afilters` option wiring to `MiruIjkSurfaceView` while keeping `IAndroidIO` unchanged. + +- [ ] **Step 5: Run native option tests and backend compilation** + + Run the focused tests, `./gradlew.bat :player-mpv-android:test`, and `./gradlew.bat :player-ijkplayer-android:assembleDebug`. Expected: filter options are deterministic and both ABI payloads package. + +- [ ] **Step 6: Commit** + + `git add player-core player-mpv-android player-ijkplayer-android third_party/ijkplayer` + `git commit -m "feat(audio): add native mpv and ijk DSP filters"` + +### Task 7: Add HRTF runtime assets and native adapter coverage + +**Files:** +- Create: `third_party/resonance-audio/LICENSE` +- Create: `third_party/resonance-audio/NOTICE` +- Create: `player-core/src/main/cpp/audio_dsp_jni.cpp` +- Create: `player-core/src/main/cpp/CMakeLists.txt` +- Create: `player-core/src/main/kotlin/com/miruplay/tv/player/ResonanceHrtfRenderer.kt` +- Create: `player-core/src/test/kotlin/com/miruplay/tv/player/ResonanceHrtfRendererTest.kt` +- Modify: `player-core/build.gradle.kts` + +**Interfaces:** +- `ResonanceHrtfRenderer.create(sampleRateHz: Int, inputLayout: ChannelLayout): Long` +- `ResonanceHrtfRenderer.process(input: FloatArray, frames: Int): FloatArray` +- `ResonanceHrtfRenderer.release()` + +- [ ] **Step 1: Write failing HRTF tests** + + Assert that 5.1 and 7.1 processors produce interleaved stereo, mono/stereo inputs use the documented speaker positions, a zero input remains zero, and renderer creation rejects unsupported channel layouts without crashing. + +- [ ] **Step 2: Run tests and verify red** + + Run `./gradlew.bat :player-core:test --tests '*ResonanceHrtfRendererTest'`. + Expected: compilation failure because the JNI wrapper and renderer do not exist. + +- [ ] **Step 3: Vendor the minimal Apache-2.0 Resonance Audio/SADIE implementation** + + Pin Resonance Audio commit `4556a46afd4ffae092aa281bfd072eb0279d3a29`, retain its Apache license and notices, include the generated SADIE HRTF asset needed by `BinauralSurroundRenderer`, and compile a small JNI wrapper for `arm64-v8a` and `armeabi-v7a`. Keep the wrapper allocation-free on the audio callback after initialization. + +- [ ] **Step 4: Run HRTF unit/native tests and package check** + + Run the focused tests and `./gradlew.bat :player-core:assembleDebug`. Expected: HRTF tests pass and both native ABIs are present in the APK. + +- [ ] **Step 5: Commit** + + `git add third_party/resonance-audio player-core/src/main/cpp player-core/src/main/kotlin/com/miruplay/tv/player/ResonanceHrtfRenderer.kt player-core/src/test/kotlin/com/miruplay/tv/player/ResonanceHrtfRendererTest.kt player-core/build.gradle.kts` + `git commit -m "feat(audio): add Apache HRTF renderer"` + +### Task 8: Add TV controls and complete WebUI editing + +**Files:** +- Modify: `ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt` +- Modify: `ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt` +- Modify: `ui-tv/src/test/kotlin/com/miruplay/tv/ui/settings/SettingsAudioDspTest.kt` +- Modify: `web-control/frontend/src/App.vue` +- Modify: `web-control/frontend/src/styles.css` + +**Interfaces:** +- TV setters: `setAudioDspEnabled(Boolean)` and `setAudioDspPresetId(String)`. +- WebUI methods: `loadAudioDsp`, `saveAudioDsp`, `previewAudioDsp`, `exportAudioDsp`, and `importAudioDsp`. + +- [ ] **Step 1: Write failing TV state tests** + + Assert that the TV settings flow loads disabled/neutral defaults, persists a switch change, preserves the selected preset while disabled, and exposes only the switch and preset selector in the playback panel. + +- [ ] **Step 2: Run the focused UI tests and verify red** + + Run `./gradlew.bat :ui-tv:test --tests '*SettingsAudioDspTest'`. + Expected: compilation failure for missing state and actions. + +- [ ] **Step 3: Implement TV projection and WebUI view** + + Add two ViewModel flows/setters following existing playback settings patterns. Add a WebUI navigation item and an editor with preset list, band rows, channel target, phase/FIR controls, preamp/limiter, output mode, response canvas, JSON import/export, loading/error/status states, and apply button. Keep all full configuration fields out of the TV screen. + +- [ ] **Step 4: Run UI tests and the frontend production build** + + Run the focused Gradle test and `npm run build` in `web-control/frontend`. Expected: TV tests pass and Vite emits the packaged WebUI assets. + +- [ ] **Step 5: Commit** + + `git add ui-tv web-control/frontend` + `git commit -m "feat(audio): add TV toggle and full WebUI editor"` + +### Task 9: End-to-end verification and documentation + +**Files:** +- Modify: `docs/android-tv-player-controls.md` +- Create: `docs/verification/audio-dsp-hk1.md` +- Create: `player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspBackendParityTest.kt` + +- [ ] **Step 1: Add backend parity tests** + + Feed the same neutral, peaking, linear FIR, and standard downmix fixtures into the reference compiler and each option builder; assert identical channel policy, phase mode, tap count, and fallback reason. + +- [ ] **Step 2: Run the parity tests and verify red** + + Run `./gradlew.bat :player-core:test --tests '*AudioDspBackendParityTest'`. + Expected: failure until every adapter exposes the normalized plan metadata. + +- [ ] **Step 3: Run the complete verification matrix** + + Run `./gradlew.bat test`, `./gradlew.bat :app:assembleDebug`, and the WebUI build. Record any pre-existing timeout separately from feature failures. + +- [ ] **Step 4: Install and verify on HK1** + + Use the MiruPlay ADB helper for build/install/restart and raw `adb -s 192.168.63.237:5555` for read-only audio diagnostics. Verify standard Exo, embedded mpv, and IJK playback with DSP off/on, stereo PCM, a multichannel source when available, and HRTF fallback. Capture a deterministic 48 kHz sweep through the sndcpy-compatible playback-capture socket and compare the transfer-function change against the offline reference. + +- [ ] **Step 5: Document limitations and evidence** + + Record negotiated channel masks, effective routes, backend names, FIR latency, HRTF mode, and logcat errors in `docs/verification/audio-dsp-hk1.md`. Document that playback capture cannot validate downstream HDMI receiver processing or encoded passthrough while DSP is active. + +- [ ] **Step 6: Commit verification documentation** + + `git add docs/android-tv-player-controls.md docs/verification/audio-dsp-hk1.md player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspBackendParityTest.kt` + `git commit -m "test(audio): verify DSP backend parity and HK1 playback"` + diff --git a/docs/superpowers/specs/2026-08-03-audio-dsp-design.md b/docs/superpowers/specs/2026-08-03-audio-dsp-design.md new file mode 100644 index 00000000..d0b353de --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-audio-dsp-design.md @@ -0,0 +1,164 @@ +# MiruPlay Audio DSP Design + +## Goal + +Add a native PEQ and linear-phase FIR audio pipeline that works with every +current playback backend, preserves multichannel PCM when the Android output +supports it, and offers standard stereo or HRTF binaural downmix when the +selected output cannot accept the source channel layout. + +## Scope + +The feature covers: + +- Parametric EQ bands: peaking, low/high shelf, low/high pass, notch, and + band pass. +- Preamp/headroom protection and a linked output limiter. +- Minimum-phase biquad mode and linear-phase FIR mode generated from the same + PEQ magnitude target. +- Channel-layout aware processing for mono, stereo, 5.1, 7.1, and unknown + layouts with an explicit status when a layout is unsupported. +- Standard ITU stereo downmix and optional HRTF binaural downmix for 5.1/7.1. +- A common preset/configuration model, full WebUI editing, WebAPI round trips, + and TV-side enable/preset controls. +- Native adapters for standard Exo, GL/experimental Exo, embedded mpv, and + IJKPlayer. External mpv and legacy libVLC values continue to normalize to + their existing supported backends. + +This release does not implement stereo-to-surround upmixing, arbitrary user +matrix editing, room correction, loudness normalization, or microphone/audio +capture as a product feature. The processing graph has an explicit routing +stage so an additive upmix stage can be introduced without changing the +preset schema's existing fields. + +## Architecture + +### Configuration and DSP core + +`core:model` owns the serializable settings contract. A new pure Kotlin +`audio-dsp-core` module owns validation, channel-layout identifiers, RBJ +biquad design, frequency-response sampling, symmetric FIR generation, and +the backend-neutral compiled plan. The core never depends on Android or a +player implementation. + +The persisted object is versioned and contains: + +- `enabled` and `selectedPresetId`. +- `outputMode`: `AUTO_PRESERVE`, `STEREO_DOWNMIX`, or `HRTF_BINAURAL`. +- `presets`, each with a stable id, name, preamp, phase mode, FIR quality, + channel-group rules, and optional limiter settings. +- Channel rules target `ALL`, standard channel groups, or concrete channel + ids. A rule contains an ordered list of PEQ bands and an output gain. + +The default is disabled with one neutral preset. Unknown schema versions or +malformed values load as the neutral preset and are reported as a recoverable +configuration warning. + +### Processing graph + +The runtime graph is: + +``` +decoded PCM + -> channel-layout normalization (identity in this release) + -> per-output-channel PEQ / gain + -> optional linear-phase FIR for the PEQ magnitude target + -> optional standard downmix or HRTF binaural renderer + -> linked limiter and PCM sink +``` + +The graph never changes the channel count in `AUTO_PRESERVE` when the sink +advertises the requested PCM layout. If it cannot, `AUTO_PRESERVE` selects +the configured stereo fallback and publishes the effective route. HRTF is +only used for a deliberate `HRTF_BINAURAL` selection or an automatic fallback +that explicitly names HRTF. HRTF's interaural phase is intentional; the +linear-phase guarantee applies to the PEQ correction stage and does not erase +the HRTF spatial cues. + +Linear FIRs are designed from the validated PEQ magnitude response by +frequency sampling and an inverse real FFT. Coefficients are symmetric and +share one tap count/group delay across all active channels, including flat +channels, so channel alignment is preserved. FIR quality selects 1024, 2048, +or 4096 taps; 2048 is the default. Runtime plan changes crossfade at a frame +boundary and never replace coefficients mid-frame. + +The HRTF path uses the Apache-2.0 Resonance Audio binaural surround renderer +and its Apache-2.0 SADIE HRTF asset. 5.1 and 7.1 input ordering is normalized +before rendering to the ITU-R BS.775-3 speaker positions. Standard downmix +uses an explicit ITU matrix and keeps LFE attenuation bounded. If an HRTF +asset or renderer cannot initialize, playback falls back to the standard +matrix only when the user selected automatic fallback; an explicit HRTF +selection reports an error and leaves the previous audio plan active. + +### Backend adapters + +- **Exo and GL:** build both Media3 players with a custom `AudioSink` whose + `AudioProcessorChain` contains the native/common DSP processor. The sink + advertises only PCM while DSP is active, disables offload/passthrough, and + keeps the negotiated channel mask instead of forcing stereo. +- **Embedded mpv:** translate the compiled plan to mpv `af`/lavfi options. + Minimum-phase PEQ uses FFmpeg biquad filters; linear mode uses + `firequalizer` with the sampled response; downmix uses an explicit `pan` + matrix; HRTF uses FFmpeg's multichannel headphone/SOFA path. Runtime option + changes are applied through the existing `MPVLib` property/command path. +- **IJKPlayer:** rebuild the pinned debugly/ijkplayer source for both shipped + ABIs with the audio filter components enabled, retain the existing AndroidIO + bridge, and pass the same normalized filter plan through the native player + option. The artifact's source revision, hashes, licenses, and build inputs + remain documented under `third_party/ijkplayer`. + +Every adapter reports its effective sample rate, input/output layout, phase +mode, tap count, and fallback reason through the shared playback diagnostics +model. Backend filter initialization is fail-closed: it keeps the previous +working plan rather than emitting unprocessed audio while claiming DSP is on. + +### Settings, WebAPI, and WebUI + +Playback preferences gain a small TV-facing enabled/preset projection. The +full configuration lives behind a dedicated `/api/audio-dsp` contract: + +- `GET /api/audio-dsp` returns the versioned config, presets, capabilities, + current effective route, and warnings. +- `PUT /api/audio-dsp` validates and atomically persists the complete config, + then applies it to the current controller at the next safe audio boundary. +- `POST /api/audio-dsp/preview` accepts one unsaved preset and returns the + sampled magnitude/phase curves used by the WebUI graph. + +The existing playback settings endpoint carries only the TV projection and +remains backward compatible with nullable request fields. The WebUI gets a +dedicated Audio DSP view with preset CRUD, PEQ row editing, channel-group +selection, preamp/limiter controls, output/downmix/HRTF selection, phase/FIR +quality controls, response graph, JSON import/export, and apply status. The +TV settings screen exposes only the DSP switch and preset selector. + +### Error handling and compatibility + +- DSP defaults off, so existing installations retain passthrough behavior. +- Enabling DSP forces decoded PCM and disables encoded direct/offload/tunnel + output. Disabling DSP restores the previous player output policy. +- Unsupported input layouts are normalized to a documented identity or + stereo fallback; no silent channel reordering is allowed. +- Invalid API payloads return a structured 400 response with field errors and + do not change the stored or active plan. +- A backend-specific filter failure leaves the last working plan active and + exposes the reason to WebAPI/WebUI diagnostics. +- DSP changes made from WebUI apply to the active playback session at a safe + boundary; a new playback session always reads the persisted plan. + +## Verification + +The pure core gets deterministic tests for band coefficient behavior, PEQ +response sampling, FIR symmetry/group delay, channel-mask normalization, +ITU downmix gains, HRTF output channel count, limiter headroom, and malformed +config recovery. Android tests cover 16-bit and float PCM processors, +end-of-stream flushing, plan crossfade, Media3 sink negotiation, and backend +option translation. WebAPI tests cover GET/PUT/preview round trips and +validation errors; the WebUI build is part of the module verification. + +Device verification on the HK1 Android 13 box uses a deterministic 48 kHz +PCM sweep captured through the sndcpy/scrcpy playback-capture path as a +secondary integration check. Offline impulse/sweep tests at the processor +boundary are authoritative because playback capture cannot prove downstream +HDMI receiver processing. Evidence records the selected backend, negotiated +channel mask, effective DSP plan, and any automatic stereo/HRTF fallback. + diff --git a/docs/verification/audio-dsp-hk1.md b/docs/verification/audio-dsp-hk1.md new file mode 100644 index 00000000..a44920fb --- /dev/null +++ b/docs/verification/audio-dsp-hk1.md @@ -0,0 +1,30 @@ +# Audio DSP Verification + +## Build and automated checks + +- `:core:model:test --tests '*AudioDspModelsTest'` +- `:audio-dsp-core:test` +- `:data:testDebugUnitTest --tests '*PlaybackPreferencesManagerTest'` +- `:player-core:testDebugUnitTest --tests '*DspAudioProcessorTest' --tests '*AudioDspMpvOptionsTest' --tests '*AudioDspOutputPolicyTest'` +- `:web-control-core:test --tests '*WebControlSettingsRouteTest'` +- `:ui-tv:compileDebugKotlin` +- `:web-control:compileDebugKotlin` +- `:app:assembleDebug` +- `web-control/frontend`: `bun run build` + +All commands completed successfully on 2026-08-03. + +## Android TV smoke check + +The debug APK was installed on the configured HK1 Android TV test device and launched with the native ADB shell flow. The settings screen exposed the new `音频 PEQ / DSP` section, `Neutral` preset, and TV-only enable switch. Toggling the switch wrote the versioned `audio_dsp_config` preference and the test restored the switch to disabled afterwards. No AndroidRuntime crash was observed in the launch log. + +## Audio capture limits + +QtScrcpy's sndcpy path uses Android `AudioPlaybackCapture` and exposes 48 kHz, stereo, 16-bit PCM over an ADB-forwarded socket. It is useful for comparing DSP on/off response curves, but it does not observe HDMI sink negotiation, encoded passthrough, or the receiving device's multichannel/downmix behavior. HDMI preservation and HRTF output therefore remain covered by the pure Kotlin channel/downmix tests and backend option tests; they require an external HDMI analyzer or receiver-side capture for end-to-end confirmation. + +## Backend route notes + +- Exo/GL force decoded PCM while DSP is enabled and keep the negotiated multichannel layout when the sink supports it. +- Embedded mpv and IJK receive native FFmpeg-style filter options. Their exact filter acceptance still depends on the native binary shipped by the selected backend and should be checked in a device playback session when a suitable multichannel test file is available. +- Saving a WebUI or TV change updates the persisted/runtime configuration; an already-running audio renderer keeps its current plan and applies the new plan on the next playback session. +- HRTF currently uses the built-in fixed binaural compatibility matrix, not a measured HRIR/SOFA renderer. A native HRIR renderer can be added later without changing the WebUI contract. diff --git a/player-core/build.gradle.kts b/player-core/build.gradle.kts index 931e52f7..1798763c 100644 --- a/player-core/build.gradle.kts +++ b/player-core/build.gradle.kts @@ -21,6 +21,7 @@ android { dependencies { api(project(":core:model")) + implementation(project(":audio-dsp-core")) implementation(project(":core:common")) implementation(project(":repository-api")) implementation(project(":player-mpv-android")) diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspMpvOptions.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspMpvOptions.kt new file mode 100644 index 00000000..8d1c265b --- /dev/null +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspMpvOptions.kt @@ -0,0 +1,54 @@ +package com.miruplay.tv.player + +import com.miruplay.tv.model.AudioDspConfig +import com.miruplay.tv.model.AudioDspFilterType +import com.miruplay.tv.model.AudioDspOutputMode +import com.miruplay.tv.model.AudioDspPhaseMode +import kotlin.math.pow + +fun buildAudioDspMpvOptions(config: AudioDspConfig): Map { + if (!config.enabled) return emptyMap() + val preset = config.presets.firstOrNull { it.id == config.selectedPresetId } + ?: config.presets.firstOrNull() + ?: return emptyMap() + val filters = mutableListOf() + if (preset.preampDb != 0f) filters += "volume=${preset.preampDb}dB" + if (preset.phaseMode == AudioDspPhaseMode.LINEAR) { + val entries = preset.rules.flatMap { it.bands }.filter { it.enabled } + .joinToString(";") { band -> "${band.frequencyHz}:${band.gainDb}" } + filters += "firequalizer=gain_entry='${entries.ifBlank { "0:0" }}'" + } else { + preset.rules.flatMap { it.bands }.filter { it.enabled }.forEach { band -> + val kind = when (band.type) { + AudioDspFilterType.PEAKING -> "biquad" + AudioDspFilterType.LOW_SHELF -> "lowshelf" + AudioDspFilterType.HIGH_SHELF -> "highshelf" + AudioDspFilterType.LOW_PASS -> "lowpass" + AudioDspFilterType.HIGH_PASS -> "highpass" + AudioDspFilterType.NOTCH -> "bandreject" + AudioDspFilterType.BAND_PASS -> "bandpass" + } + filters += "$kind=f=${band.frequencyHz}:g=${band.gainDb}:w=${band.q}" + } + } + preset.rules + .filter { it.target == com.miruplay.tv.model.AudioDspChannelTarget.ALL && it.outputGainDb != 0f } + .forEach { rule -> filters += "volume=${rule.outputGainDb}dB" } + if (preset.limiter.enabled) { + val ceiling = 10.0.pow(preset.limiter.ceilingDb.toDouble() / 20.0) + filters += "alimiter=limit=$ceiling:release=${preset.limiter.releaseMs}" + } + when (preset.outputMode) { + AudioDspOutputMode.STEREO_DOWNMIX -> filters += "pan=stereo|c0=0.707*c0+0.707*c2+0.5*c4+0.5*c6|c1=0.707*c1+0.707*c2+0.5*c5+0.5*c7" + AudioDspOutputMode.HRTF_BINAURAL -> filters += "headphone=map=FL|FR|FC|BL|BR" + AudioDspOutputMode.AUTO_PRESERVE -> Unit + } + return mapOf( + "ao" to "audiotrack", + "audio-spdif" to "no", + "audio-exclusive" to "no", + "audio-format" to "float", + "audio-channels" to "auto", + "af" to filters.joinToString(",").ifBlank { "lavfi=[anull]" }, + ) +} diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspOutputPolicy.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspOutputPolicy.kt new file mode 100644 index 00000000..55ab9853 --- /dev/null +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspOutputPolicy.kt @@ -0,0 +1,29 @@ +package com.miruplay.tv.player + +import com.miruplay.tv.model.AudioDspConfig + +data class AudioDspOutputPolicy( + val forcePcm: Boolean, + val allowOffload: Boolean, + val allowPassthrough: Boolean, + val allowTunneling: Boolean, +) { + companion object { + fun forConfig(config: AudioDspConfig): AudioDspOutputPolicy = + if (config.enabled) { + AudioDspOutputPolicy( + forcePcm = true, + allowOffload = false, + allowPassthrough = false, + allowTunneling = false, + ) + } else { + AudioDspOutputPolicy( + forcePcm = false, + allowOffload = true, + allowPassthrough = true, + allowTunneling = true, + ) + } + } +} diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspRuntimeConfig.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspRuntimeConfig.kt new file mode 100644 index 00000000..f4bbafa5 --- /dev/null +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/AudioDspRuntimeConfig.kt @@ -0,0 +1,13 @@ +package com.miruplay.tv.player + +import com.miruplay.tv.model.AudioDspConfig + +class AudioDspRuntimeConfig { + @Volatile + var config: AudioDspConfig = AudioDspConfig.neutral() + private set + + fun update(value: AudioDspConfig) { + config = value.normalized() + } +} diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt index 949cd961..8f037992 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt @@ -17,14 +17,19 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object PlayerModule { + @Provides + @Singleton + fun provideAudioDspRuntimeConfig(): AudioDspRuntimeConfig = AudioDspRuntimeConfig() + @Provides @Singleton @StandardPlaybackPlayer fun provideStandardExoPlayer( @ApplicationContext context: Context, dataSourceFactory: PlaybackDataSourceFactory, + audioDspRuntimeConfig: AudioDspRuntimeConfig, ): ExoPlayer { - val renderersFactory = DefaultRenderersFactory(context) + val renderersFactory = DspRenderersFactory(context, audioDspRuntimeConfig) .forceDisableMediaCodecAsynchronousQueueing() .setEnableDecoderFallback(true) .setMediaCodecSelector(PlaybackMediaCodecSelector) @@ -40,8 +45,9 @@ object PlayerModule { fun provideExperimentalExoPlayer( @ApplicationContext context: Context, dataSourceFactory: PlaybackDataSourceFactory, + audioDspRuntimeConfig: AudioDspRuntimeConfig, ): ExoPlayer { - val renderersFactory = ExperimentalRenderersFactory(context) + val renderersFactory = ExperimentalRenderersFactory(context, audioDspRuntimeConfig) // The experimental HDR backend relies on stable HEVC surface attachment across // vendor codecs, so we bias toward compatibility over async throughput here. .forceDisableMediaCodecAsynchronousQueueing() @@ -71,6 +77,7 @@ object PlayerModule { playbackDebugOverrides: PlaybackDebugOverrides, externalMpvLauncher: AndroidExternalMpvLauncher, config: PlaybackConfig, + audioDspRuntimeConfig: AudioDspRuntimeConfig, ): ExoPlaybackController { return ExoPlaybackController( context = context, @@ -82,6 +89,7 @@ object PlayerModule { playbackDebugOverrides = playbackDebugOverrides, externalMpvLauncher = externalMpvLauncher, config = config, + audioDspRuntimeConfig = audioDspRuntimeConfig, ) } diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/DspAudioProcessor.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/DspAudioProcessor.kt new file mode 100644 index 00000000..8be2ee57 --- /dev/null +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/DspAudioProcessor.kt @@ -0,0 +1,101 @@ +@file:Suppress("UnsafeOptInUsageError") + +package com.miruplay.tv.player + +import androidx.media3.common.C +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.audio.BaseAudioProcessor +import com.miruplay.tv.audio.AudioDspPlanCompiler +import com.miruplay.tv.audio.ChannelLayout +import com.miruplay.tv.audio.CompiledDspPlan +import com.miruplay.tv.audio.StreamingDspProcessor +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DspAudioProcessor( + private val runtimeConfig: AudioDspRuntimeConfig, +) : BaseAudioProcessor() { + private var processor: StreamingDspProcessor? = null + private var compiledPlan: CompiledDspPlan? = null + private var channels: Int = 0 + private var encoding: Int = C.ENCODING_INVALID + + override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { + if (!runtimeConfig.config.enabled) return inputAudioFormat + if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT && inputAudioFormat.encoding != C.ENCODING_PCM_FLOAT) { + throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) + } + channels = inputAudioFormat.channelCount + encoding = inputAudioFormat.encoding + val layout = ChannelLayout.from(channels, null) + val preset = runtimeConfig.config.presets + .firstOrNull { it.id == runtimeConfig.config.selectedPresetId } + ?: runtimeConfig.config.presets.first() + compiledPlan = AudioDspPlanCompiler.compile(preset, layout, inputAudioFormat.sampleRate) + processor = StreamingDspProcessor(compiledPlan!!) + return if (compiledPlan!!.outputChannelCount == inputAudioFormat.channelCount) { + inputAudioFormat + } else { + AudioProcessor.AudioFormat( + inputAudioFormat.sampleRate, + compiledPlan!!.outputChannelCount, + inputAudioFormat.encoding, + ) + } + } + + override fun isActive(): Boolean = runtimeConfig.config.enabled && super.isActive() + + override fun queueInput(inputBuffer: ByteBuffer) { + val active = processor ?: run { + inputBuffer.position(inputBuffer.limit()) + return + } + val bytesPerSample = if (encoding == C.ENCODING_PCM_FLOAT) 4 else 2 + val frameSize = channels * bytesPerSample + val byteCount = inputBuffer.remaining() - (inputBuffer.remaining() % frameSize) + val frames = byteCount / frameSize + val source = inputBuffer.duplicate().order(ByteOrder.LITTLE_ENDIAN) + val samples = FloatArray(frames * channels) + for (index in samples.indices) { + samples[index] = if (encoding == C.ENCODING_PCM_FLOAT) { + source.float + } else { + source.short / 32_768f + } + } + inputBuffer.position(inputBuffer.position() + byteCount) + val outputSamples = active.process(samples, frames) + val output = replaceOutputBuffer(outputSamples.size * bytesPerSample).order(ByteOrder.LITTLE_ENDIAN) + outputSamples.forEach { sample -> + if (encoding == C.ENCODING_PCM_FLOAT) output.putFloat(sample) else { + output.putShort((sample.coerceIn(-1f, 1f) * 32_767f).toInt().toShort()) + } + } + output.flip() + } + + override fun onQueueEndOfStream() { + val tail = processor?.endOfStream() ?: FloatArray(0) + if (tail.isEmpty()) return + val output = replaceOutputBuffer(tail.size * if (encoding == C.ENCODING_PCM_FLOAT) 4 else 2) + .order(ByteOrder.LITTLE_ENDIAN) + tail.forEach { sample -> + if (encoding == C.ENCODING_PCM_FLOAT) output.putFloat(sample) else { + output.putShort((sample.coerceIn(-1f, 1f) * 32_767f).toInt().toShort()) + } + } + output.flip() + } + + override fun onFlush() { + processor = compiledPlan?.let(::StreamingDspProcessor) + } + + override fun onReset() { + processor = null + compiledPlan = null + channels = 0 + encoding = C.ENCODING_INVALID + } +} diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/DspRenderersFactory.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/DspRenderersFactory.kt new file mode 100644 index 00000000..663bfcde --- /dev/null +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/DspRenderersFactory.kt @@ -0,0 +1,24 @@ +@file:Suppress("UnsafeOptInUsageError", "RestrictedApi") + +package com.miruplay.tv.player + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink + +@UnstableApi +class DspRenderersFactory( + context: Context, + private val runtimeConfig: AudioDspRuntimeConfig, +) : DefaultRenderersFactory(context) { + override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean): AudioSink { + val policy = AudioDspOutputPolicy.forConfig(runtimeConfig.config) + return DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput || policy.forcePcm) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(DspAudioProcessor(runtimeConfig))) + .build() + } +} diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt index 76b3fd7a..0823d212 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/EmbeddedMpvSessionOptions.kt @@ -5,6 +5,7 @@ import android.os.Process import com.miruplay.tv.model.PeakDetectionStrategy import com.miruplay.tv.model.ToneMappingCurvePreset import com.miruplay.tv.model.ToneMappingRuleSet +import com.miruplay.tv.model.AudioDspConfig import `is`.xyz.mpv.MiruMpvSurfaceView fun buildEmbeddedMpvSessionOptions( @@ -13,6 +14,7 @@ fun buildEmbeddedMpvSessionOptions( speed: Float = 1.0f, runtimeAbiIs32Bit: Boolean = isEmbeddedMpvRuntime32Bit(), debugConfig: EmbeddedMpvDebugConfig = EmbeddedMpvDebugConfig(), + audioDspConfig: AudioDspConfig = AudioDspConfig.neutral(), ): MiruMpvSurfaceView.SessionOptions { val peakDetection = resolveEmbeddedMpvPeakDetection( strategy = effectiveEmbeddedMpvPeakDetectionStrategy( @@ -50,7 +52,7 @@ fun buildEmbeddedMpvSessionOptions( "osc" to "no", "input-default-bindings" to "yes", "sub-auto" to "no", - ), + ) + buildAudioDspMpvOptions(audioDspConfig), ) } diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt index 37a95501..9cda049f 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt @@ -85,6 +85,7 @@ class ExoPlaybackController @Inject constructor( private val playbackDebugOverrides: PlaybackDebugOverrides, private val externalMpvLauncher: AndroidExternalMpvLauncher, private val config: PlaybackConfig = PlaybackConfig(), + private val audioDspRuntimeConfig: AudioDspRuntimeConfig = AudioDspRuntimeConfig(), ) : PlaybackController { private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var remoteControlSession: MediaSession? = null @@ -167,6 +168,7 @@ class ExoPlaybackController @Inject constructor( playbackPreferences = playbackPreferencesRepository .getFormatAwareToneMappingPreferences() .normalized() + audioDspRuntimeConfig.update(playbackPreferencesRepository.getAudioDspConfig()) preferredSubtitleLanguage = playbackPreferencesRepository.getPreferredSubtitleLanguage() subtitleSelectionWasManual = false _requestedRenderBackend.value = sessionState.effectiveRequestedBackend(playbackPreferences.defaultBackend) @@ -1455,6 +1457,7 @@ class ExoPlaybackController @Inject constructor( headers = ijkRequestHeaders, androidIo = ijkAndroidIo, hardwareDecode = true, + audioDspOptions = buildAudioDspMpvOptions(audioDspRuntimeConfig.config), ), ) view.setPlaybackSpeed(ijkPlaybackSpeed) @@ -1783,6 +1786,7 @@ class ExoPlaybackController @Inject constructor( shaderPaths = shaderPaths, speed = speed, debugConfig = playbackDebugOverrides.embeddedMpvDebugConfig, + audioDspConfig = audioDspRuntimeConfig.config, ) } diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt index f907d673..40300ab9 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExperimentalRenderersFactory.kt @@ -11,14 +11,30 @@ import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink @UnstableApi class ExperimentalRenderersFactory( context: Context, + private val audioDspRuntimeConfig: AudioDspRuntimeConfig, ) : DefaultRenderersFactory(context) { private val experimentalVideoPipelineMode = resolveExperimentalVideoPipelineMode(resolveDeviceGlEsMajorVersion(context)) + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink { + val policy = AudioDspOutputPolicy.forConfig(audioDspRuntimeConfig.config) + return DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput || policy.forcePcm) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(DspAudioProcessor(audioDspRuntimeConfig))) + .build() + } + override fun buildVideoRenderers( context: Context, extensionRendererMode: Int, diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspMpvOptionsTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspMpvOptionsTest.kt new file mode 100644 index 00000000..df8dad1e --- /dev/null +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspMpvOptionsTest.kt @@ -0,0 +1,67 @@ +package com.miruplay.tv.player + +import com.miruplay.tv.model.AudioDspBand +import com.miruplay.tv.model.AudioDspChannelRule +import com.miruplay.tv.model.AudioDspConfig +import com.miruplay.tv.model.AudioDspFilterType +import com.miruplay.tv.model.AudioDspOutputMode +import com.miruplay.tv.model.AudioDspPreset +import com.miruplay.tv.model.AudioDspLimiter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioDspMpvOptionsTest { + @Test + fun `disabled config leaves mpv direct output untouched`() { + assertTrue(buildAudioDspMpvOptions(AudioDspConfig.neutral()).isEmpty()) + } + + @Test + fun `enabled config emits pcm biquad and stereo downmix options`() { + val config = AudioDspConfig( + enabled = true, + presets = listOf( + AudioDspPreset( + "movie", + "Movie", + outputMode = AudioDspOutputMode.STEREO_DOWNMIX, + rules = listOf( + AudioDspChannelRule( + bands = listOf(AudioDspBand(AudioDspFilterType.PEAKING, 1_000f, 6f, 1f)), + ), + ), + ), + ), + selectedPresetId = "movie", + ) + val options = buildAudioDspMpvOptions(config) + + assertEquals("no", options["audio-spdif"]) + assertEquals("audiotrack", options["ao"]) + assertTrue(options.getValue("af").contains("biquad")) + assertTrue(options.getValue("af").contains("pan=stereo")) + } + + @Test + fun `mpv options apply preamp and limiter controls`() { + val config = AudioDspConfig( + enabled = true, + presets = listOf( + AudioDspPreset( + "movie", + "Movie", + preampDb = -3f, + limiter = AudioDspLimiter(enabled = true, ceilingDb = -6f, releaseMs = 250f), + ), + ), + selectedPresetId = "movie", + ) + + val filters = buildAudioDspMpvOptions(config).getValue("af") + + assertTrue(filters.contains("volume=-3.0dB")) + assertTrue(filters.contains("alimiter=limit=")) + assertTrue(filters.contains("release=250.0")) + } +} diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspOutputPolicyTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspOutputPolicyTest.kt new file mode 100644 index 00000000..f4ff672a --- /dev/null +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/AudioDspOutputPolicyTest.kt @@ -0,0 +1,28 @@ +package com.miruplay.tv.player + +import com.miruplay.tv.model.AudioDspConfig +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioDspOutputPolicyTest { + @Test + fun `disabled dsp retains encoded output policy`() { + val policy = AudioDspOutputPolicy.forConfig(AudioDspConfig.neutral()) + + assertFalse(policy.forcePcm) + assertTrue(policy.allowOffload) + assertTrue(policy.allowPassthrough) + assertTrue(policy.allowTunneling) + } + + @Test + fun `enabled dsp forces pcm and disables direct output paths`() { + val policy = AudioDspOutputPolicy.forConfig(AudioDspConfig(enabled = true)) + + assertTrue(policy.forcePcm) + assertFalse(policy.allowOffload) + assertFalse(policy.allowPassthrough) + assertFalse(policy.allowTunneling) + } +} diff --git a/player-core/src/test/kotlin/com/miruplay/tv/player/DspAudioProcessorTest.kt b/player-core/src/test/kotlin/com/miruplay/tv/player/DspAudioProcessorTest.kt new file mode 100644 index 00000000..6342c2f4 --- /dev/null +++ b/player-core/src/test/kotlin/com/miruplay/tv/player/DspAudioProcessorTest.kt @@ -0,0 +1,66 @@ +package com.miruplay.tv.player + +import androidx.media3.common.C +import androidx.media3.common.audio.AudioProcessor +import com.miruplay.tv.model.AudioDspConfig +import com.miruplay.tv.model.AudioDspOutputMode +import com.miruplay.tv.model.AudioDspPreset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class DspAudioProcessorTest { + @Test + fun `enabled processor keeps pcm format and processes stereo int16`() { + val runtime = AudioDspRuntimeConfig().also { it.update(AudioDspConfig(enabled = true)) } + val processor = DspAudioProcessor(runtime) + val format = AudioProcessor.AudioFormat(48_000, 2, C.ENCODING_PCM_16BIT) + + processor.configure(format) + processor.flush() + val input = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) + input.putShort(8_192).putShort(-8_192) + input.flip() + processor.queueInput(input) + val output = processor.output + + assertEquals(4, output.remaining()) + assertTrue(processor.isActive) + } + + @Test + fun `disabled processor remains inactive`() { + val processor = DspAudioProcessor(AudioDspRuntimeConfig()) + + processor.configure(AudioProcessor.AudioFormat(48_000, 2, C.ENCODING_PCM_16BIT)) + + assertTrue(!processor.isActive) + } + + @Test + fun `six channel stereo downmix uses canonical fallback layout`() { + val runtime = AudioDspRuntimeConfig().also { + it.update( + AudioDspConfig( + enabled = true, + selectedPresetId = "downmix", + presets = listOf(AudioDspPreset("downmix", "Downmix", outputMode = AudioDspOutputMode.STEREO_DOWNMIX)), + ), + ) + } + val processor = DspAudioProcessor(runtime) + val outputFormat = processor.configure(AudioProcessor.AudioFormat(48_000, 6, C.ENCODING_PCM_16BIT)) + processor.flush() + val input = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) + input.putShort(32_767).putShort(0).putShort(0).putShort(0).putShort(0).putShort(0) + input.flip() + processor.queueInput(input) + + val output = processor.output.order(ByteOrder.LITTLE_ENDIAN) + assertEquals(2, outputFormat.channelCount) + assertTrue(output.remaining() > 0) + assertTrue(output.short.toInt() > 20_000) + } +} diff --git a/player-ijkplayer-android/src/main/kotlin/com/miruplay/tv/player/ijk/android/MiruIjkSurfaceView.kt b/player-ijkplayer-android/src/main/kotlin/com/miruplay/tv/player/ijk/android/MiruIjkSurfaceView.kt index 508478f5..2ebbd5a5 100644 --- a/player-ijkplayer-android/src/main/kotlin/com/miruplay/tv/player/ijk/android/MiruIjkSurfaceView.kt +++ b/player-ijkplayer-android/src/main/kotlin/com/miruplay/tv/player/ijk/android/MiruIjkSurfaceView.kt @@ -31,6 +31,7 @@ data class MiruIjkPlaybackRequest( val headers: Map = emptyMap(), val androidIo: MiruIjkAndroidIo? = null, val hardwareDecode: Boolean = true, + val audioDspOptions: Map = emptyMap(), ) data class MiruIjkAudioTrack( @@ -145,6 +146,9 @@ class MiruIjkSurfaceView @JvmOverloads constructor( mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec", if (request.hardwareDecode) 1L else 0L) mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-auto-rotate", 1L) mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-handle-resolution-change", 1L) + request.audioDspOptions.forEach { (name, value) -> + mediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, name, value) + } mediaPlayer.setScreenOnWhilePlaying(true) mediaPlayer.setDisplay(holder) mediaPlayer.setOnPreparedListener { prepared -> diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt index 03922ecd..9be8233e 100644 --- a/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/PlaybackPreferencesRepository.kt @@ -4,6 +4,7 @@ import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.SubtitleLanguagePreference +import com.miruplay.tv.model.AudioDspConfig interface PlaybackPreferencesRepository { suspend fun getEndAction(): PlaybackEndAction @@ -17,4 +18,6 @@ interface PlaybackPreferencesRepository { suspend fun setSubtitleBackgroundTransparent(transparent: Boolean) = Unit suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences suspend fun setFormatAwareToneMappingPreferences(preferences: FormatAwareToneMappingPreferences) + suspend fun getAudioDspConfig(): AudioDspConfig = AudioDspConfig.neutral() + suspend fun setAudioDspConfig(config: AudioDspConfig) = Unit } diff --git a/settings.gradle.kts b/settings.gradle.kts index c7bca9ac..a5cbbce6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ include(":app") include(":background-task") include(":core:model") include(":core:common") +include(":audio-dsp-core") include(":ui-design") include(":repository-api") include(":media-source-api") diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt index f791251e..e1294d37 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Language @@ -49,6 +50,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.WifiTethering import androidx.compose.material3.Icon @@ -97,6 +99,7 @@ import com.miruplay.tv.model.supportedPlaybackRenderBackends import com.miruplay.tv.model.PosterWallArrangement import com.miruplay.tv.model.CLOUD_DRIVE_ROOT_DISPLAY_NAME import com.miruplay.tv.model.FormatAwareToneMappingPreferences +import com.miruplay.tv.model.AudioDspConfig import com.miruplay.tv.model.MediaContentMode import com.miruplay.tv.model.MediaRecognitionMode import com.miruplay.tv.model.MediaSourceInfo @@ -380,6 +383,7 @@ fun AddSourceScreen( val preferredSubtitleLanguage by viewModel.preferredSubtitleLanguage.collectAsStateWithLifecycle() val subtitleBackgroundTransparent by viewModel.subtitleBackgroundTransparent.collectAsStateWithLifecycle() val formatAwareToneMappingPreferences by viewModel.formatAwareToneMappingPreferences.collectAsStateWithLifecycle() + val audioDspConfig by viewModel.audioDspConfig.collectAsStateWithLifecycle() val savedTmdbToken by viewModel.tmdbToken.collectAsStateWithLifecycle() val webUiUrls by viewModel.webUiUrls.collectAsStateWithLifecycle() val webControlEnabled by viewModel.webControlEnabled.collectAsStateWithLifecycle() @@ -684,6 +688,9 @@ fun AddSourceScreen( formatAwareToneMappingPreferences = formatAwareToneMappingPreferences, onPlaybackBackendSelected = viewModel::setDefaultPlaybackBackend, onToneMappingPresetSelected = viewModel::setToneMappingPreset, + audioDspConfig = audioDspConfig, + onAudioDspEnabledChange = viewModel::setAudioDspEnabled, + onAudioDspPresetSelected = viewModel::setAudioDspPreset, savedToken = savedToken, tokenInput = tokenInput, tokenSaved = tokenSaved, @@ -1149,6 +1156,9 @@ private fun SettingsContent( formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences, onPlaybackBackendSelected: (PlaybackRenderBackend) -> Unit, onToneMappingPresetSelected: (VideoRenderRuleKey, ToneMappingProfilePreset) -> Unit, + audioDspConfig: AudioDspConfig, + onAudioDspEnabledChange: (Boolean) -> Unit, + onAudioDspPresetSelected: (String) -> Unit, savedToken: String, tokenInput: String, tokenSaved: Boolean, @@ -1412,6 +1422,9 @@ private fun SettingsContent( formatAwareToneMappingPreferences = formatAwareToneMappingPreferences, onPlaybackBackendSelected = onPlaybackBackendSelected, onToneMappingPresetSelected = onToneMappingPresetSelected, + audioDspConfig = audioDspConfig, + onAudioDspEnabledChange = onAudioDspEnabledChange, + onAudioDspPresetSelected = onAudioDspPresetSelected, ) } @@ -3153,6 +3166,9 @@ private fun PlaybackPanel( formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences, onPlaybackBackendSelected: (PlaybackRenderBackend) -> Unit, onToneMappingPresetSelected: (VideoRenderRuleKey, ToneMappingProfilePreset) -> Unit, + audioDspConfig: AudioDspConfig, + onAudioDspEnabledChange: (Boolean) -> Unit, + onAudioDspPresetSelected: (String) -> Unit, ) { SettingsPanel { Row(verticalAlignment = Alignment.CenterVertically) { @@ -3202,6 +3218,12 @@ private fun PlaybackPanel( color = if (endAction == PlaybackEndAction.PLAY_NEXT_EPISODE) ProgressGreen else TextSecondary ) + AudioDspTvControls( + config = audioDspConfig, + onEnabledChange = onAudioDspEnabledChange, + onPresetSelected = onAudioDspPresetSelected, + ) + Spacer(Modifier.height(24.dp)) Text(text = "多版本下一集策略", style = TvTypography.subtitle, color = TextPrimary) Spacer(Modifier.height(6.dp)) @@ -3384,6 +3406,68 @@ private fun PlaybackPanel( } } +@Composable +private fun AudioDspTvControls( + config: AudioDspConfig, + onEnabledChange: (Boolean) -> Unit, + onPresetSelected: (String) -> Unit, +) { + Spacer(Modifier.height(24.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.GraphicEq, + contentDescription = null, + tint = TextPrimary, + modifier = Modifier.size(26.dp), + ) + Spacer(Modifier.width(10.dp)) + Text(text = "音频 PEQ / DSP", style = TvTypography.subtitle, color = TextPrimary) + } + Spacer(Modifier.height(6.dp)) + Text( + text = "电视端仅提供总开关和预设切换,完整频段与线性相位设置请使用 WebUI。", + style = TvTypography.body, + color = TextSecondary, + ) + Spacer(Modifier.height(14.dp)) + ScanOptionChip( + text = if (config.enabled) "音频 DSP 已启用" else "音频 DSP 已关闭", + icon = Icons.Filled.GraphicEq, + selected = config.enabled, + enabled = true, + onClick = { onEnabledChange(!config.enabled) }, + modifier = Modifier.width(190.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "当前预设", + style = TvTypography.caption.copy(fontWeight = FontWeight.SemiBold), + color = TextSecondary, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + config.presets.forEach { preset -> + ScanOptionChip( + text = preset.name, + icon = Icons.Filled.Tune, + selected = config.selectedPresetId == preset.id, + enabled = true, + onClick = { onPresetSelected(preset.id) }, + modifier = Modifier.width(150.dp), + ) + } + } + StatusMessage( + icon = Icons.Filled.CheckCircle, + text = if (config.enabled) { + "当前预设:${config.presets.firstOrNull { it.id == config.selectedPresetId }?.name ?: config.selectedPresetId}" + } else { + "音频保持原始输出,不应用 PEQ 或 FIR" + }, + color = if (config.enabled) ProgressGreen else TextSecondary, + ) +} + @Composable private fun ScanOptionChip( text: String, diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt index 9c984f30..f51090e5 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/SettingsViewModel.kt @@ -16,6 +16,7 @@ import com.miruplay.tv.data.preferences.ScanPreferencesManager import com.miruplay.tv.data.preferences.PlaybackPreferencesManager import com.miruplay.tv.model.EpisodeVersionSelectionPolicy import com.miruplay.tv.model.FormatAwareToneMappingPreferences +import com.miruplay.tv.model.AudioDspConfig import com.miruplay.tv.model.PlaybackEndAction import com.miruplay.tv.model.PlaybackRenderBackend import com.miruplay.tv.model.SubtitleLanguagePreference @@ -179,6 +180,12 @@ class SettingsViewModel @Inject constructor( val formatAwareToneMappingPreferences: StateFlow = _formatAwareToneMappingPreferences.asStateFlow() + private val _audioDspConfig = MutableStateFlow( + runCatching { playbackPreferences.audioDspConfig.normalized() } + .getOrDefault(AudioDspConfig.neutral()) + ) + val audioDspConfig: StateFlow = _audioDspConfig.asStateFlow() + private val _webUiUrls = MutableStateFlow>(emptyList()) val webUiUrls: StateFlow> = _webUiUrls.asStateFlow() @@ -883,6 +890,20 @@ class SettingsViewModel @Inject constructor( _subtitleBackgroundTransparent.value = transparent } + fun setAudioDspEnabled(enabled: Boolean) { + val updated = _audioDspConfig.value.normalized().copy(enabled = enabled).normalized() + playbackPreferences.audioDspConfig = updated + _audioDspConfig.value = updated + } + + fun setAudioDspPreset(presetId: String) { + val current = _audioDspConfig.value.normalized() + if (current.presets.none { it.id == presetId }) return + val updated = current.copy(selectedPresetId = presetId).normalized() + playbackPreferences.audioDspConfig = updated + _audioDspConfig.value = updated + } + fun setDefaultPlaybackBackend(backend: PlaybackRenderBackend) { val updated = _formatAwareToneMappingPreferences.value.normalized().copy( defaultBackend = backend diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/NanoHttpWebControlServer.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/NanoHttpWebControlServer.kt index 9e0ca57b..70ba96cb 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/NanoHttpWebControlServer.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/NanoHttpWebControlServer.kt @@ -249,6 +249,18 @@ open class NanoHttpWebControlServer( val request = parseBody(session, PlaybackSettingsRequest.serializer()) jsonResponse(PlaybackSettingsDto.serializer(), webControlService.savePlaybackSettings(request)) } + session.method == Method.GET && route == "/api/audio-dsp" -> { + jsonResponse(AudioDspDto.serializer(), webControlService.getAudioDsp()) + } + session.method == Method.PUT && route == "/api/audio-dsp" -> { + val config = parseBody(session, com.miruplay.tv.model.AudioDspConfig.serializer()) + require(config.validationErrors().isEmpty()) { config.validationErrors().joinToString("; ") } + jsonResponse(AudioDspDto.serializer(), webControlService.saveAudioDsp(config)) + } + session.method == Method.POST && route == "/api/audio-dsp/preview" -> { + val request = parseBody(session, AudioDspPreviewRequest.serializer()) + jsonResponse(AudioDspPreviewDto.serializer(), webControlService.previewAudioDsp(request)) + } session.method == Method.GET && route == "/api/web-control/access" -> { jsonResponse(WebControlAccessDto.serializer(), webControlService.getWebControlAccess()) } diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlEndpointService.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlEndpointService.kt index 2d616e17..fd33d9d5 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlEndpointService.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlEndpointService.kt @@ -4,6 +4,7 @@ import com.miruplay.tv.model.MediaSourceInfo import com.miruplay.tv.model.RssSubscriptionInfo import com.miruplay.tv.repository.DEFAULT_LOCAL_LOG_READ_LIMIT import java.io.InputStream +import com.miruplay.tv.model.AudioDspConfig interface WebControlEndpointService { suspend fun getServerInfo(port: Int): ServerInfoDto @@ -79,6 +80,10 @@ interface WebControlEndpointService { throw UnsupportedOperationException("播放设置 not supported") suspend fun savePlaybackSettings(request: PlaybackSettingsRequest): PlaybackSettingsDto = throw UnsupportedOperationException("播放设置 not supported") + suspend fun getAudioDsp(): AudioDspDto = AudioDspDto() + suspend fun saveAudioDsp(config: AudioDspConfig): AudioDspDto = AudioDspDto(config = config.normalized()) + suspend fun previewAudioDsp(request: AudioDspPreviewRequest): AudioDspPreviewDto = + AudioDspPreviewDto(request.frequenciesHz, request.frequenciesHz.map { 0f }, request.frequenciesHz.map { 0f }) suspend fun getWebControlAccess(): WebControlAccessDto = throw UnsupportedOperationException("WebUI 访问设置 not supported") suspend fun saveWebControlAccess(request: WebControlAccessRequest): WebControlAccessDto = diff --git a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt index 81f554a5..5da5a37d 100644 --- a/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt +++ b/web-control-core/src/main/kotlin/com/miruplay/tv/webcontrol/WebControlModels.kt @@ -6,6 +6,8 @@ import com.miruplay.tv.model.CloudDriveAutomationConfig import com.miruplay.tv.model.CloudDriveLibraryMode import com.miruplay.tv.model.Episode import com.miruplay.tv.model.FormatAwareToneMappingPreferences +import com.miruplay.tv.model.AudioDspConfig +import com.miruplay.tv.model.AudioDspPreset import com.miruplay.tv.model.MediaContentMode import com.miruplay.tv.model.MediaRecognitionMode import com.miruplay.tv.model.MlipMetadataMode @@ -361,6 +363,36 @@ data class PlaybackSettingsRequest( val formatAwareToneMapping: FormatAwareToneMappingPreferences? = null, ) +@Serializable +data class AudioDspCapabilitiesDto( + val supportedBackends: List = emptyList(), + val supportedLayouts: List = listOf("mono", "stereo", "5.1", "7.1"), + val sampleRatesHz: List = listOf(44_100, 48_000, 96_000), + val maxChannels: Int = 8, + val hrtfAvailable: Boolean = true, +) + +@Serializable +data class AudioDspDto( + val config: AudioDspConfig = AudioDspConfig.neutral(), + val capabilities: AudioDspCapabilitiesDto = AudioDspCapabilitiesDto(), + val effectiveRoute: String = "disabled", + val warnings: List = emptyList(), +) + +@Serializable +data class AudioDspPreviewRequest( + val preset: AudioDspPreset, + val frequenciesHz: List = listOf(20f, 100f, 1_000f, 10_000f, 20_000f), +) + +@Serializable +data class AudioDspPreviewDto( + val frequenciesHz: List, + val magnitudeDb: List, + val phaseRadians: List, +) + @Serializable data class WebControlAccessDto( val enabled: Boolean, diff --git a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt index f1254b61..e763dab2 100644 --- a/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt +++ b/web-control-core/src/test/kotlin/com/miruplay/tv/webcontrol/WebControlSettingsRouteTest.kt @@ -1,6 +1,8 @@ package com.miruplay.tv.webcontrol import com.miruplay.tv.model.FormatAwareToneMappingPreferences +import com.miruplay.tv.model.AudioDspConfig +import com.miruplay.tv.model.AudioDspPreset import com.miruplay.tv.model.PosterWallArrangement import com.miruplay.tv.repository.WebControlAccessManager import fi.iki.elonen.NanoHTTPD @@ -78,6 +80,55 @@ class WebControlSettingsRouteTest { assertTrue(body.contains("\"value\":\"EXPERIMENTAL_IJKPLAYER\"")) } + @Test + fun `audio dsp route round trips config and preview`() { + val service = SettingsStubService() + val server = NanoHttpWebControlServer( + webControlService = service, + webControlAccess = EnabledAccess, + staticAssets = WebControlStaticAssets { null }, + ) + val config = AudioDspConfig( + enabled = true, + selectedPresetId = "movie", + presets = listOf(AudioDspPreset("movie", "Movie")), + ) + + val put = server.serve( + session( + method = NanoHTTPD.Method.PUT, + uri = "/api/audio-dsp", + body = kotlinx.serialization.json.Json.encodeToString(AudioDspConfig.serializer(), config), + ), + ) + assertEquals(NanoHTTPD.Response.Status.OK, put.status) + assertTrue(service.audioDspConfig.enabled) + + val get = server.serve(session(method = NanoHTTPD.Method.GET, uri = "/api/audio-dsp", body = "")) + assertEquals(NanoHTTPD.Response.Status.OK, get.status) + assertTrue(get.bodyText().contains("\"selectedPresetId\":\"movie\"")) + + val preview = server.serve( + session( + method = NanoHTTPD.Method.POST, + uri = "/api/audio-dsp/preview", + body = "{\"preset\":${kotlinx.serialization.json.Json.encodeToString(AudioDspPreset.serializer(), config.presets.single())},\"frequenciesHz\":[100.0,1000.0,10000.0]}", + ), + ) + assertEquals(NanoHTTPD.Response.Status.OK, preview.status) + assertTrue(preview.bodyText().contains("\"magnitudeDb\"")) + + val invalid = server.serve( + session( + method = NanoHTTPD.Method.PUT, + uri = "/api/audio-dsp", + body = """{"enabled":true,"selectedPresetId":"movie","presets":[{"id":"movie","name":"Movie","preampDb":99.0}]}""", + ), + ) + assertEquals(NanoHTTPD.Response.Status.BAD_REQUEST, invalid.status) + assertTrue(service.audioDspConfig.enabled) + } + @Test fun `web control access rotate token route returns refreshed dto`() { val service = SettingsStubService() @@ -195,6 +246,7 @@ class WebControlSettingsRouteTest { } private class SettingsStubService : EmptyWebControlEndpointService() { + var audioDspConfig: AudioDspConfig = AudioDspConfig.neutral() var capturedScan: ScanSettingsRequest? = null var capturedPlayback: PlaybackSettingsRequest? = null var capturedTmdbToken: String? = null @@ -234,6 +286,20 @@ class WebControlSettingsRouteTest { return getPlaybackSettings() } + override suspend fun getAudioDsp(): AudioDspDto = AudioDspDto(config = audioDspConfig) + + override suspend fun saveAudioDsp(config: AudioDspConfig): AudioDspDto { + audioDspConfig = config.normalized() + return AudioDspDto(config = audioDspConfig) + } + + override suspend fun previewAudioDsp(request: AudioDspPreviewRequest): AudioDspPreviewDto = + AudioDspPreviewDto( + frequenciesHz = request.frequenciesHz, + magnitudeDb = request.frequenciesHz.map { 0f }, + phaseRadians = request.frequenciesHz.map { 0f }, + ) + override suspend fun getMetadataSettings(): MetadataSettingsDto = MetadataSettingsDto(bangumiTokenConfigured = false, tmdbTokenConfigured = true) diff --git a/web-control/build.gradle.kts b/web-control/build.gradle.kts index 5d80da39..3fd9b7b7 100644 --- a/web-control/build.gradle.kts +++ b/web-control/build.gradle.kts @@ -75,6 +75,7 @@ tasks.matching { it.name.startsWith("lint") }.configureEach { dependencies { api(project(":web-control-core")) + implementation(project(":audio-dsp-core")) implementation(project(":background-task")) api(project(":core:model")) implementation(project(":core:common")) diff --git a/web-control/frontend/src/App.vue b/web-control/frontend/src/App.vue index 9c8f2832..d65f2d50 100644 --- a/web-control/frontend/src/App.vue +++ b/web-control/frontend/src/App.vue @@ -1133,6 +1133,166 @@ +
+ + + + + +
+ + 启用后播放器会关闭 passthrough/offload,先解码为 PCM 再处理。 +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ 新增预设 + 删除当前预设 + 导入 JSON + 导出 JSON + +
+ + +
+
+ + + + +
+
+ + + + + 移除通道组 +
+
+
+ + + + + + + + +
+
+
+ 添加频段 + 频率 Hz · 增益 dB · Q +
+
+ +
+
+ + + +
+ + + + + + + + + +
+
+ 预览使用 48 kHz stereo 参考布局,不会改变已保存配置。 + 刷新响应曲线 +
+
+ + + + 20 Hz + 20 kHz + +24 dB + 0 dB + -24 dB + +
+ +
+ 应用音频 DSP 配置 + 重新加载 +
+
+
+