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_PREAMP_DB,
EQ_MIN_Q,
isPassEQBandType,
isShelfEQBandType
isPassEQBandType
} from '@/audio/eq';
import { parseAutoEQ } from '@/audio/autoEQParser';
import { buildGraphicBands } from '@/audio/graphicEq';
@@ -728,7 +727,6 @@ function getValueEditConfig(kind: EQEditableValue, band: EQBand) {
parseValue: parseDb,
};
case 'Q':
if (isShelfEQBandType(band.type)) return null;
return {
title: 'Edit Q',
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);
});
test('shelf filters ignore Q like Web Audio BiquadFilterNode', () => {
const lowLoose = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 0.1 });
const lowTight = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 18 });
const highLoose = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 0.1 });
const highTight = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 18 });
test('shelf filters use Q in their coefficients and response', () => {
const lowWide = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 0.5 });
const lowNarrow = band({ type: 'lowshelf', frequency: 100, gain: 6, Q: 2 });
const highWide = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 0.5 });
const highNarrow = band({ type: 'highshelf', frequency: 8000, gain: -4, Q: 2 });
assertCoefficientsClose(
computeEQFilterCoefficients(lowLoose, 48000),
computeEQFilterCoefficients(lowTight, 48000)
assertCoefficientsClose(computeEQFilterCoefficients(lowWide, 48000), {
b0: 1.004523905875,
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(
computeEQFilterCoefficients(highLoose, 48000),
computeEQFilterCoefficients(highTight, 48000)
assert.notDeepEqual(
computeEQFilterCoefficients(highWide, 48000),
computeEQFilterCoefficients(highNarrow, 48000)
);
assertClose(
computeEQFilterMagnitude(lowLoose, 40, 48000),
computeEQFilterMagnitude(lowTight, 40, 48000)
assert.ok(
Math.abs(
computeEQFilterMagnitude(lowWide, 200, 48000) -
computeEQFilterMagnitude(lowNarrow, 200, 48000)
) > 0.1
);
assertClose(
computeEQFilterMagnitude(highLoose, 12000, 48000),
computeEQFilterMagnitude(highTight, 12000, 48000)
assert.ok(
Math.abs(
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`.
// Web Audio BiquadFilterNode-compatible math drives the response curve in the EQ
// screen. Native playback computes matching coefficients in Kotlin at the real
// stream sample rate — here we also flatten band params for the native bridge.
// Parametric EQ math + helpers. Audio EQ Cookbook biquad math drives the response
// curve in the EQ screen. Native playback computes matching coefficients in Kotlin
// at the real stream sample rate — here we also flatten band params for the bridge.
import type { EQBand, EQBandType, EQMode, EQPreset } from '../types/audio';
@@ -88,10 +87,6 @@ export function isPassEQBandType(type: EQBandType): boolean {
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. */
export function normalizeEQBand<T extends EQBand>(band: T): T {
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 {
@@ -227,7 +222,6 @@ export function computeEQFilterCoefficients(band: EQBand, sampleRate: number): E
const cosW0 = Math.cos(w0);
const alphaQ = sinW0 / (2 * Math.max(band.Q, MIN_FILTER_Q));
const alphaQDb = sinW0 / (2 * Math.pow(10, band.Q / 20));
const alphaShelf = (sinW0 / 2) * Math.SQRT2;
let b0 = 1;
let b1 = 0;
@@ -247,22 +241,22 @@ export function computeEQFilterCoefficients(band: EQBand, sampleRate: number): E
break;
case 'lowshelf': {
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);
b2 = A * (A + 1 - (A - 1) * cosW0 - 2 * sqrtA * alphaShelf);
a0 = 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 * alphaQ;
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;
}
case 'highshelf': {
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);
b2 = A * (A + 1 + (A - 1) * cosW0 - 2 * sqrtA * alphaShelf);
a0 = 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 * alphaQ;
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;
}
case 'lowpass':
+2 -4
View File
@@ -8,7 +8,6 @@ import { AppPressable, SCROLL_PRESS_DELAY } from '@/components/AppPressable';
import {
EQ_MAX_GAIN_DB,
isPassEQBandType,
isShelfEQBandType,
} from '@/audio/eq';
import type { EQBand } from '@/types/audio';
import { VerticalEQSlider } from './VerticalEQSlider';
@@ -78,7 +77,6 @@ export function BandConsole({
{bands.map((band, index) => {
const isActive = band.id === activeBandId;
const isPass = isPassEQBandType(band.type);
const isShelf = isShelfEQBandType(band.type);
return (
<AppPressable
key={band.id}
@@ -152,8 +150,8 @@ export function BandConsole({
/>
<Readout
label="Q"
value={isShelf ? '—' : band.Q.toFixed(2)}
onPress={isShelf ? undefined : () => onEditValue(band.id, 'Q')}
value={band.Q.toFixed(2)}
onPress={() => onEditValue(band.id, 'Q')}
/>
</AppPressable>
);
+11 -15
View File
@@ -18,8 +18,7 @@ import {
EQ_MAX_Q,
EQ_MIN_FREQUENCY,
EQ_MIN_Q,
isPassEQBandType,
isShelfEQBandType
isPassEQBandType
} from '@/audio/eq';
import { EQSlider } from './EQSlider';
import {
@@ -55,7 +54,6 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
}
const isPass = isPassEQBandType(band.type);
const isShelf = isShelfEQBandType(band.type);
return (
<View style={styles.card}>
@@ -98,18 +96,16 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
onValuePress={() => onEditValue('gain')}
disabled={isPass}
/>
{!isShelf ? (
<EQSlider
label="Q"
value={band.Q}
min={EQ_MIN_Q}
max={EQ_MAX_Q}
log
format={(v) => v.toFixed(2)}
onChange={(v) => onUpdate({ Q: v })}
onValuePress={() => onEditValue('Q')}
/>
) : null}
<EQSlider
label="Q"
value={band.Q}
min={EQ_MIN_Q}
max={EQ_MAX_Q}
log
format={(v) => v.toFixed(2)}
onChange={(v) => onUpdate({ Q: v })}
onValuePress={() => onEditValue('Q')}
/>
</View>
);
}
@@ -7,7 +7,7 @@ import kotlin.math.sin
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:
* 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 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
@@ -46,21 +45,21 @@ internal object EqCoefficients {
}
0 -> { // lowshelf
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)
b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alphaShelf)
a0 = 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 * alphaQ
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
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)
b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alphaShelf)
a0 = 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 * alphaQ
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
b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2
@@ -1,6 +1,7 @@
package com.doublesymmetry.kotlinaudio.scope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class EqCoefficientsTest {
@@ -29,15 +30,34 @@ class EqCoefficientsTest {
}
@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(
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 0.1f),
coeffs(type = 0, freq = 100f, gainDb = 6f, q = 18f)
lowWide,
doubleArrayOf(
1.004523905875,
-1.978032948597,
0.973748440145,
-1.978092655842,
0.978212638774
)
)
assertCoefficients(
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 0.1f),
coeffs(type = 2, freq = 8000f, gainDb = -4f, q = 18f)
highWide,
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 {
@@ -59,4 +79,12 @@ class EqCoefficientsTest {
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 }
)
}
}