From d4b1a1e122e25238cb3da9ed5be1955a348b41c5 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:42:57 -0400 Subject: [PATCH] add spectrum to graphic eq mode --- .../modules/astrascope/AstraScopeModule.kt | 5 ++ .../astrascope/AstraScopeProjection.kt | 74 ++++++++++++++++- .../expo/modules/astrascope/AstraScopeView.kt | 4 +- .../astrascope/AstraScopeProjectionTest.kt | 39 +++++++++ modules/astra-scope/index.ts | 2 + src/app/(tabs)/eq.tsx | 7 +- src/components/SpectrumCurve.tsx | 4 + src/components/eq/GraphicEQPanel.tsx | 14 +++- src/components/eq/GraphicResponseCurve.tsx | 82 ++++++++++++------- 9 files changed, 194 insertions(+), 37 deletions(-) diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt index ae0db57..f2ff6ab 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeModule.kt @@ -100,6 +100,11 @@ class AstraScopeModule : Module() { Prop("analysisFrameMs") { view, value: Double -> view.analysisFrameMs = value } Prop("smoothing") { view, value: Double -> view.smoothing = value.toFloat() } Prop("pointCount") { view, value: Int -> view.pointCount = value } + Prop("frequencyAnchors") { view, value: List? -> + view.frequencyAnchors = value?.let { frequencies -> + FloatArray(frequencies.size) { index -> frequencies[index].toFloat() } + } + } Prop("dbMin") { view, value: Double -> view.dbMin = value.toFloat() } Prop("dbMax") { view, value: Double -> view.dbMax = value.toFloat() } Prop("tiltDbPerOctave") { view, value: Double -> view.tiltDbPerOctave = value.toFloat() } diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt index 7959ce1..1403a44 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt @@ -1,5 +1,6 @@ package expo.modules.astrascope +import kotlin.math.floor import kotlin.math.ln import kotlin.math.max import kotlin.math.min @@ -83,6 +84,7 @@ internal object AstraScopeProjection { dbMin: Float, dbMax: Float, tiltDbPerOctave: Float, + frequencyAnchors: FloatArray? = null, sampleRate: Float = 48_000f ) { val bins = min(rawCount, raw.capacity()) @@ -97,12 +99,13 @@ internal object AstraScopeProjection { val maxFrequency = max(minFrequency + 1.0, min(MAX_FREQUENCY, nyquist)) val binWidth = nyquist / bins.toDouble() val range = max(1f, dbMax - dbMin) + val anchors = validFrequencyAnchors(frequencyAnchors, minFrequency, maxFrequency) for (point in 0 until points) { val t0 = point.toDouble() / (points - 1).toDouble() val t1 = min(1.0, (point + 1).toDouble() / (points - 1).toDouble()) - val frequency0 = frequencyAt(t0, minFrequency, maxFrequency) - val frequency1 = frequencyAt(t1, minFrequency, maxFrequency) + val frequency0 = frequencyAt(t0, minFrequency, maxFrequency, anchors) + val frequency1 = frequencyAt(t1, minFrequency, maxFrequency, anchors) val centerFrequency = (frequency0 + frequency1) * 0.5 val bin0 = frequency0 / 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. */ fun decay(values: FloatArray, count: Int, factor: Float = DECAY_PER_FRAME): Float { var peak = 0f @@ -131,8 +148,57 @@ internal object AstraScopeProjection { return peak } - private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double = - minFrequency * (maxFrequency / minFrequency).pow(t) + private fun frequencyAt( + 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 = if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt index 523f989..ba4e5fa 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt @@ -64,6 +64,7 @@ internal class AstraScopeView( var analysisFrameMs = 32.0 var smoothing = 0.92f var pointCount = 120 + var frequencyAnchors: FloatArray? = null var dbMin = -90f var dbMax = -10f var tiltDbPerOctave = 3.5f @@ -332,7 +333,8 @@ internal class AstraScopeView( renderedPointCount, dbMin, dbMax, - tiltDbPerOctave + tiltDbPerOctave, + frequencyAnchors ) true } diff --git a/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt index bc188a2..8f04617 100644 --- a/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt +++ b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt @@ -22,6 +22,45 @@ class AstraScopeProjectionTest { 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 fun clampRejectsInvalidAndOutOfRangeValues() { assertEquals(0f, AstraScopeProjection.clamp01(Float.NaN), 0f) diff --git a/modules/astra-scope/index.ts b/modules/astra-scope/index.ts index a5dc9f0..20b2b27 100644 --- a/modules/astra-scope/index.ts +++ b/modules/astra-scope/index.ts @@ -87,6 +87,8 @@ export interface AstraScopeViewProps extends ViewProps { analysisFrameMs?: number; smoothing?: number; pointCount?: number; + /** Optional frequencies pinned to evenly spaced horizontal centers. */ + frequencyAnchors?: number[]; dbMin?: number; dbMax?: number; tiltDbPerOctave?: number; diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index db8aa9d..e811a8a 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -329,7 +329,12 @@ export default function EQScreen() { // in the tracks' own coordinate space, so it stays glued to the thumbs. const graphicEditorEl = renderEqGraphics ? ( - + ) : null; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 7d46a1d..d7e5ea5 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -15,6 +15,8 @@ interface SpectrumCurveProps { source?: 'pre' | 'post'; /** Number of log-frequency render points. */ 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. */ frameMs?: number; /** Native analysis cadence. Defaults to frameMs. */ @@ -52,6 +54,7 @@ export function SpectrumCurve({ active = false, source = 'pre', pointCount, + frequencyAnchors, frameMs = MINI_FRAME_MS, analysisFrameMs, smoothing = DEFAULT_SMOOTHING, @@ -89,6 +92,7 @@ export function SpectrumCurve({ analysisFrameMs={analysisFrameMs ?? frameMs} smoothing={smoothing} pointCount={resolvedPointCount} + frequencyAnchors={frequencyAnchors} dbMin={dbMin} dbMax={dbMax} tiltDbPerOctave={tiltDbPerOctave} diff --git a/src/components/eq/GraphicEQPanel.tsx b/src/components/eq/GraphicEQPanel.tsx index ba00faf..5c23b51 100644 --- a/src/components/eq/GraphicEQPanel.tsx +++ b/src/components/eq/GraphicEQPanel.tsx @@ -15,6 +15,7 @@ import { interface GraphicEQPanelProps { gains: number[]; enabled: boolean; + spectrumActive: boolean; 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 evenly spaced band positions. */ -export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelProps) { +export function GraphicEQPanel({ + gains, + enabled, + spectrumActive, + onChangeGain, +}: GraphicEQPanelProps) { const styles = useStyles(); const colors = useColors(); return ( @@ -44,7 +50,11 @@ export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelP - + {GRAPHIC_BANDS.map((def, i) => ( band.frequency); interface GraphicResponseCurveProps { gains: number[]; enabled: boolean; + spectrumActive: boolean; } /** @@ -38,7 +41,11 @@ interface GraphicResponseCurveProps { * Rendered behind the sliders; transparent background (the editor card owns * the chrome). */ -export function GraphicResponseCurve({ gains, enabled }: GraphicResponseCurveProps) { +export function GraphicResponseCurve({ + gains, + enabled, + spectrumActive, +}: GraphicResponseCurveProps) { const colors = useColors(); const [size, setSize] = useState({ width: 0, height: 0 }); const width = size.width; @@ -61,36 +68,53 @@ export function GraphicResponseCurve({ gains, enabled }: GraphicResponseCurvePro return ( {width > 0 && height > 0 ? ( - - {/* Grid: ±6 dB lines + dashed 0 dB midline (track coordinates). */} - - + + - - - - - + - - - + + {/* Grid: ±6 dB lines + dashed 0 dB midline (track coordinates). */} + + + + + + + + + + + + ) : null} );