-
Notifications
You must be signed in to change notification settings - Fork 0
feat(audio): add native PEQ DSP and linear-phase processing #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
da66d66
01f6d5e
c3805bd
c9bf851
89e6a55
57834b2
b70d247
2a10b79
1c59b4a
771efd5
4b9e20a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
For Since 🤖 Prompt for AI Agents |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Cache
⚡ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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:
Repository: ModerRAS/MiruPlay
Length of output: 3258
🏁 Script executed:
Repository: ModerRAS/MiruPlay
Length of output: 8306
🏁 Script executed:
Repository: ModerRAS/MiruPlay
Length of output: 555
🏁 Script executed:
Repository: ModerRAS/MiruPlay
Length of output: 1249
🏁 Script executed:
Repository: ModerRAS/MiruPlay
Length of output: 2257
Distinguish
SURROUND_5_1fromSURROUND_7_1inmatches().CHdefinesSURROUND_5_1asLS/RSchannels andSURROUND_7_1asLS/RSplusLB/RB. The compiler branch currently mapsSURROUND,SURROUND_5_1, andSURROUND_7_1to the same channel condition and ignores the passedlayout, 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