performance improvements + oscilloscope

This commit is contained in:
Boof2015
2026-06-18 15:52:48 -04:00
parent 5853edd933
commit 9fd37c93d7
14 changed files with 907 additions and 236 deletions
+37 -1
View File
@@ -23,6 +23,7 @@ import { colors, radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
import {
cycleRepeat,
seekTo,
@@ -130,6 +131,7 @@ export default function NowPlayingScreen() {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [showScopeStage, setShowScopeStage] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const scopeMode = useSettingsStore((s) => s.scopeMode);
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
const currentTime = usePlayerStore((s) => s.currentTime);
@@ -259,9 +261,30 @@ export default function NowPlayingScreen() {
height={layout.scopeHeight}
interactive={false}
showChrome={false}
mode="spectrum"
mode={scopeMode}
edgeFade
/>
{/* Nested Pressable: RN grants the responder to this
button, so a swap tap does not also collapse the
stage (the outer Pressable's onPress won't fire). */}
<Pressable
onPress={() =>
useSettingsStore
.getState()
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum')
}
hitSlop={12}
style={styles.scopeSwap}
accessibilityRole="button"
accessibilityLabel={`Showing ${
scopeMode === 'spectrum' ? 'spectrum' : 'oscilloscope'
}. Tap to switch.`}
>
<Text variant="caption" style={styles.scopeSwapLabel}>
{scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'}
</Text>
<Ionicons name="swap-horizontal" size={14} color={colors.textTertiary} />
</Pressable>
</View>
) : (
<View
@@ -476,6 +499,19 @@ const styles = StyleSheet.create({
justifyContent: 'center',
overflow: 'hidden',
},
scopeSwap: {
position: 'absolute',
top: spacing.xs,
right: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
scopeSwapLabel: {
color: colors.textTertiary,
letterSpacing: 1.5,
fontSize: 10,
},
artImage: {
width: '100%',
height: '100%',
+7 -4
View File
@@ -10,7 +10,6 @@ import { colors, radius, spacing } from '@/theme';
import { usePlayerStore } from '@/stores/playerStore';
import { skipToNext, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
const PILL_HEIGHT = 56;
const ART = 42;
@@ -29,7 +28,6 @@ export function MiniPlayer() {
const duration = usePlayerStore((s) => s.duration);
const scopeActive = useScopeActive();
const values = useSpectrumCurve(CURVE_POINTS, scopeActive);
const [pillWidth, setPillWidth] = useState(0);
if (!track) return null;
@@ -45,11 +43,16 @@ export function MiniPlayer() {
{scopeActive && pillWidth > 0 && (
<View pointerEvents="none" style={styles.spectrum}>
<SpectrumCurve
values={values}
active={scopeActive}
pointCount={CURVE_POINTS}
analysisFrameMs={0}
dbMin={-84}
dbMax={-20}
width={pillWidth}
height={PILL_HEIGHT}
lineWidth={1.5}
fillOpacity={0.5}
fillOpacity={0.75}
glow
/>
</View>
)}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useMemo, useRef } from 'react';
import {
PaintStyle,
Skia,
SkiaPictureView,
StrokeCap,
StrokeJoin,
type SkPicture,
} from '@shopify/react-native-skia';
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
import { colors } from '@/theme';
interface OscilloscopeWaveProps {
active: boolean;
width: number;
height: number;
color?: string;
lineWidth?: number;
glow?: boolean;
edgeFade?: boolean;
}
type SkiaViewApiShape = {
setJsiProperty: <T>(nativeId: number, name: string, value: T) => void;
requestRedraw: (nativeId: number) => void;
};
const VISUAL_GAIN = 1.8;
const values = new Float32Array(OSCILLOSCOPE_POINTS);
function skiaViewApi(): SkiaViewApiShape | null {
const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape };
return globalWithSkia.SkiaViewApi ?? null;
}
function withAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function makeStrokePaint(color: string, width: number, alpha = 1) {
const paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setColor(Skia.Color(alpha === 1 ? color : withAlpha(color, alpha)));
paint.setStrokeWidth(width);
paint.setStyle(PaintStyle.Stroke);
paint.setStrokeCap(StrokeCap.Round);
paint.setStrokeJoin(StrokeJoin.Round);
return paint;
}
function buildPicture(
samples: Float32Array,
sampleCount: number,
width: number,
height: number,
color: string,
lineWidth: number,
glow: boolean
): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
const n = Math.min(sampleCount, samples.length);
if (n >= 2 && width > 0 && height > 0) {
const path = Skia.Path.Make();
const mid = height / 2;
const amp = mid - lineWidth;
const xAt = (i: number) => (i / (n - 1)) * width;
const yAt = (i: number) => {
let v = samples[i] * VISUAL_GAIN;
if (v < -1) v = -1;
else if (v > 1) v = 1;
return mid - v * amp;
};
path.moveTo(0, yAt(0));
for (let i = 1; i < n; i++) {
path.lineTo(xAt(i), yAt(i));
}
if (glow) {
canvas.drawPath(path, makeStrokePaint(color, lineWidth * 3, 0.18));
}
canvas.drawPath(path, makeStrokePaint(color, lineWidth));
}
return recorder.finishRecordingAsPicture();
}
/**
* Imperative oscilloscope renderer. This mirrors desktop/prism's hot path:
* a frame loop pulls native scope data and draws directly into a canvas-like
* surface instead of routing each frame through React reconciliation.
*/
export function OscilloscopeWave({
active,
width,
height,
color = colors.accent,
lineWidth = 2,
glow = false,
edgeFade: _edgeFade = false,
}: OscilloscopeWaveProps) {
const viewRef = useRef<SkiaPictureView | null>(null);
const initialPicture = useMemo(
() => buildPicture(values, values.length, Math.max(1, width), Math.max(1, height), color, lineWidth, glow),
[color, glow, height, lineWidth, width]
);
useEffect(() => {
const view = viewRef.current;
const api = skiaViewApi();
if (!view || !api || width <= 0 || height <= 0) return;
let mounted = true;
let raf = 0;
const draw = (sampleCount: number) => {
const picture = buildPicture(values, sampleCount, width, height, color, lineWidth, glow);
api.setJsiProperty(view.nativeId, 'picture', picture);
api.requestRedraw(view.nativeId);
};
values.fill(0);
draw(values.length);
const tick = () => {
if (!mounted) return;
raf = requestAnimationFrame(tick);
if (!active) return;
const n = AstraScope.getOscilloscopeFrame(values);
if (n > 0) draw(n);
};
raf = requestAnimationFrame(tick);
return () => {
mounted = false;
cancelAnimationFrame(raf);
};
}, [active, color, glow, height, lineWidth, width]);
if (width <= 0 || height <= 0) return null;
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
}
export default OscilloscopeWave;
+342 -70
View File
@@ -1,25 +1,62 @@
import { useMemo } from 'react';
import { Canvas, Group, LinearGradient, Path, Rect, Skia, vec } from '@shopify/react-native-skia';
import { useEffect, useMemo, useRef } from 'react';
import {
PaintStyle,
Skia,
SkiaPictureView,
StrokeCap,
StrokeJoin,
TileMode,
type SkPicture,
} from '@shopify/react-native-skia';
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
import { colors } from '@/theme';
interface SpectrumCurveProps {
/** Normalized magnitudes in [0,1], one per point (see useSpectrumCurve). */
values: number[];
/** Normalized magnitudes in [0,1] for static rendering. Live rendering ignores this. */
values?: ArrayLike<number>;
width: number;
height: number;
/** Hex line/fill color (e.g. theme accent). Defaults to the cyan accent. */
/** Pull native spectrum frames while active, bypassing React per-frame state. */
active?: boolean;
/** Number of render points when active. Defaults to one point per rendered pixel. */
pointCount?: number;
/** Active render cadence. 0 means display-sync; 32 keeps the mini-player battery-friendly. */
frameMs?: number;
/** Native pull cadence. Defaults to frameMs; 0 advances analysis every display frame. */
analysisFrameMs?: number;
dbMin?: number;
dbMax?: number;
tiltDbPerOctave?: number;
color?: string;
lineWidth?: number;
/** 0..1 multiplier on the gradient fill under the line. */
fillOpacity?: number;
/** Adds a soft wider stroke under the line for a glow. */
glow?: boolean;
/** Fades the left/right edges into the background so stage views do not end abruptly. */
edgeFade?: boolean;
edgeFadeColor?: string;
edgeFadeWidth?: number;
}
type SkiaViewApiShape = {
setJsiProperty: <T>(nativeId: number, name: string, value: T) => void;
requestRedraw: (nativeId: number) => void;
};
const DEFAULT_POINTS = 120;
const MINI_FRAME_MS = 32;
const DISPLAY_DB_MIN = -90;
const DISPLAY_DB_MAX = -10;
const SPECTRUM_SAMPLE_RATE = 48000;
const MIN_FREQUENCY = 20;
const MAX_FREQUENCY = 20000;
const TILT_DB_PER_OCT = 3.5;
const TILT_REFERENCE_HZ = 1000;
const spectrumBins = new Float32Array(SPECTRUM_BINS);
function skiaViewApi(): SkiaViewApiShape | null {
const globalWithSkia = globalThis as typeof globalThis & { SkiaViewApi?: SkiaViewApiShape };
return globalWithSkia.SkiaViewApi ?? null;
}
/** #rrggbb -> rgba() with the given alpha. */
function withAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
@@ -28,12 +65,53 @@ function withAlpha(hex: string, alpha: number): string {
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Builds a smooth (quadratic-through-midpoints) path for the line, plus a copy
* closed to the baseline for the gradient fill. Same curve the desktop spectrum
* draws, ported to Skia.
*/
function buildPaths(values: number[], width: number, height: number, pad: number) {
function makeStrokePaint(color: string, width: number, alpha = 1) {
const paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setColor(Skia.Color(alpha === 1 ? color : withAlpha(color, alpha)));
paint.setStrokeWidth(width);
paint.setStyle(PaintStyle.Stroke);
paint.setStrokeCap(StrokeCap.Round);
paint.setStrokeJoin(StrokeJoin.Round);
return paint;
}
function makeFillPaint(color: string, height: number, opacity: number) {
const paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setStyle(PaintStyle.Fill);
paint.setShader(
Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: 0, y: height },
[
Skia.Color(withAlpha(color, 0.38 * opacity)),
Skia.Color(withAlpha(color, 0.08 * opacity)),
Skia.Color(withAlpha(color, 0)),
],
null,
TileMode.Clamp
)
);
return paint;
}
function makeFadePaint(color: string, startAlpha: number, endAlpha: number, x0: number, x1: number) {
const paint = Skia.Paint();
paint.setStyle(PaintStyle.Fill);
paint.setShader(
Skia.Shader.MakeLinearGradient(
{ x: x0, y: 0 },
{ x: x1, y: 0 },
[Skia.Color(withAlpha(color, startAlpha)), Skia.Color(withAlpha(color, endAlpha))],
null,
TileMode.Clamp
)
);
return paint;
}
function buildPaths(values: ArrayLike<number>, width: number, height: number, pad: number) {
const line = Skia.Path.Make();
const n = values.length;
if (n < 2 || width <= 0 || height <= 0) return { line, fill: line.copy() };
@@ -61,14 +139,161 @@ function buildPaths(values: number[], width: number, height: number, pad: number
return { line, fill };
}
function buildPicture(
values: ArrayLike<number>,
width: number,
height: number,
color: string,
lineWidth: number,
fillOpacity: number,
glow: boolean,
edgeFade: boolean,
edgeFadeColor: string,
edgeFadeWidth: number
): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
const { line, fill } = buildPaths(values, width, height, lineWidth);
if (values.length >= 2 && width > 0 && height > 0) {
canvas.drawPath(fill, makeFillPaint(color, height, fillOpacity));
if (glow) {
canvas.drawPath(line, makeStrokePaint(color, lineWidth * 3, 0.18));
}
canvas.drawPath(line, makeStrokePaint(color, lineWidth));
}
if (edgeFade && width > 0 && height > 0 && edgeFadeWidth > 0) {
const fadeWidth = Math.min(edgeFadeWidth, width * 0.5);
canvas.drawRect(
Skia.XYWHRect(0, 0, fadeWidth, height),
makeFadePaint(edgeFadeColor, 1, 0, 0, fadeWidth)
);
canvas.drawRect(
Skia.XYWHRect(width - fadeWidth, 0, fadeWidth, height),
makeFadePaint(edgeFadeColor, 0, 1, width - fadeWidth, width)
);
}
return recorder.finishRecordingAsPicture();
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function interpolatedValue(data: Float32Array, index: number): number {
const i0 = Math.max(0, Math.min(data.length - 1, Math.floor(index)));
const i1 = Math.min(i0 + 1, data.length - 1);
return lerp(data[i0], data[i1], index - i0);
}
function frequencyAtPosition(t: number, minFrequency: number, maxFrequency: number): number {
const logMin = Math.log10(minFrequency);
const logMax = Math.log10(maxFrequency);
return 10 ** (logMin + t * (logMax - logMin));
}
function peakInRange(data: Float32Array, startIndex: number, endIndex: number, binWidth: number) {
const clampedStart = Math.max(0, Math.min(data.length - 1, startIndex));
const clampedEnd = Math.max(0, Math.min(data.length - 1, endIndex));
const lo = Math.floor(Math.min(clampedStart, clampedEnd));
const hi = Math.ceil(Math.max(clampedStart, clampedEnd));
if (hi <= lo) {
return {
rawDb: interpolatedValue(data, clampedStart),
frequencyHz: Math.max(0, clampedStart * binWidth),
};
}
let peakBin = lo;
let peakDb = Number.NEGATIVE_INFINITY;
for (let i = lo; i <= hi; i++) {
if (data[i] > peakDb) {
peakDb = data[i];
peakBin = i;
}
}
if (peakBin > 0 && peakBin < data.length - 1) {
const y1 = data[peakBin - 1];
const y2 = data[peakBin];
const y3 = data[peakBin + 1];
const denominator = y1 - 2 * y2 + y3;
if (Math.abs(denominator) > 1e-9) {
const offset = Math.max(-0.5, Math.min(0.5, 0.5 * (y1 - y3) / denominator));
return {
rawDb: y2 - 0.25 * (y1 - y3) * offset,
frequencyHz: Math.max(0, (peakBin + offset) * binWidth),
};
}
}
return {
rawDb: peakDb,
frequencyHz: Math.max(0, peakBin * binWidth),
};
}
interface SpectrumPointOptions {
dbMin: number;
dbMax: number;
tiltDbPerOctave: number;
}
function applyTilt(db: number, frequency: number, tiltDbPerOctave: number): number {
const safeFreq = Math.max(1, frequency);
return db + tiltDbPerOctave * Math.log2(safeFreq / TILT_REFERENCE_HZ);
}
function writeSpectrumPoints(rawBins: Float32Array, out: Float32Array, options: SpectrumPointOptions) {
const pointCount = out.length;
const bufferLength = rawBins.length;
const nyquist = SPECTRUM_SAMPLE_RATE / 2;
const minFrequency = Math.max(1, Math.min(MIN_FREQUENCY, nyquist));
const maxFrequency = Math.max(minFrequency + 1, Math.min(MAX_FREQUENCY, nyquist));
const binWidth = nyquist / bufferLength;
const dbRange = Math.max(1, options.dbMax - options.dbMin);
for (let p = 0; p < pointCount; p++) {
const t0 = p / (pointCount - 1);
const t1 = Math.min(1, (p + 1) / (pointCount - 1));
const frequency0 = frequencyAtPosition(t0, minFrequency, maxFrequency);
const frequency1 = frequencyAtPosition(t1, minFrequency, maxFrequency);
const centerFrequency = (frequency0 + frequency1) * 0.5;
const bin0 = frequency0 / binWidth;
const bin1 = frequency1 / binWidth;
const centerBin = (bin0 + bin1) * 0.5;
const binSpan = Math.abs(bin1 - bin0);
const rawDb =
binSpan <= 1
? interpolatedValue(rawBins, Math.min(centerBin, bufferLength - 1))
: peakInRange(rawBins, bin0, bin1, binWidth).rawDb;
const db = applyTilt(rawDb, centerFrequency, options.tiltDbPerOctave);
let norm = (db - options.dbMin) / dbRange;
if (norm < 0) norm = 0;
else if (norm > 1) norm = 1;
out[p] = norm;
}
}
/**
* Filled-line spectrum (the desktop "CURVE" look): a smooth line over a vertical
* gradient fill. Source-agnostic — give it normalized values and a size.
* Filled-line spectrum. When `active` is true this mirrors the oscilloscope hot
* path: a frame loop pulls native data and updates the Skia view imperatively.
*/
export function SpectrumCurve({
values,
width,
height,
active = false,
pointCount,
frameMs = MINI_FRAME_MS,
analysisFrameMs,
dbMin = DISPLAY_DB_MIN,
dbMax = DISPLAY_DB_MAX,
tiltDbPerOctave = TILT_DB_PER_OCT,
color = colors.accent,
lineWidth = 2,
fillOpacity = 1,
@@ -77,63 +302,110 @@ export function SpectrumCurve({
edgeFadeColor = colors.bgPrimary,
edgeFadeWidth = 28,
}: SpectrumCurveProps) {
const pad = lineWidth;
const { line, fill } = useMemo(
() => buildPaths(values, width, height, pad),
[values, width, height, pad]
const viewRef = useRef<SkiaPictureView | null>(null);
const activePointCount = Math.max(2, Math.floor(width));
const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
const staticValues = useMemo(
() => values ?? new Float32Array(resolvedPointCount),
[resolvedPointCount, values]
);
const initialPicture = useMemo(
() =>
buildPicture(
staticValues,
Math.max(1, width),
Math.max(1, height),
color,
lineWidth,
fillOpacity,
glow,
edgeFade,
edgeFadeColor,
edgeFadeWidth
),
[color, edgeFade, edgeFadeColor, edgeFadeWidth, fillOpacity, glow, height, lineWidth, staticValues, width]
);
useEffect(() => {
if (!active) return;
const view = viewRef.current;
const api = skiaViewApi();
if (!view || !api || width <= 0 || height <= 0 || resolvedPointCount < 2) return;
let mounted = true;
let raf = 0;
let lastAnalysis = 0;
let lastDraw = 0;
let hasNewFrame = false;
const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0;
const analysisMs = analysisFrameMs ?? frameMs;
const analysisThreshold = analysisMs > 0 ? Math.max(0, analysisMs - 0.5) : 0;
const renderValues = new Float32Array(resolvedPointCount);
const pointOptions = { dbMin, dbMax, tiltDbPerOctave };
const draw = () => {
const picture = buildPicture(
renderValues,
width,
height,
color,
lineWidth,
fillOpacity,
glow,
edgeFade,
edgeFadeColor,
edgeFadeWidth
);
api.setJsiProperty(view.nativeId, 'picture', picture);
api.requestRedraw(view.nativeId);
};
renderValues.fill(0);
draw();
const tick = (t: number) => {
if (!mounted) return;
raf = requestAnimationFrame(tick);
if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) {
lastAnalysis = t;
if (AstraScope.getSpectrumFrame(spectrumBins) > 0) {
writeSpectrumPoints(spectrumBins, renderValues, pointOptions);
hasNewFrame = true;
}
}
if (!hasNewFrame || (drawThreshold > 0 && t - lastDraw < drawThreshold)) return;
lastDraw = t;
hasNewFrame = false;
draw();
};
raf = requestAnimationFrame(tick);
return () => {
mounted = false;
cancelAnimationFrame(raf);
};
}, [
active,
analysisFrameMs,
color,
dbMax,
dbMin,
edgeFade,
edgeFadeColor,
edgeFadeWidth,
fillOpacity,
frameMs,
glow,
height,
lineWidth,
resolvedPointCount,
tiltDbPerOctave,
width,
]);
if (width <= 0 || height <= 0) return null;
return (
<Canvas style={{ width, height }}>
<Group opacity={fillOpacity}>
<Path path={fill}>
<LinearGradient
start={vec(0, 0)}
end={vec(0, height)}
colors={[withAlpha(color, 0.38), withAlpha(color, 0.08), withAlpha(color, 0)]}
/>
</Path>
</Group>
{glow && (
<Path
path={line}
style="stroke"
strokeWidth={lineWidth * 3}
strokeJoin="round"
strokeCap="round"
color={withAlpha(color, 0.18)}
/>
)}
<Path
path={line}
style="stroke"
strokeWidth={lineWidth}
strokeJoin="round"
strokeCap="round"
color={color}
/>
{edgeFade && (
<>
<Rect x={0} y={0} width={edgeFadeWidth} height={height}>
<LinearGradient
start={vec(0, 0)}
end={vec(edgeFadeWidth, 0)}
colors={[edgeFadeColor, withAlpha(edgeFadeColor, 0)]}
/>
</Rect>
<Rect x={width - edgeFadeWidth} y={0} width={edgeFadeWidth} height={height}>
<LinearGradient
start={vec(width - edgeFadeWidth, 0)}
end={vec(width, 0)}
colors={[withAlpha(edgeFadeColor, 0), edgeFadeColor]}
/>
</Rect>
</>
)}
</Canvas>
);
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
}
export default SpectrumCurve;
+18 -21
View File
@@ -3,12 +3,12 @@ import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { SpectrumCurve } from './SpectrumCurve';
import { OscilloscopeWave } from './OscilloscopeWave';
import { colors, spacing } from '@/theme';
import { useScopeActive } from '@/scope/scopeStore';
import { useSpectrumCurve } from '@/scope/useSpectrumCurve';
const CANVAS_HEIGHT = 96;
const POINTS = 120;
const STAGE_FRAME_MS = 0; // display-sync
type Mode = 'spectrum' | 'scope';
@@ -38,7 +38,7 @@ export function Visualizer({
const mode = controlledMode ?? uncontrolledMode;
const scopeActive = useScopeActive();
const spectrumActive = scopeActive && mode === 'spectrum';
const values = useSpectrumCurve(POINTS, spectrumActive);
const scopeWaveActive = scopeActive && mode === 'scope';
const toggle = () => {
if (controlledMode) return;
@@ -58,14 +58,22 @@ export function Visualizer({
<View style={{ width, height }}>
{mode === 'spectrum' ? (
<SpectrumCurve values={values} width={width} height={height} glow edgeFade={edgeFade} />
<SpectrumCurve
active={spectrumActive}
frameMs={STAGE_FRAME_MS}
width={width}
height={height}
glow
edgeFade={edgeFade}
/>
) : (
<View style={styles.placeholder}>
<Ionicons name="pulse-outline" size={20} color={colors.textTertiary} />
<Text variant="caption" style={styles.placeholderText}>
OSCILLOSCOPE · COMING SOON
</Text>
</View>
<OscilloscopeWave
active={scopeWaveActive}
width={width}
height={height}
glow
edgeFade={edgeFade}
/>
)}
</View>
</>
@@ -109,17 +117,6 @@ const styles = StyleSheet.create({
letterSpacing: 1.5,
fontSize: 10,
},
placeholder: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
},
placeholderText: {
color: colors.textTertiary,
letterSpacing: 1.5,
fontSize: 10,
},
});
export default Visualizer;
-91
View File
@@ -1,91 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
const FRAME_MS = 32; // ~30fps — ambient, battery-friendly
// Display window (dB). Tighter than the raw [-100,0] capture range so music
// fills the curve with punch instead of hugging the floor.
const DISPLAY_DB_MIN = -88;
const DISPLAY_DB_MAX = -16;
const DB_RANGE = DISPLAY_DB_MAX - DISPLAY_DB_MIN;
// Map points across a log-frequency (geometric bin) axis like the desktop
// SpectrumAnalyzer, so the low end isn't squashed. Skip DC/rumble at the bottom.
const BIN_LOW = 2;
const BIN_HIGH = SPECTRUM_BINS - 1;
// Gentle upward tilt (dB/octave) so the curve reads as a shape, not a downward
// ramp dominated by bass — same idea as the desktop's spectrum tilt.
const TILT_DB_PER_OCT = 2;
// Temporal smoothing: rise instantly, fall smoothly, for a fluid line.
const RELEASE = 0.72;
// One reused buffer across all consumers: getSpectrumFrame fills it in place and
// we read it out synchronously on the JS thread, so a module-level buffer is safe.
const buffer = new Float32Array(SPECTRUM_BINS);
/**
* Pulls the latest spectrum from the native tap on a JS-thread rAF loop (while
* `active`) and returns `pointCount` magnitudes in [0,1] sampled on a
* log-frequency axis, smoothed over time. Feeds the filled-line {@link
* SpectrumCurve}. Returns all-zero (flat) points when inactive — no loop, no
* setState — so callers render a clean baseline.
*/
export function useSpectrumCurve(pointCount: number, active: boolean): number[] {
const [values, setValues] = useState<number[]>(() => new Array(pointCount).fill(0));
const zeros = useMemo(() => new Array<number>(pointCount).fill(0), [pointCount]);
useEffect(() => {
if (!active) return; // inactive: no loop, no setState; caller gets `zeros`
let mounted = true;
let raf = 0;
let last = 0;
const smoothed = new Float32Array(pointCount);
const logLow = Math.log(BIN_LOW);
const logHigh = Math.log(BIN_HIGH);
const binAt = (t: number) => Math.exp(logLow + t * (logHigh - logLow));
const refBin = binAt(0.5); // tilt pivot (midband)
const tick = (t: number) => {
if (!mounted) return;
raf = requestAnimationFrame(tick);
if (t - last < FRAME_MS) return;
last = t;
if (AstraScope.getSpectrumFrame(buffer) <= 0) return;
const out = new Array<number>(pointCount);
for (let p = 0; p < pointCount; p++) {
const b0 = binAt(p / pointCount);
const b1 = binAt((p + 1) / pointCount);
const lo = Math.max(BIN_LOW, Math.floor(b0));
const hi = Math.min(BIN_HIGH, Math.max(lo, Math.ceil(b1)));
// Peak (loudest bin) across the band — punchier than an average.
let db = -200;
for (let i = lo; i <= hi; i++) if (buffer[i] > db) db = buffer[i];
const octaves = Math.log2(Math.max(1, (b0 + b1) * 0.5) / refBin);
db += TILT_DB_PER_OCT * octaves;
let norm = (db - DISPLAY_DB_MIN) / DB_RANGE;
if (norm < 0) norm = 0;
else if (norm > 1) norm = 1;
const prev = smoothed[p];
const next = norm >= prev ? norm : prev * RELEASE + norm * (1 - RELEASE);
smoothed[p] = next;
out[p] = next;
}
setValues(out);
};
raf = requestAnimationFrame(tick);
return () => {
mounted = false;
cancelAnimationFrame(raf);
};
}, [active, pointCount]);
return active ? values : zeros;
}
+27 -2
View File
@@ -9,27 +9,45 @@ import type { ArtistGroupingMode } from '@/library/artistGrouping';
* subscribes here to recompute the artist list when the grouping mode changes.
*/
const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
const SCOPE_MODE_KEY = 'scope_mode';
/** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope';
function parseGroupingMode(value: string | null): ArtistGroupingMode {
return value === 'fileTags' ? 'fileTags' : 'astra';
}
function parseScopeMode(value: string | null): ScopeMode {
return value === 'scope' ? 'scope' : 'spectrum';
}
interface SettingsStore {
artistGroupingMode: ArtistGroupingMode;
scopeMode: ScopeMode;
loaded: boolean;
load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
setScopeMode: (mode: ScopeMode) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
artistGroupingMode: 'astra',
scopeMode: 'spectrum',
loaded: false,
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const stored = await getSetting(db, ARTIST_GROUPING_KEY);
set({ artistGroupingMode: parseGroupingMode(stored), loaded: true });
const [grouping, scope] = await Promise.all([
getSetting(db, ARTIST_GROUPING_KEY),
getSetting(db, SCOPE_MODE_KEY),
]);
set({
artistGroupingMode: parseGroupingMode(grouping),
scopeMode: parseScopeMode(scope),
loaded: true,
});
},
setArtistGroupingMode: async (mode) => {
@@ -38,4 +56,11 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
const db = await openLibraryDb();
await setSetting(db, ARTIST_GROUPING_KEY, mode);
},
setScopeMode: async (mode) => {
if (get().scopeMode === mode) return;
set({ scopeMode: mode });
const db = await openLibraryDb();
await setSetting(db, SCOPE_MODE_KEY, mode);
},
}));