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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions audio-dsp-core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<List<BiquadCoefficients>>,
val firTapsByChannel: List<FloatArray>,
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
}
Comment on lines +79 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect ChannelLayout.kt to determine how 5.1 vs 7.1 layouts are represented,
# to confirm whether SURROUND_5_1/SURROUND_7_1 should be distinguished by layout.
fd ChannelLayout.kt
cat -n audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/ChannelLayout.kt

Repository: ModerRAS/MiruPlay

Length of output: 3258


🏁 Script executed:

#!/bin/bash
# Inspect the DSP plan compiler context for enum usage and how matches() is called.
ast-grep outline audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/AudioDspPlanCompiler.kt --view expanded
sed -n '1,140p' audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/AudioDspPlanCompiler.kt
rg -n "AudioDspChannelTarget|matches\\(|SURROUND_5_1|SURROUND_7_1" audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio -S

Repository: ModerRAS/MiruPlay

Length of output: 8306


🏁 Script executed:

#!/bin/bash
# Inspect the model declarations for AudioDspChannelTarget/audio DSP preset API.
rg -n "enum class AudioDspChannelTarget|data class AudioDspPreset|data class AudioDspChannelRule|class AudioDspChannelRule" . \
  -g '*.kt' \
  -g '*.java' \
  -g '*.xsd' \
  -g '*.xml' \
  -g '*.kts'
fd AudioDsp*.kt audio-dsp-core audio-dsp-model . | sort

Repository: ModerRAS/MiruPlay

Length of output: 555


🏁 Script executed:

#!/bin/bash
# Inspect the model file around AudioDspChannelTarget to capture explicit semantics/naming.
cat -n core/model/src/main/kotlin/com/miruplay/tv/model/AudioDspModels.kt | sed -n '45,75p'

Repository: ModerRAS/MiruPlay

Length of output: 1249


🏁 Script executed:

#!/bin/bash
# Locate and inspect any persisted audio preset files or test fixtures that show whether SURROUND_5_1/SURROUND_7_1 are distinguished.
rg -n '"surround_5_1"|"surround_7_1"|surround_5_1|surround_7_1' \
  -g '*.json' -g '*.kts' -g '*.kt' -g '*.xml' -g '*.yaml' -g '*.yml' .

fd -e json -e kt -e kts -e xml -e yaml -e yml . | rg -i 'preset|audio|dsp' | head -100

Repository: ModerRAS/MiruPlay

Length of output: 2257


Distinguish SURROUND_5_1 from SURROUND_7_1 in matches().

CH defines SURROUND_5_1 as LS/RS channels and SURROUND_7_1 as LS/RS plus LB/RB. The compiler branch currently maps SURROUND, SURROUND_5_1, and SURROUND_7_1 to the same channel condition and ignores the passed layout, so 5.1 and 7.1 target rules produce the same routing. Match only the channels that belong to the resolved layout for each target.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/AudioDspPlanCompiler.kt`
around lines 79 - 91, The AudioDspChannelTarget.matches extension currently
treats SURROUND_5_1 and SURROUND_7_1 identically and ignores layout. Update this
branch to use the resolved layout: SURROUND_5_1 should match only LS/RS, while
SURROUND_7_1 should additionally match LB/RB; preserve the existing SURROUND
behavior and use the layout parameter to determine applicable channels.

}
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +30 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Validate the sample rate against the band frequency.

Line 32 calculates coefficients when sampleRateHz is invalid or when frequencyHz is at or above Nyquist. For example, a persisted 24 kHz band on 32 kHz PCM targets an aliased frequency. Reject the plan or clamp the band below Nyquist before coefficient calculation. Add coverage for a low sample rate and a high-frequency band.

One safe rejection option
     fun design(band: AudioDspBand, sampleRateHz: Int): BiquadCoefficients {
+        require(sampleRateHz > 0) { "sample rate must be positive" }
         val normalized = band.normalized()
+        require(normalized.frequencyHz < sampleRateHz / 2.0) {
+            "band frequency must be below Nyquist"
+        }
         val omega = 2.0 * Math.PI * normalized.frequencyHz / sampleRateHz
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
fun design(band: AudioDspBand, sampleRateHz: Int): BiquadCoefficients {
require(sampleRateHz > 0) { "sample rate must be positive" }
val normalized = band.normalized()
require(normalized.frequencyHz < sampleRateHz / 2.0) {
"band frequency must be below Nyquist"
}
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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/BiquadDesigner.kt`
around lines 30 - 36, Update BiquadDesigner.design to validate sampleRateHz
before calculating omega: reject nonpositive sample rates and bands whose
normalized frequency is at or above the Nyquist limit (sampleRateHz / 2),
including persisted high-frequency bands. Preserve valid coefficient calculation
and add coverage for low sample rates and high-frequency bands.

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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Channel>,
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]]
}
Comment on lines +17 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Correct the AAC mapping and handle incompatible channel counts.

Line 21 maps AAC 5.1 samples as if the first three samples were already L,R,C. AAC 5.1 order is C,L,R,LS,RS,LFE, so the canonical mapping must be 1,2,0,5,3,4. Also, Line 28 throws ArrayIndexOutOfBoundsException when AAC_5_1 or WAV_5_1 is passed with more than six channels. Return the unchanged samples, or reject the input order, when the mapping size does not equal channelCount.

Proposed fix
     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.AAC_5_1 -> intArrayOf(1, 2, 0, 5, 3, 4)
             InputOrder.WAV_5_1 -> intArrayOf(0, 1, 2, 3, 4, 5)
             else -> return samples.copyOf()
         }
+        if (order.size != channelCount) return samples.copyOf()
         return FloatArray(samples.size) { index ->
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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]]
}
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(1, 2, 0, 5, 3, 4)
InputOrder.WAV_5_1 -> intArrayOf(0, 1, 2, 3, 4, 5)
else -> return samples.copyOf()
}
if (order.size != channelCount) return samples.copyOf()
return FloatArray(samples.size) { index ->
val frame = index / channelCount
val outputChannel = index % channelCount
samples[frame * channelCount + order[outputChannel]]
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/ChannelLayout.kt` around
lines 17 - 29, Update the channel remapping logic in ChannelLayout so
InputOrder.AAC_5_1 uses the canonical mapping 1,2,0,5,3,4. Before indexing with
the selected order in the remapping flow, return samples.copyOf() or otherwise
reject the input when the mapping length does not equal channelCount, preventing
invalid access for layouts with more than six channels.

}

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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Comment on lines +8 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Replace the O(n²) direct inverse DFT with an FFT-based implementation.

design() computes each of the taps output samples with an inner loop over all taps frequency bins (Lines 26-33). This is an O(taps²) direct inverse DFT. AudioDspPlanCompiler.compile() invokes this once per channel when phaseMode == LINEAR, so the cost multiplies by channel count.

For AudioDspFirQuality.HIGH (4096 taps, defined in AudioDspModels.kt) on a 7.1 stream (8 channels), this is roughly 4096 × 4096 × 8 ≈ 134 million inner-loop iterations, each with two trigonometric calls. This compile step runs synchronously as part of onConfigure() on the audio-processor configuration path (per the DspAudioProcessor context and DspAudioProcessorTest.kt), so it can stall playback startup or a preset switch on Android TV hardware.

Since taps is already validated as a power of two (Line 9), use a radix-2 FFT/IFFT instead of the direct summation to reduce this to O(taps·log2(taps)).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinearPhaseFirDesigner.kt`
around lines 8 - 34, Replace the direct inverse-DFT summation in
LinearPhaseFirDesigner.design with a radix-2 FFT-based IFFT, reusing the
existing spectrumReal and spectrumImag arrays and power-of-two taps validation.
Preserve the current magnitude interpolation, phase construction, Hermitian
mirroring, normalization by taps, and FloatArray output ordering while reducing
reconstruction complexity to O(taps·log2(taps)).


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)
}
}
Original file line number Diff line number Diff line change
@@ -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)),
)
Comment on lines +13 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache releaseCoefficient instead of recomputing it on every process() call.

releaseCoefficient depends only on releaseMs and sampleRateHz, which never change after construction, but it is recomputed with an exp() call every time process() runs. StreamingDspProcessor calls limiter.process() once per single audio frame (see StreamingDspProcessor.kt lines 37-51), so this exp() call and its surrounding array allocation repeat on every audio sample-frame during playback. Move the computation to the class body so it runs once per LinkedLimiter instance.

⚡ Proposed fix to cache the release coefficient
 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
+    private val releaseCoefficient = 1f - exp(
+        -1f / (releaseMs.coerceAtLeast(1f) * 0.001f * sampleRateHz.coerceAtLeast(1)),
+    )

     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) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)),
)
private val ceiling = 10.0.pow(ceilingDb.toDouble() / 20.0).toFloat().coerceIn(0.01f, 1f)
private var gain = 1f
private val releaseCoefficient = 1f - exp(
-1f / (releaseMs.coerceAtLeast(1f) * 0.001f * sampleRateHz.coerceAtLeast(1)),
)
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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@audio-dsp-core/src/main/kotlin/com/miruplay/tv/audio/LinkedLimiter.kt` around
lines 13 - 23, Move the releaseCoefficient calculation out of
LinkedLimiter.process and cache it as a class-level immutable property
initialized from releaseMs and sampleRateHz. Remove the per-call exp()
computation while preserving the existing clamping and coefficient behavior.

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
}
}
Loading
Loading