mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
restore q value to shelves
This commit is contained in:
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
vendor/kotlinaudio/kotlin-audio/src/main/java/com/doublesymmetry/kotlinaudio/scope/EqCoefficients.kt
Vendored
+9
-10
@@ -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
|
||||
|
||||
+33
-5
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user