onboarding + eq fixes

This commit is contained in:
Boof2015
2026-07-07 20:21:09 -04:00
parent c072a1df16
commit 6aa4b544fc
20 changed files with 1349 additions and 131 deletions
+1
View File
@@ -36,6 +36,7 @@ dependencies {
api 'com.google.android.exoplayer:exoplayer:2.19.0'
api 'com.google.android.exoplayer:extension-mediasession:2.19.0'
api 'com.jakewharton.timber:timber:5.0.1'
testImplementation 'junit:junit:4.13.2'
// The PCM tap forwards to expo.modules.astrascope.ScopeBridge.
implementation project(':astra-scope')
@@ -6,17 +6,12 @@ import com.google.android.exoplayer2.audio.BaseAudioProcessor
import expo.modules.astrascope.EqBridge
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Parametric EQ as an ExoPlayer AudioProcessor (M4). Reads raw band params from
* [EqBridge] (set from JS) and computes Audio-EQ-Cookbook biquad coefficients at
* the real stream sample rate — mirroring Web Audio's BiquadFilterNode on desktop.
* [EqBridge] (set from JS) and computes Web Audio BiquadFilterNode-compatible
* coefficients at the real stream sample rate, matching desktop Astra.
* A cascade of transposed-direct-form-II biquads runs per channel after a preamp.
*
* Passthrough (bit-exact) when the EQ is disabled or has no active bands and unity
@@ -100,7 +95,7 @@ class EqAudioProcessor : BaseAudioProcessor() {
var bi = 0
for (i in 0 until total) {
if (params[i * 5 + 4] == 0f) continue
computeCoeffs(
EqCoefficients.compute(
params[i * 5].toInt(),
params[i * 5 + 1],
params[i * 5 + 2],
@@ -188,71 +183,4 @@ class EqAudioProcessor : BaseAudioProcessor() {
}
}
/**
* Audio-EQ-Cookbook biquad coefficients (a0-normalized) into out[off..off+4].
* Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts:
* 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass.
*/
private fun computeCoeffs(
type: Int,
freq: Float,
gainDb: Float,
q: Float,
sr: Float,
out: FloatArray,
off: Int
) {
if (sr <= 0f) {
out[off] = 1f; out[off + 1] = 0f; out[off + 2] = 0f; out[off + 3] = 0f; out[off + 4] = 0f
return
}
val w0 = 2.0 * PI * freq / sr
val cosW0 = cos(w0)
val sinW0 = sin(w0)
val a = 10.0.pow(gainDb / 40.0)
val alpha = sinW0 / (2.0 * q.coerceAtLeast(0.0001f))
var b0 = 1.0; var b1 = 0.0; var b2 = 0.0
var a0 = 1.0; var a1 = 0.0; var a2 = 0.0
when (type) {
1 -> { // peaking
b0 = 1 + alpha * a; b1 = -2 * cosW0; b2 = 1 - alpha * a
a0 = 1 + alpha / a; a1 = -2 * cosW0; a2 = 1 - alpha / a
}
0 -> { // lowshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha)
b1 = 2 * a * (a - 1 - (a + 1) * cosW0)
b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha)
a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha
a1 = -2 * (a - 1 + (a + 1) * cosW0)
a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha
}
2 -> { // highshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha)
b1 = -2 * a * (a - 1 + (a + 1) * cosW0)
b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha)
a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha
a1 = 2 * (a - 1 - (a + 1) * cosW0)
a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha
}
4 -> { // lowpass
b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2
a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha
}
3 -> { // highpass
b0 = (1 + cosW0) / 2; b1 = -(1 + cosW0); b2 = (1 + cosW0) / 2
a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha
}
}
val inv = 1.0 / a0
out[off] = (b0 * inv).toFloat()
out[off + 1] = (b1 * inv).toFloat()
out[off + 2] = (b2 * inv).toFloat()
out[off + 3] = (a1 * inv).toFloat()
out[off + 4] = (a2 * inv).toFloat()
}
}
@@ -0,0 +1,82 @@
package com.doublesymmetry.kotlinaudio.scope
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Web Audio BiquadFilterNode coefficients, a0-normalized as b0,b1,b2,a1,a2.
* Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts:
* 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass.
*/
internal object EqCoefficients {
private const val MIN_FILTER_Q = 0.0001
fun compute(
type: Int,
freq: Float,
gainDb: Float,
q: Float,
sr: Float,
out: FloatArray,
off: Int
) {
if (sr <= 0f) {
out[off] = 1f; out[off + 1] = 0f; out[off + 2] = 0f; out[off + 3] = 0f; out[off + 4] = 0f
return
}
val w0 = 2.0 * PI * freq / sr
val cosW0 = cos(w0)
val sinW0 = sin(w0)
val a = 10.0.pow(gainDb / 40.0)
val alphaQ = sinW0 / (2.0 * q.coerceAtLeast(MIN_FILTER_Q.toFloat()))
val alphaQDb = sinW0 / (2.0 * 10.0.pow(q / 20.0))
val alphaShelf = (sinW0 / 2.0) * sqrt(2.0)
var b0 = 1.0; var b1 = 0.0; var b2 = 0.0
var a0 = 1.0; var a1 = 0.0; var a2 = 0.0
when (type) {
1 -> { // peaking
b0 = 1 + alphaQ * a; b1 = -2 * cosW0; b2 = 1 - alphaQ * a
a0 = 1 + alphaQ / a; a1 = -2 * cosW0; a2 = 1 - alphaQ / a
}
0 -> { // lowshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaShelf)
b1 = 2 * a * (a - 1 - (a + 1) * cosW0)
b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaShelf)
a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaShelf
a1 = -2 * (a - 1 + (a + 1) * cosW0)
a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaShelf
}
2 -> { // highshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaShelf)
b1 = -2 * a * (a - 1 + (a + 1) * cosW0)
b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaShelf)
a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaShelf
a1 = 2 * (a - 1 - (a + 1) * cosW0)
a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaShelf
}
4 -> { // lowpass
b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2
a0 = 1 + alphaQDb; a1 = -2 * cosW0; a2 = 1 - alphaQDb
}
3 -> { // highpass
b0 = (1 + cosW0) / 2; b1 = -(1 + cosW0); b2 = (1 + cosW0) / 2
a0 = 1 + alphaQDb; a1 = -2 * cosW0; a2 = 1 - alphaQDb
}
}
val inv = 1.0 / a0
out[off] = (b0 * inv).toFloat()
out[off + 1] = (b1 * inv).toFloat()
out[off + 2] = (b2 * inv).toFloat()
out[off + 3] = (a1 * inv).toFloat()
out[off + 4] = (a2 * inv).toFloat()
}
}
@@ -0,0 +1,62 @@
package com.doublesymmetry.kotlinaudio.scope
import org.junit.Assert.assertEquals
import org.junit.Test
class EqCoefficientsTest {
@Test
fun passFiltersUseWebAudioQDbSemantics() {
assertCoefficients(
coeffs(type = 4, freq = 1000f, gainDb = 0f, q = 6f),
doubleArrayOf(
0.004142085705,
0.008284171410,
0.004142085705,
-1.920085584611,
0.936653927431
)
)
assertCoefficients(
coeffs(type = 3, freq = 1000f, gainDb = 0f, q = 6f),
doubleArrayOf(
0.964184878011,
-1.928369756021,
0.964184878011,
-1.920085584611,
0.936653927431
)
)
}
@Test
fun shelfFiltersIgnoreQ() {
assertCoefficients(
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 0.1f),
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 18f)
)
assertCoefficients(
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 0.1f),
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 18f)
)
}
private fun coeffs(type: Int, freq: Float, gainDb: Float, q: Float): FloatArray {
val out = FloatArray(5)
EqCoefficients.compute(type, freq, gainDb, q, 48000f, out, 0)
return out
}
private fun assertCoefficients(actual: FloatArray, expected: DoubleArray) {
assertEquals("coefficient count", expected.size, actual.size)
for (i in expected.indices) {
assertEquals("coefficient $i", expected[i], actual[i].toDouble(), 1e-6)
}
}
private fun assertCoefficients(actual: FloatArray, expected: FloatArray) {
assertEquals("coefficient count", expected.size, actual.size)
for (i in expected.indices) {
assertEquals("coefficient $i", expected[i].toDouble(), actual[i].toDouble(), 0.0)
}
}
}