add spectrum to graphic eq mode

This commit is contained in:
Boof2015
2026-07-28 15:42:57 -04:00
parent f00cd0abcd
commit d4b1a1e122
9 changed files with 194 additions and 37 deletions
@@ -100,6 +100,11 @@ class AstraScopeModule : Module() {
Prop("analysisFrameMs") { view, value: Double -> view.analysisFrameMs = value } Prop("analysisFrameMs") { view, value: Double -> view.analysisFrameMs = value }
Prop("smoothing") { view, value: Double -> view.smoothing = value.toFloat() } Prop("smoothing") { view, value: Double -> view.smoothing = value.toFloat() }
Prop("pointCount") { view, value: Int -> view.pointCount = value } Prop("pointCount") { view, value: Int -> view.pointCount = value }
Prop("frequencyAnchors") { view, value: List<Double>? ->
view.frequencyAnchors = value?.let { frequencies ->
FloatArray(frequencies.size) { index -> frequencies[index].toFloat() }
}
}
Prop("dbMin") { view, value: Double -> view.dbMin = value.toFloat() } Prop("dbMin") { view, value: Double -> view.dbMin = value.toFloat() }
Prop("dbMax") { view, value: Double -> view.dbMax = value.toFloat() } Prop("dbMax") { view, value: Double -> view.dbMax = value.toFloat() }
Prop("tiltDbPerOctave") { view, value: Double -> view.tiltDbPerOctave = value.toFloat() } Prop("tiltDbPerOctave") { view, value: Double -> view.tiltDbPerOctave = value.toFloat() }
@@ -1,5 +1,6 @@
package expo.modules.astrascope package expo.modules.astrascope
import kotlin.math.floor
import kotlin.math.ln import kotlin.math.ln
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -83,6 +84,7 @@ internal object AstraScopeProjection {
dbMin: Float, dbMin: Float,
dbMax: Float, dbMax: Float,
tiltDbPerOctave: Float, tiltDbPerOctave: Float,
frequencyAnchors: FloatArray? = null,
sampleRate: Float = 48_000f sampleRate: Float = 48_000f
) { ) {
val bins = min(rawCount, raw.capacity()) val bins = min(rawCount, raw.capacity())
@@ -97,12 +99,13 @@ internal object AstraScopeProjection {
val maxFrequency = max(minFrequency + 1.0, min(MAX_FREQUENCY, nyquist)) val maxFrequency = max(minFrequency + 1.0, min(MAX_FREQUENCY, nyquist))
val binWidth = nyquist / bins.toDouble() val binWidth = nyquist / bins.toDouble()
val range = max(1f, dbMax - dbMin) val range = max(1f, dbMax - dbMin)
val anchors = validFrequencyAnchors(frequencyAnchors, minFrequency, maxFrequency)
for (point in 0 until points) { for (point in 0 until points) {
val t0 = point.toDouble() / (points - 1).toDouble() val t0 = point.toDouble() / (points - 1).toDouble()
val t1 = min(1.0, (point + 1).toDouble() / (points - 1).toDouble()) val t1 = min(1.0, (point + 1).toDouble() / (points - 1).toDouble())
val frequency0 = frequencyAt(t0, minFrequency, maxFrequency) val frequency0 = frequencyAt(t0, minFrequency, maxFrequency, anchors)
val frequency1 = frequencyAt(t1, minFrequency, maxFrequency) val frequency1 = frequencyAt(t1, minFrequency, maxFrequency, anchors)
val centerFrequency = (frequency0 + frequency1) * 0.5 val centerFrequency = (frequency0 + frequency1) * 0.5
val bin0 = frequency0 / binWidth val bin0 = frequency0 / binWidth
val bin1 = frequency1 / binWidth val bin1 = frequency1 / binWidth
@@ -119,6 +122,20 @@ internal object AstraScopeProjection {
} }
} }
/**
* Maps normalized x to frequency. With anchors, each frequency is pinned to
* the center of an equal-width column and the intervals remain logarithmic.
*/
internal fun spectrumFrequencyAt(
t: Double,
minFrequency: Double = MIN_FREQUENCY,
maxFrequency: Double = MAX_FREQUENCY,
frequencyAnchors: FloatArray? = null
): Double {
val anchors = validFrequencyAnchors(frequencyAnchors, minFrequency, maxFrequency)
return frequencyAt(t, minFrequency, maxFrequency, anchors)
}
/** Returns the largest absolute value left after one decay step. */ /** Returns the largest absolute value left after one decay step. */
fun decay(values: FloatArray, count: Int, factor: Float = DECAY_PER_FRAME): Float { fun decay(values: FloatArray, count: Int, factor: Float = DECAY_PER_FRAME): Float {
var peak = 0f var peak = 0f
@@ -131,8 +148,57 @@ internal object AstraScopeProjection {
return peak return peak
} }
private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double = private fun frequencyAt(
minFrequency * (maxFrequency / minFrequency).pow(t) t: Double,
minFrequency: Double,
maxFrequency: Double,
anchors: FloatArray?
): Double {
val position = t.coerceIn(0.0, 1.0)
if (anchors == null) return logLerp(minFrequency, maxFrequency, position)
val count = anchors.size
val firstCenter = 0.5 / count
val lastCenter = (count - 0.5) / count
if (position <= firstCenter) {
return logLerp(minFrequency, anchors.first().toDouble(), position / firstCenter)
}
if (position >= lastCenter) {
return logLerp(
anchors.last().toDouble(),
maxFrequency,
(position - lastCenter) / (1.0 - lastCenter)
)
}
val lower = floor(position * count - 0.5).toInt().coerceIn(0, count - 2)
val lowerCenter = (lower + 0.5) / count
val upperCenter = (lower + 1.5) / count
return logLerp(
anchors[lower].toDouble(),
anchors[lower + 1].toDouble(),
(position - lowerCenter) / (upperCenter - lowerCenter)
)
}
private fun validFrequencyAnchors(
anchors: FloatArray?,
minFrequency: Double,
maxFrequency: Double
): FloatArray? {
val frequencies = anchors ?: return null
if (frequencies.isEmpty()) return null
var previous = minFrequency
for (anchor in frequencies) {
val frequency = anchor.toDouble()
if (!frequency.isFinite() || frequency <= previous || frequency >= maxFrequency) return null
previous = frequency
}
return frequencies
}
private fun logLerp(start: Double, end: Double, t: Double): Double =
start * (end / start).pow(t.coerceIn(0.0, 1.0))
private fun safeRefreshRate(refreshRate: Float): Float = private fun safeRefreshRate(refreshRate: Float): Float =
if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f
@@ -64,6 +64,7 @@ internal class AstraScopeView(
var analysisFrameMs = 32.0 var analysisFrameMs = 32.0
var smoothing = 0.92f var smoothing = 0.92f
var pointCount = 120 var pointCount = 120
var frequencyAnchors: FloatArray? = null
var dbMin = -90f var dbMin = -90f
var dbMax = -10f var dbMax = -10f
var tiltDbPerOctave = 3.5f var tiltDbPerOctave = 3.5f
@@ -332,7 +333,8 @@ internal class AstraScopeView(
renderedPointCount, renderedPointCount,
dbMin, dbMin,
dbMax, dbMax,
tiltDbPerOctave tiltDbPerOctave,
frequencyAnchors
) )
true true
} }
@@ -22,6 +22,45 @@ class AstraScopeProjectionTest {
for (index in 1 until out.size) assertTrue(out[index] >= out[index - 1]) for (index in 1 until out.size) assertTrue(out[index] >= out[index - 1])
} }
@Test
fun graphicEqFrequenciesAlignToEvenColumnCenters() {
val anchors = floatArrayOf(60f, 250f, 1_000f, 4_000f, 12_000f)
assertEquals(20.0, AstraScopeProjection.spectrumFrequencyAt(0.0, frequencyAnchors = anchors), 0.001)
anchors.forEachIndexed { index, frequency ->
val center = (index + 0.5) / anchors.size
assertEquals(
frequency.toDouble(),
AstraScopeProjection.spectrumFrequencyAt(center, frequencyAnchors = anchors),
0.001
)
}
assertEquals(
20_000.0,
AstraScopeProjection.spectrumFrequencyAt(1.0, frequencyAnchors = anchors),
0.001
)
}
@Test
fun missingOrInvalidFrequencyAnchorsFallBackToLogProjection() {
val expectedMidpoint = kotlin.math.sqrt(20.0 * 20_000.0)
val nonIncreasing = floatArrayOf(60f, 1_000f, 250f)
val outOfRange = floatArrayOf(60f, 25_000f)
assertEquals(expectedMidpoint, AstraScopeProjection.spectrumFrequencyAt(0.5), 0.001)
assertEquals(
expectedMidpoint,
AstraScopeProjection.spectrumFrequencyAt(0.5, frequencyAnchors = nonIncreasing),
0.001
)
assertEquals(
expectedMidpoint,
AstraScopeProjection.spectrumFrequencyAt(0.5, frequencyAnchors = outOfRange),
0.001
)
}
@Test @Test
fun clampRejectsInvalidAndOutOfRangeValues() { fun clampRejectsInvalidAndOutOfRangeValues() {
assertEquals(0f, AstraScopeProjection.clamp01(Float.NaN), 0f) assertEquals(0f, AstraScopeProjection.clamp01(Float.NaN), 0f)
+2
View File
@@ -87,6 +87,8 @@ export interface AstraScopeViewProps extends ViewProps {
analysisFrameMs?: number; analysisFrameMs?: number;
smoothing?: number; smoothing?: number;
pointCount?: number; pointCount?: number;
/** Optional frequencies pinned to evenly spaced horizontal centers. */
frequencyAnchors?: number[];
dbMin?: number; dbMin?: number;
dbMax?: number; dbMax?: number;
tiltDbPerOctave?: number; tiltDbPerOctave?: number;
+6 -1
View File
@@ -329,7 +329,12 @@ export default function EQScreen() {
// in the tracks' own coordinate space, so it stays glued to the thumbs. // in the tracks' own coordinate space, so it stays glued to the thumbs.
const graphicEditorEl = renderEqGraphics ? ( const graphicEditorEl = renderEqGraphics ? (
<View style={styles.graphicEditor}> <View style={styles.graphicEditor}>
<GraphicEQPanel gains={eq.graphicGains} enabled={eq.enabled} onChangeGain={eq.setGraphicGain} /> <GraphicEQPanel
gains={eq.graphicGains}
enabled={eq.enabled}
spectrumActive={scopeActive && focused}
onChangeGain={eq.setGraphicGain}
/>
</View> </View>
) : null; ) : null;
+4
View File
@@ -15,6 +15,8 @@ interface SpectrumCurveProps {
source?: 'pre' | 'post'; source?: 'pre' | 'post';
/** Number of log-frequency render points. */ /** Number of log-frequency render points. */
pointCount?: number; pointCount?: number;
/** Optional frequencies pinned to evenly spaced horizontal centers. */
frequencyAnchors?: number[];
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */ /** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
frameMs?: number; frameMs?: number;
/** Native analysis cadence. Defaults to frameMs. */ /** Native analysis cadence. Defaults to frameMs. */
@@ -52,6 +54,7 @@ export function SpectrumCurve({
active = false, active = false,
source = 'pre', source = 'pre',
pointCount, pointCount,
frequencyAnchors,
frameMs = MINI_FRAME_MS, frameMs = MINI_FRAME_MS,
analysisFrameMs, analysisFrameMs,
smoothing = DEFAULT_SMOOTHING, smoothing = DEFAULT_SMOOTHING,
@@ -89,6 +92,7 @@ export function SpectrumCurve({
analysisFrameMs={analysisFrameMs ?? frameMs} analysisFrameMs={analysisFrameMs ?? frameMs}
smoothing={smoothing} smoothing={smoothing}
pointCount={resolvedPointCount} pointCount={resolvedPointCount}
frequencyAnchors={frequencyAnchors}
dbMin={dbMin} dbMin={dbMin}
dbMax={dbMax} dbMax={dbMax}
tiltDbPerOctave={tiltDbPerOctave} tiltDbPerOctave={tiltDbPerOctave}
+12 -2
View File
@@ -15,6 +15,7 @@ import {
interface GraphicEQPanelProps { interface GraphicEQPanelProps {
gains: number[]; gains: number[];
enabled: boolean; enabled: boolean;
spectrumActive: boolean;
onChangeGain: (index: number, gainDb: number) => void; onChangeGain: (index: number, gainDb: number) => void;
} }
@@ -25,7 +26,12 @@ interface GraphicEQPanelProps {
* curve's scale. All cells are gap-less flex:1 so column centers match the * curve's scale. All cells are gap-less flex:1 so column centers match the
* curve's evenly spaced band positions. * curve's evenly spaced band positions.
*/ */
export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelProps) { export function GraphicEQPanel({
gains,
enabled,
spectrumActive,
onChangeGain,
}: GraphicEQPanelProps) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
return ( return (
@@ -44,7 +50,11 @@ export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelP
<View style={styles.trackRow}> <View style={styles.trackRow}>
<View style={StyleSheet.absoluteFill}> <View style={StyleSheet.absoluteFill}>
<GraphicResponseCurve gains={gains} enabled={enabled} /> <GraphicResponseCurve
gains={gains}
enabled={enabled}
spectrumActive={spectrumActive}
/>
</View> </View>
{GRAPHIC_BANDS.map((def, i) => ( {GRAPHIC_BANDS.map((def, i) => (
<VerticalEQSlider <VerticalEQSlider
+53 -29
View File
@@ -12,6 +12,7 @@ import {
Skia, Skia,
type SkPath type SkPath
} from '@shopify/react-native-skia'; } from '@shopify/react-native-skia';
import { SpectrumCurve } from '@/components/SpectrumCurve';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
import type { EQBand } from '@/types/audio'; import type { EQBand } from '@/types/audio';
import { import {
@@ -24,10 +25,12 @@ import { GRAPHIC_BANDS, buildGraphicBands } from '@/audio/graphicEq';
import { GRAPH_SAMPLE_RATE, buildResponseFill } from './eqGraphMath'; import { GRAPH_SAMPLE_RATE, buildResponseFill } from './eqGraphMath';
const SAMPLES = 96; const SAMPLES = 96;
const GRAPHIC_SPECTRUM_ANCHORS = GRAPHIC_BANDS.map((band) => band.frequency);
interface GraphicResponseCurveProps { interface GraphicResponseCurveProps {
gains: number[]; gains: number[];
enabled: boolean; enabled: boolean;
spectrumActive: boolean;
} }
/** /**
@@ -38,7 +41,11 @@ interface GraphicResponseCurveProps {
* Rendered behind the sliders; transparent background (the editor card owns * Rendered behind the sliders; transparent background (the editor card owns
* the chrome). * the chrome).
*/ */
export function GraphicResponseCurve({ gains, enabled }: GraphicResponseCurveProps) { export function GraphicResponseCurve({
gains,
enabled,
spectrumActive,
}: GraphicResponseCurveProps) {
const colors = useColors(); const colors = useColors();
const [size, setSize] = useState({ width: 0, height: 0 }); const [size, setSize] = useState({ width: 0, height: 0 });
const width = size.width; const width = size.width;
@@ -61,36 +68,53 @@ export function GraphicResponseCurve({ gains, enabled }: GraphicResponseCurvePro
return ( return (
<View style={styles.container} onLayout={onLayout} pointerEvents="none"> <View style={styles.container} onLayout={onLayout} pointerEvents="none">
{width > 0 && height > 0 ? ( {width > 0 && height > 0 ? (
<Canvas style={StyleSheet.absoluteFill}> <>
{/* Grid: ±6 dB lines + dashed 0 dB midline (track coordinates). */} <View style={StyleSheet.absoluteFill}>
<Group> <SpectrumCurve
<Path source="post"
path={hLine(gainToTrackY(6, height), width)} active={spectrumActive}
color={colors.glassBorder} width={width}
style="stroke" height={height}
strokeWidth={1} frameMs={16}
frequencyAnchors={GRAPHIC_SPECTRUM_ANCHORS}
color={colors.accent}
lineOpacity={0.22}
fillOpacity={0.5}
glow={false}
/> />
<Path </View>
path={hLine(gainToTrackY(-6, height), width)}
color={colors.glassBorder}
style="stroke"
strokeWidth={1}
/>
<Path path={hLine(height / 2, width)} color={colors.glassBorder} style="stroke" strokeWidth={1}>
<DashPathEffect intervals={[3, 5]} />
</Path>
</Group>
<Path path={fillPath} color={withAlpha(curveColor, 0.1)} style="fill" /> <Canvas style={StyleSheet.absoluteFill}>
<Path {/* Grid: ±6 dB lines + dashed 0 dB midline (track coordinates). */}
path={linePath} <Group>
color={curveColor} <Path
style="stroke" path={hLine(gainToTrackY(6, height), width)}
strokeWidth={2} color={colors.glassBorder}
strokeJoin="round" style="stroke"
strokeCap="round" strokeWidth={1}
/> />
</Canvas> <Path
path={hLine(gainToTrackY(-6, height), width)}
color={colors.glassBorder}
style="stroke"
strokeWidth={1}
/>
<Path path={hLine(height / 2, width)} color={colors.glassBorder} style="stroke" strokeWidth={1}>
<DashPathEffect intervals={[3, 5]} />
</Path>
</Group>
<Path path={fillPath} color={withAlpha(curveColor, 0.1)} style="fill" />
<Path
path={linePath}
color={curveColor}
style="stroke"
strokeWidth={2}
strokeJoin="round"
strokeCap="round"
/>
</Canvas>
</>
) : null} ) : null}
</View> </View>
); );