restore q value to shelves

This commit is contained in:
Boof2015
2026-08-11 19:11:02 -04:00
parent 915242bae6
commit 441299254b
7 changed files with 104 additions and 72 deletions
+1 -3
View File
@@ -58,8 +58,7 @@ import {
EQ_MIN_FREQUENCY, EQ_MIN_FREQUENCY,
EQ_MIN_PREAMP_DB, EQ_MIN_PREAMP_DB,
EQ_MIN_Q, EQ_MIN_Q,
isPassEQBandType, isPassEQBandType
isShelfEQBandType
} from '@/audio/eq'; } from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser'; import { parseAutoEQ } from '@/audio/autoEQParser';
import { buildGraphicBands } from '@/audio/graphicEq'; import { buildGraphicBands } from '@/audio/graphicEq';
@@ -728,7 +727,6 @@ function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
parseValue: parseDb, parseValue: parseDb,
}; };
case 'Q': case 'Q':
if (isShelfEQBandType(band.type)) return null;
return { return {
title: 'Edit Q', title: 'Edit Q',
initialValue: band.Q.toFixed(2), initialValue: band.Q.toFixed(2),
+36 -17
View File
@@ -44,27 +44,46 @@ test('peaking filter reaches requested gain at center frequency', () => {
assertClose(computeEQFilterMagnitude(boost, 1200, 48000), 5.5, 1e-6); assertClose(computeEQFilterMagnitude(boost, 1200, 48000), 5.5, 1e-6);
}); });
test('shelf filters ignore Q like Web Audio BiquadFilterNode', () => { test('shelf filters use Q in their coefficients and response', () => {
const lowLoose = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 0.1 }); const lowWide = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 0.5 });
const lowTight = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 18 }); const lowNarrow = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 2 });
const highLoose = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 0.1 }); const highWide = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 0.5 });
const highTight = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 18 }); const highNarrow = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 2 });
assertCoefficientsClose( assertCoefficientsClose(computeEQFilterCoefficients(lowWide, 48000), {
computeEQFilterCoefficients(lowLoose, 48000), b0: 1.004523905875,
computeEQFilterCoefficients(lowTight, 48000) b1: -1.978032948597,
b2: 0.973748440145,
a1: -1.978092655842,
a2: 0.978212638774,
});
assertCoefficientsClose(computeEQFilterCoefficients(highWide, 48000), {
b0: 0.746848511923,
b1: -0.319264680328,
b2: 0.034120017138,
a1: -0.641024137473,
a2: 0.102727986206,
});
assert.notDeepEqual(
computeEQFilterCoefficients(lowWide, 48000),
computeEQFilterCoefficients(lowNarrow, 48000)
); );
assertCoefficientsClose( assert.notDeepEqual(
computeEQFilterCoefficients(highLoose, 48000), computeEQFilterCoefficients(highWide, 48000),
computeEQFilterCoefficients(highTight, 48000) computeEQFilterCoefficients(highNarrow, 48000)
); );
assertClose( assert.ok(
computeEQFilterMagnitude(lowLoose, 40, 48000), Math.abs(
computeEQFilterMagnitude(lowTight, 40, 48000) computeEQFilterMagnitude(lowWide, 200, 48000) -
computeEQFilterMagnitude(lowNarrow, 200, 48000)
) > 0.1
); );
assertClose( assert.ok(
computeEQFilterMagnitude(highLoose, 12000, 48000), Math.abs(
computeEQFilterMagnitude(highTight, 12000, 48000) computeEQFilterMagnitude(highWide, 4000, 48000) -
computeEQFilterMagnitude(highNarrow, 4000, 48000)
) > 0.1
); );
}); });
+12 -18
View File
@@ -1,7 +1,6 @@
// Parametric EQ math + helpers — ported from desktop `src/renderer/utils/eq.ts`. // Parametric EQ math + helpers. Audio EQ Cookbook biquad math drives the response
// Web Audio BiquadFilterNode-compatible math drives the response curve in the EQ // curve in the EQ screen. Native playback computes matching coefficients in Kotlin
// screen. Native playback computes matching coefficients in Kotlin at the real // at the real stream sample rate — here we also flatten band params for the bridge.
// stream sample rate — here we also flatten band params for the native bridge.
import type { EQBand, EQBandType, EQMode, EQPreset } from '../types/audio'; import type { EQBand, EQBandType, EQMode, EQPreset } from '../types/audio';
@@ -88,10 +87,6 @@ export function isPassEQBandType(type: EQBandType): boolean {
return type === 'highpass' || type === 'lowpass'; return type === 'highpass' || type === 'lowpass';
} }
export function isShelfEQBandType(type: EQBandType): boolean {
return type === 'lowshelf' || type === 'highshelf';
}
/** Pass filters carry no gain — force it to 0. */ /** Pass filters carry no gain — force it to 0. */
export function normalizeEQBand<T extends EQBand>(band: T): T { export function normalizeEQBand<T extends EQBand>(band: T): T {
if (!isPassEQBandType(band.type) || band.gain === 0) { if (!isPassEQBandType(band.type) || band.gain === 0) {
@@ -185,7 +180,7 @@ export function serializeEQPresetData(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Response curve magnitude (Web Audio BiquadFilterNode) — for the Skia response curve. // Response curve magnitude (Audio EQ Cookbook) — for the Skia response curve.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface EQFilterCoefficients { export interface EQFilterCoefficients {
@@ -227,7 +222,6 @@ export function computeEQFilterCoefficients(band: EQBand, sampleRate: number): E
const cosW0 = Math.cos(w0); const cosW0 = Math.cos(w0);
const alphaQ = sinW0 / (2 * Math.max(band.Q, MIN_FILTER_Q)); const alphaQ = sinW0 / (2 * Math.max(band.Q, MIN_FILTER_Q));
const alphaQDb = sinW0 / (2 * Math.pow(10, band.Q / 20)); const alphaQDb = sinW0 / (2 * Math.pow(10, band.Q / 20));
const alphaShelf = (sinW0 / 2) * Math.SQRT2;
let b0 = 1; let b0 = 1;
let b1 = 0; let b1 = 0;
@@ -247,22 +241,22 @@ export function computeEQFilterCoefficients(band: EQBand, sampleRate: number): E
break; break;
case 'lowshelf': { case 'lowshelf': {
const sqrtA = Math.sqrt(A); const sqrtA = Math.sqrt(A);
b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaShelf); b0 = A * (A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaQ);
b1 = 2 * A * (A - 1 - (A + 1) * cosW0); b1 = 2 * A * (A - 1 - (A + 1) * cosW0);
b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaShelf); b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaQ);
a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaShelf; a0 = A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaQ;
a1 = -2 * (A - 1 + (A + 1) * cosW0); a1 = -2 * (A - 1 + (A + 1) * cosW0);
a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaShelf; a2 = A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaQ;
break; break;
} }
case 'highshelf': { case 'highshelf': {
const sqrtA = Math.sqrt(A); const sqrtA = Math.sqrt(A);
b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaShelf); b0 = A * (A + 1 + (A - 1) * cosW0 + 2 * sqrtA * alphaQ);
b1 = -2 * A * (A - 1 + (A + 1) * cosW0); b1 = -2 * A * (A - 1 + (A + 1) * cosW0);
b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaShelf); b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaQ);
a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaShelf; a0 = A + 1 - (A - 1) * cosW0 + 2 * sqrtA * alphaQ;
a1 = 2 * (A - 1 - (A + 1) * cosW0); a1 = 2 * (A - 1 - (A + 1) * cosW0);
a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaShelf; a2 = A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaQ;
break; break;
} }
case 'lowpass': case 'lowpass':
+2 -4
View File
@@ -8,7 +8,6 @@ import { AppPressable, SCROLL_PRESS_DELAY } from '@/components/AppPressable';
import { import {
EQ_MAX_GAIN_DB, EQ_MAX_GAIN_DB,
isPassEQBandType, isPassEQBandType,
isShelfEQBandType,
} from '@/audio/eq'; } from '@/audio/eq';
import type { EQBand } from '@/types/audio'; import type { EQBand } from '@/types/audio';
import { VerticalEQSlider } from './VerticalEQSlider'; import { VerticalEQSlider } from './VerticalEQSlider';
@@ -78,7 +77,6 @@ export function BandConsole({
{bands.map((band, index) => { {bands.map((band, index) => {
const isActive = band.id === activeBandId; const isActive = band.id === activeBandId;
const isPass = isPassEQBandType(band.type); const isPass = isPassEQBandType(band.type);
const isShelf = isShelfEQBandType(band.type);
return ( return (
<AppPressable <AppPressable
key={band.id} key={band.id}
@@ -152,8 +150,8 @@ export function BandConsole({
/> />
<Readout <Readout
label="Q" label="Q"
value={isShelf ? '—' : band.Q.toFixed(2)} value={band.Q.toFixed(2)}
onPress={isShelf ? undefined : () => onEditValue(band.id, 'Q')} onPress={() => onEditValue(band.id, 'Q')}
/> />
</AppPressable> </AppPressable>
); );
+11 -15
View File
@@ -18,8 +18,7 @@ import {
EQ_MAX_Q, EQ_MAX_Q,
EQ_MIN_FREQUENCY, EQ_MIN_FREQUENCY,
EQ_MIN_Q, EQ_MIN_Q,
isPassEQBandType, isPassEQBandType
isShelfEQBandType
} from '@/audio/eq'; } from '@/audio/eq';
import { EQSlider } from './EQSlider'; import { EQSlider } from './EQSlider';
import { import {
@@ -55,7 +54,6 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
} }
const isPass = isPassEQBandType(band.type); const isPass = isPassEQBandType(band.type);
const isShelf = isShelfEQBandType(band.type);
return ( return (
<View style={styles.card}> <View style={styles.card}>
@@ -98,18 +96,16 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
onValuePress={() => onEditValue('gain')} onValuePress={() => onEditValue('gain')}
disabled={isPass} disabled={isPass}
/> />
{!isShelf ? ( <EQSlider
<EQSlider label="Q"
label="Q" value={band.Q}
value={band.Q} min={EQ_MIN_Q}
min={EQ_MIN_Q} max={EQ_MAX_Q}
max={EQ_MAX_Q} log
log format={(v) => v.toFixed(2)}
format={(v) => v.toFixed(2)} onChange={(v) => onUpdate({ Q: v })}
onChange={(v) => onUpdate({ Q: v })} onValuePress={() => onEditValue('Q')}
onValuePress={() => onEditValue('Q')} />
/>
) : null}
</View> </View>
); );
} }
@@ -7,7 +7,7 @@ import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
/** /**
* Web Audio BiquadFilterNode coefficients, a0-normalized as b0,b1,b2,a1,a2. * Audio EQ Cookbook coefficients, a0-normalized as b0,b1,b2,a1,a2.
* Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts: * Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts:
* 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass. * 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass.
*/ */
@@ -34,7 +34,6 @@ internal object EqCoefficients {
val a = 10.0.pow(gainDb / 40.0) val a = 10.0.pow(gainDb / 40.0)
val alphaQ = sinW0 / (2.0 * q.coerceAtLeast(MIN_FILTER_Q.toFloat())) val alphaQ = sinW0 / (2.0 * q.coerceAtLeast(MIN_FILTER_Q.toFloat()))
val alphaQDb = sinW0 / (2.0 * 10.0.pow(q / 20.0)) 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 b0 = 1.0; var b1 = 0.0; var b2 = 0.0
var a0 = 1.0; var a1 = 0.0; var a2 = 0.0 var a0 = 1.0; var a1 = 0.0; var a2 = 0.0
@@ -46,21 +45,21 @@ internal object EqCoefficients {
} }
0 -> { // lowshelf 0 -> { // lowshelf
val sqrtA = sqrt(a) val sqrtA = sqrt(a)
b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaShelf) b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaQ)
b1 = 2 * a * (a - 1 - (a + 1) * cosW0) b1 = 2 * a * (a - 1 - (a + 1) * cosW0)
b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaShelf) b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaQ)
a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaShelf a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaQ
a1 = -2 * (a - 1 + (a + 1) * cosW0) a1 = -2 * (a - 1 + (a + 1) * cosW0)
a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaShelf a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaQ
} }
2 -> { // highshelf 2 -> { // highshelf
val sqrtA = sqrt(a) val sqrtA = sqrt(a)
b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaShelf) b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alphaQ)
b1 = -2 * a * (a - 1 + (a + 1) * cosW0) b1 = -2 * a * (a - 1 + (a + 1) * cosW0)
b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaShelf) b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaQ)
a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaShelf a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alphaQ
a1 = 2 * (a - 1 - (a + 1) * cosW0) a1 = 2 * (a - 1 - (a + 1) * cosW0)
a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaShelf a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaQ
} }
4 -> { // lowpass 4 -> { // lowpass
b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2 b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2
@@ -1,6 +1,7 @@
package com.doublesymmetry.kotlinaudio.scope package com.doublesymmetry.kotlinaudio.scope
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class EqCoefficientsTest { class EqCoefficientsTest {
@@ -29,15 +30,34 @@ class EqCoefficientsTest {
} }
@Test @Test
fun shelfFiltersIgnoreQ() { fun shelfFiltersUseQ() {
val lowWide = coeffs(type = 0, freq = 100f, gainDb = 6f, q = 0.5f)
val lowNarrow = coeffs(type = 0, freq = 100f, gainDb = 6f, q = 2f)
val highWide = coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 0.5f)
val highNarrow = coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 2f)
assertCoefficients( assertCoefficients(
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 0.1f), lowWide,
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 18f) doubleArrayOf(
1.004523905875,
-1.978032948597,
0.973748440145,
-1.978092655842,
0.978212638774
)
) )
assertCoefficients( assertCoefficients(
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 0.1f), highWide,
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 18f) doubleArrayOf(
0.746848511923,
-0.319264680328,
0.034120017138,
-0.641024137473,
0.102727986206
)
) )
assertCoefficientsDiffer(lowWide, lowNarrow)
assertCoefficientsDiffer(highWide, highNarrow)
} }
private fun coeffs(type: Int, freq: Float, gainDb: Float, q: Float): FloatArray { private fun coeffs(type: Int, freq: Float, gainDb: Float, q: Float): FloatArray {
@@ -59,4 +79,12 @@ class EqCoefficientsTest {
assertEquals("coefficient $i", expected[i].toDouble(), actual[i].toDouble(), 0.0) assertEquals("coefficient $i", expected[i].toDouble(), actual[i].toDouble(), 0.0)
} }
} }
private fun assertCoefficientsDiffer(first: FloatArray, second: FloatArray) {
assertEquals("coefficient count", first.size, second.size)
assertTrue(
"expected at least one coefficient to change",
first.indices.any { i -> kotlin.math.abs(first[i] - second[i]) > 1e-6f }
)
}
} }