performance improvements + ui/ux fixes

This commit is contained in:
Boof2015
2026-07-25 19:42:42 -04:00
parent aaadff107c
commit 6dd82420c8
22 changed files with 1269 additions and 953 deletions
@@ -90,5 +90,34 @@ class AstraScopeModule : Module() {
Function("setFallbackGain") { linear: Double ->
GainBridge.fallbackGain = linear.toFloat()
}
View(AstraScopeView::class) {
Prop("mode") { view, value: String -> view.mode = ScopeMode.from(value) }
Prop("source") { view, value: String -> view.source = ScopeSource.from(value) }
Prop("active") { view, value: Boolean -> view.requestedActive = value }
Prop("reducedMotion") { view, value: Boolean -> view.reducedMotion = value }
Prop("frameMs") { view, value: Double -> view.frameMs = value }
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("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() }
Prop("color") { view, value: Int -> view.scopeColor = value }
Prop("lineWidth") { view, value: Double -> view.lineWidthDp = value.toFloat() }
Prop("lineOpacity") { view, value: Double -> view.lineOpacity = value.toFloat() }
Prop("fillOpacity") { view, value: Double -> view.fillOpacity = value.toFloat() }
Prop("glow") { view, value: Boolean -> view.glow = value }
Prop("glowOpacity") { view, value: Double -> view.glowOpacity = value.toFloat() }
Prop("edgeFade") { view, value: Boolean -> view.edgeFade = value }
Prop("edgeFadeWidth") { view, value: Double -> view.edgeFadeWidthDp = value.toFloat() }
Prop("gain") { view, value: Double -> view.gain = value.toFloat() }
Prop("values") { view, value: List<Double>? ->
view.staticValues = value?.let { values ->
FloatArray(values.size) { index -> values[index].toFloat() }
}
}
OnViewDidUpdateProps { view -> view.commitProps() }
}
}
}
@@ -0,0 +1,165 @@
package expo.modules.astrascope
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
internal enum class ScopeMode {
SPECTRUM,
OSCILLOSCOPE;
companion object {
fun from(value: String): ScopeMode =
if (value == "oscilloscope") OSCILLOSCOPE else SPECTRUM
}
}
internal enum class ScopeSource {
PRE,
POST;
companion object {
fun from(value: String): ScopeSource = if (value == "post") POST else PRE
}
}
/**
* Pure scope math kept separate from the Android view so cadence, projection,
* clamping, decay, and lifecycle generation changes have inexpensive JVM tests.
*/
internal object AstraScopeProjection {
const val SPECTRUM_BINS = 1024
const val OSCILLOSCOPE_POINTS = 256
const val DECAY_PER_FRAME = 0.72f
const val REST_EPSILON = 0.004f
private const val MIN_FREQUENCY = 20.0
private const val MAX_FREQUENCY = 20_000.0
private const val TILT_REFERENCE_HZ = 1_000.0
private const val LN_2 = 0.6931471805599453
fun cadenceMs(requestedMs: Double, refreshRate: Float): Long {
if (requestedMs > 0.0) return max(1L, requestedMs.roundToInt().toLong())
val safeRate = if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f
return max(1L, (1_000.0 / safeRate).roundToInt().toLong())
}
fun clamp01(value: Float): Float = when {
!value.isFinite() || value <= 0f -> 0f
value >= 1f -> 1f
else -> value
}
/**
* Projects linear FFT bins into log-frequency display points, matching the
* former React/Skia renderer's 20 Hz20 kHz presentation and tilt.
*/
fun writeSpectrum(
raw: java.nio.FloatBuffer,
rawCount: Int,
out: FloatArray,
pointCount: Int,
dbMin: Float,
dbMax: Float,
tiltDbPerOctave: Float,
sampleRate: Float = 48_000f
) {
val bins = min(rawCount, raw.capacity())
val points = min(pointCount, out.size)
if (bins <= 0 || points < 2) {
out.fill(0f, 0, max(0, points))
return
}
val nyquist = max(1.0, sampleRate.toDouble() / 2.0)
val minFrequency = min(MIN_FREQUENCY, nyquist)
val maxFrequency = max(minFrequency + 1.0, min(MAX_FREQUENCY, nyquist))
val binWidth = nyquist / bins.toDouble()
val range = max(1f, dbMax - dbMin)
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 centerFrequency = (frequency0 + frequency1) * 0.5
val bin0 = frequency0 / binWidth
val bin1 = frequency1 / binWidth
val centerBin = (bin0 + bin1) * 0.5
val rawDb = if (kotlin.math.abs(bin1 - bin0) <= 1.0) {
interpolated(raw, bins, min(centerBin, (bins - 1).toDouble()))
} else {
peak(raw, bins, bin0, bin1)
}
val tiltedDb =
rawDb + tiltDbPerOctave * (ln(max(1.0, centerFrequency) / TILT_REFERENCE_HZ) / LN_2).toFloat()
out[point] = clamp01((tiltedDb - dbMin) / range)
}
}
/** 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
val n = min(count, values.size)
for (index in 0 until n) {
val next = values[index] * factor
values[index] = next
peak = max(peak, kotlin.math.abs(next))
}
return peak
}
private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double =
minFrequency * (maxFrequency / minFrequency).pow(t)
private fun interpolated(
values: java.nio.FloatBuffer,
count: Int,
position: Double
): Float {
val lower = position.toInt().coerceIn(0, count - 1)
val upper = min(count - 1, lower + 1)
val mix = (position - lower.toDouble()).toFloat()
return values.get(lower) + (values.get(upper) - values.get(lower)) * mix
}
private fun peak(
values: java.nio.FloatBuffer,
count: Int,
startPosition: Double,
endPosition: Double
): Float {
val start = startPosition.toInt().coerceIn(0, count - 1)
val end = kotlin.math.ceil(endPosition).toInt().coerceIn(start, count - 1)
var result = values.get(start)
for (index in (start + 1)..end) result = max(result, values.get(index))
return result
}
}
/**
* A monotonically increasing token makes queued/running work self-cancelling
* after detach, backgrounding, pause, size loss, or a prop-generation change.
*/
internal class ScopeRenderGate {
@Volatile
private var generation = 0
@Volatile
var eligible: Boolean = false
private set
@Synchronized
fun update(nextEligible: Boolean): Int {
eligible = nextEligible
generation += 1
return generation
}
fun isCurrent(token: Int): Boolean = eligible && generation == token
fun currentGeneration(): Int = generation
}
@@ -0,0 +1,451 @@
package expo.modules.astrascope
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ComposeShader
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PorterDuff
import android.graphics.Shader
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.view.TextureView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
private object ScopeRenderDispatcher {
private val thread = HandlerThread("AstraScopeRender").apply { start() }
val handler = Handler(thread.looper)
}
/**
* Android-native spectrum/oscilloscope surface. The shared worker owns every
* FFT read and path mutation; the UI thread only paints the most recently
* prepared front path.
*/
internal class AstraScopeView(
context: Context,
appContext: AppContext
) : ExpoView(context, appContext) {
var mode = ScopeMode.SPECTRUM
var source = ScopeSource.PRE
var requestedActive = false
var reducedMotion = false
var frameMs = 32.0
var analysisFrameMs = 32.0
var smoothing = 0.92f
var pointCount = 120
var dbMin = -90f
var dbMax = -10f
var tiltDbPerOctave = 3.5f
var scopeColor = Color.WHITE
var lineWidthDp = 2f
var lineOpacity = 1f
var fillOpacity = 1f
var glow = false
var glowOpacity = 0.18f
var edgeFade = false
var edgeFadeWidthDp = 28f
var gain = 1f
var staticValues: FloatArray? = null
private val density = resources.displayMetrics.density
private val pathLock = Any()
private val linePaths = arrayOf(Path(), Path())
private val fillPaths = arrayOf(Path(), Path())
private var frontPath = 0
private val renderedValues = FloatArray(MAX_RENDER_POINTS)
private var renderedPointCount = 0
private val spectrumBytes =
ByteBuffer.allocateDirect(AstraScopeProjection.SPECTRUM_BINS * Float.SIZE_BYTES)
.order(ByteOrder.nativeOrder())
private val spectrumFloats: FloatBuffer = spectrumBytes.asFloatBuffer()
private val oscilloscopeBytes =
ByteBuffer.allocateDirect(AstraScopeProjection.OSCILLOSCOPE_POINTS * Float.SIZE_BYTES)
.order(ByteOrder.nativeOrder())
private val oscilloscopeFloats: FloatBuffer = oscilloscopeBytes.asFloatBuffer()
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textureView = TextureView(context)
private val renderGate = ScopeRenderGate()
private var attached = false
private var windowVisible = false
@Volatile
private var surfaceAvailable = false
private var scheduledToken = 0
private var lastAnalysisAt = 0L
private var lastDrawAt = 0L
private var hasNewFrame = false
@Volatile
private var displayRefreshRate = 60f
private val renderRunnable = object : Runnable {
override fun run() {
val token = scheduledToken
if (!renderGate.isCurrent(token)) return
val now = SystemClock.uptimeMillis()
if (!requestedActive) {
renderDecayFrame(token)
return
}
val analysisCadence = AstraScopeProjection.cadenceMs(analysisFrameMs, displayRefreshRate)
val drawCadence = AstraScopeProjection.cadenceMs(frameMs, displayRefreshRate)
if (lastAnalysisAt == 0L || now - lastAnalysisAt >= analysisCadence) {
lastAnalysisAt = now
hasNewFrame = readScopeFrame()
}
if (hasNewFrame && (lastDrawAt == 0L || now - lastDrawAt >= drawCadence)) {
if (!renderGate.isCurrent(token)) return
preparePaths()
hasNewFrame = false
lastDrawAt = now
publishFrame()
}
val loopCadence = min(analysisCadence, drawCadence)
if (renderGate.isCurrent(token)) {
ScopeRenderDispatcher.handler.postDelayed(this, loopCadence)
}
}
}
init {
clipChildren = false
clipToPadding = false
textureView.isOpaque = false
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
surfaceAvailable = true
restartRendering()
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
configurePaints()
restartRendering()
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
surfaceAvailable = false
cancelRendering()
return true
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
}
addView(
textureView,
LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
configurePaints()
}
override fun hasOverlappingRendering(): Boolean = false
fun commitProps() {
configurePaints()
restartRendering()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
attached = true
windowVisible = windowVisibility == View.VISIBLE
restartRendering()
}
override fun onDetachedFromWindow() {
attached = false
cancelRendering()
super.onDetachedFromWindow()
}
override fun onWindowVisibilityChanged(visibility: Int) {
super.onWindowVisibilityChanged(visibility)
windowVisible = visibility == View.VISIBLE
if (attached) restartRendering()
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
configurePaints()
restartRendering()
}
private fun restartRendering() {
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
lastAnalysisAt = 0L
lastDrawAt = 0L
hasNewFrame = false
val eligible = attached && windowVisible && surfaceAvailable && width > 0 && height > 0
displayRefreshRate = display?.refreshRate ?: 60f
scheduledToken = renderGate.update(eligible)
if (eligible) ScopeRenderDispatcher.handler.post(renderRunnable)
}
private fun cancelRendering() {
scheduledToken = renderGate.update(false)
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
}
private fun readScopeFrame(): Boolean {
val staticSnapshot = staticValues
if (!requestedActive && staticSnapshot != null) {
val count = min(min(staticSnapshot.size, pointCount), renderedValues.size)
for (index in 0 until count) {
renderedValues[index] = AstraScopeProjection.clamp01(staticSnapshot[index])
}
renderedPointCount = count
return count >= 2
}
return when (mode) {
ScopeMode.SPECTRUM -> {
val count = if (source == ScopeSource.POST) {
ScopeBridge.nativeFillSpectrumPostEq(
spectrumBytes,
AstraScopeProjection.SPECTRUM_BINS,
smoothing
)
} else {
ScopeBridge.nativeFillSpectrum(
spectrumBytes,
AstraScopeProjection.SPECTRUM_BINS,
smoothing
)
}
if (count <= 0) {
false
} else {
renderedPointCount = pointCount.coerceIn(2, renderedValues.size)
AstraScopeProjection.writeSpectrum(
spectrumFloats,
count,
renderedValues,
renderedPointCount,
dbMin,
dbMax,
tiltDbPerOctave
)
true
}
}
ScopeMode.OSCILLOSCOPE -> {
val count = ScopeBridge.nativeFillOscilloscope(
oscilloscopeBytes,
AstraScopeProjection.OSCILLOSCOPE_POINTS
)
renderedPointCount = min(count, renderedValues.size)
if (renderedPointCount < 2) {
false
} else {
for (index in 0 until renderedPointCount) {
renderedValues[index] =
(oscilloscopeFloats.get(index) * gain).coerceIn(-1f, 1f)
}
true
}
}
}
}
private fun renderDecayFrame(token: Int) {
val staticSnapshot = staticValues
if (staticSnapshot != null && mode == ScopeMode.SPECTRUM) {
readScopeFrame()
preparePaths()
publishFrame()
return
}
val count = renderedPointCount
val peak = if (reducedMotion || count < 2) {
renderedValues.fill(0f)
0f
} else {
AstraScopeProjection.decay(renderedValues, count)
}
if (peak < AstraScopeProjection.REST_EPSILON) {
renderedValues.fill(0f, 0, count)
}
preparePaths()
publishFrame()
if (peak >= AstraScopeProjection.REST_EPSILON && renderGate.isCurrent(token)) {
val cadence = AstraScopeProjection.cadenceMs(frameMs, displayRefreshRate)
ScopeRenderDispatcher.handler.postDelayed(renderRunnable, cadence)
}
}
private fun preparePaths() {
val count = renderedPointCount
val canvasWidth = width.toFloat()
val canvasHeight = height.toFloat()
if (count < 2 || canvasWidth <= 0f || canvasHeight <= 0f) return
val back = 1 - frontPath
val line = linePaths[back]
val fill = fillPaths[back]
line.reset()
fill.reset()
val pad = lineWidthDp * density
val usableHeight = max(0f, canvasHeight - pad * 2f)
fun xAt(index: Int) = index.toFloat() / (count - 1).toFloat() * canvasWidth
fun yAt(index: Int): Float {
return if (mode == ScopeMode.SPECTRUM) {
pad + (1f - AstraScopeProjection.clamp01(renderedValues[index])) * usableHeight
} else {
canvasHeight * 0.5f - renderedValues[index] * max(0f, canvasHeight * 0.5f - pad)
}
}
line.moveTo(0f, yAt(0))
if (mode == ScopeMode.SPECTRUM) {
for (index in 1 until count) {
val previousX = xAt(index - 1)
val previousY = yAt(index - 1)
line.quadTo(
previousX,
previousY,
(previousX + xAt(index)) * 0.5f,
(previousY + yAt(index)) * 0.5f
)
}
line.lineTo(canvasWidth, yAt(count - 1))
fill.addPath(line)
fill.lineTo(canvasWidth, canvasHeight)
fill.lineTo(0f, canvasHeight)
fill.close()
} else {
for (index in 1 until count) line.lineTo(xAt(index), yAt(index))
}
synchronized(pathLock) {
frontPath = back
}
}
private fun configurePaints() {
synchronized(pathLock) {
val lineWidth = max(0.5f, lineWidthDp * density)
configureStrokePaint(strokePaint, lineWidth, lineOpacity)
configureStrokePaint(glowPaint, lineWidth * 3f, glowOpacity)
fillPaint.style = Paint.Style.FILL
fillPaint.shader = createFillShader()
fillPaint.color = withAlpha(scopeColor, 0.38f * fillOpacity)
}
}
/**
* SurfaceTexture publication does not invalidate the React/Android view tree.
* The serialized scope worker prepares and rasterizes this small transparent
* layer; the platform compositor presents it with the retained scene.
*/
private fun publishFrame() {
if (!surfaceAvailable) return
val canvas = textureView.lockCanvas() ?: return
try {
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
synchronized(pathLock) {
if (mode == ScopeMode.SPECTRUM && fillOpacity > 0f) {
canvas.drawPath(fillPaths[frontPath], fillPaint)
}
if (glow) canvas.drawPath(linePaths[frontPath], glowPaint)
canvas.drawPath(linePaths[frontPath], strokePaint)
}
} finally {
textureView.unlockCanvasAndPost(canvas)
}
}
private fun configureStrokePaint(paint: Paint, width: Float, opacity: Float) {
paint.style = Paint.Style.STROKE
paint.strokeCap = Paint.Cap.ROUND
paint.strokeJoin = Paint.Join.ROUND
paint.strokeWidth = width
paint.color = withAlpha(scopeColor, opacity)
paint.shader = if (edgeFade && this.width > 0 && edgeFadeWidthDp > 0f) {
val fade = min(edgeFadeWidthDp * density, this.width * 0.5f)
LinearGradient(
0f,
0f,
this.width.toFloat(),
0f,
intArrayOf(
withAlpha(scopeColor, 0f),
withAlpha(scopeColor, opacity),
withAlpha(scopeColor, opacity),
withAlpha(scopeColor, 0f)
),
floatArrayOf(0f, fade / this.width, 1f - fade / this.width, 1f),
Shader.TileMode.CLAMP
)
} else {
null
}
}
private fun createFillShader(): Shader? {
if (width <= 0 || height <= 0 || fillOpacity <= 0f) return null
val vertical = LinearGradient(
0f,
0f,
0f,
height.toFloat(),
intArrayOf(
withAlpha(scopeColor, 0.38f * fillOpacity),
withAlpha(scopeColor, 0.08f * fillOpacity),
withAlpha(scopeColor, 0f)
),
null,
Shader.TileMode.CLAMP
)
if (!edgeFade || edgeFadeWidthDp <= 0f) return vertical
val fade = min(edgeFadeWidthDp * density, width * 0.5f)
val mask = LinearGradient(
0f,
0f,
width.toFloat(),
0f,
intArrayOf(Color.TRANSPARENT, Color.WHITE, Color.WHITE, Color.TRANSPARENT),
floatArrayOf(0f, fade / width, 1f - fade / width, 1f),
Shader.TileMode.CLAMP
)
return ComposeShader(vertical, mask, PorterDuff.Mode.MULTIPLY)
}
private fun withAlpha(color: Int, opacity: Float): Int {
val baseAlpha = Color.alpha(color) / 255f
val alpha = (255f * baseAlpha * opacity.coerceIn(0f, 1f)).roundToInt()
return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
}
companion object {
private const val MAX_RENDER_POINTS = 512
}
}
@@ -0,0 +1,73 @@
package expo.modules.astrascope
import java.nio.ByteBuffer
import java.nio.ByteOrder
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AstraScopeProjectionTest {
@Test
fun projectionClampsDbValuesAndKeepsLogSpectrumShape() {
val rawBytes = ByteBuffer.allocateDirect(16 * Float.SIZE_BYTES).order(ByteOrder.nativeOrder())
val raw = rawBytes.asFloatBuffer()
for (index in 0 until 16) raw.put(index, -100f + index * 10f)
val out = FloatArray(8)
AstraScopeProjection.writeSpectrum(raw, 16, out, 8, -90f, -10f, 0f)
assertTrue(out.first() >= 0f)
assertTrue(out.last() <= 1f)
for (index in 1 until out.size) assertTrue(out[index] >= out[index - 1])
}
@Test
fun clampRejectsInvalidAndOutOfRangeValues() {
assertEquals(0f, AstraScopeProjection.clamp01(Float.NaN), 0f)
assertEquals(0f, AstraScopeProjection.clamp01(-2f), 0f)
assertEquals(1f, AstraScopeProjection.clamp01(3f), 0f)
}
@Test
fun decayMatchesLegacyPauseEnvelope() {
val values = floatArrayOf(-1f, 0.5f, 0.1f)
val peak = AstraScopeProjection.decay(values, values.size)
assertEquals(0.72f, peak, 0.0001f)
assertEquals(-0.72f, values[0], 0.0001f)
assertEquals(0.36f, values[1], 0.0001f)
}
@Test
fun cadenceUsesRequestedPolicyOrDisplayRefresh() {
assertEquals(32L, AstraScopeProjection.cadenceMs(32.0, 120f))
assertEquals(16L, AstraScopeProjection.cadenceMs(16.0, 120f))
assertEquals(8L, AstraScopeProjection.cadenceMs(0.0, 120f))
assertEquals(17L, AstraScopeProjection.cadenceMs(0.0, 60f))
}
@Test
fun sourceAndModeSelectionDefaultSafely() {
assertEquals(ScopeSource.POST, ScopeSource.from("post"))
assertEquals(ScopeSource.PRE, ScopeSource.from("unexpected"))
assertEquals(ScopeMode.OSCILLOSCOPE, ScopeMode.from("oscilloscope"))
assertEquals(ScopeMode.SPECTRUM, ScopeMode.from("unexpected"))
}
@Test
fun lifecycleGenerationCancelsDetachedOrSupersededWork() {
val gate = ScopeRenderGate()
val first = gate.update(true)
assertTrue(gate.isCurrent(first))
val second = gate.update(true)
assertFalse(gate.isCurrent(first))
assertTrue(gate.isCurrent(second))
gate.update(false)
assertFalse(gate.isCurrent(second))
assertFalse(gate.eligible)
}
}
+10 -4
View File
@@ -2,16 +2,16 @@
// Process-wide scope driver: a single-producer / single-consumer bridge between
// the ExoPlayer audio thread (which pushes PCM via the tap AudioProcessor) and
// the JS render thread (which pulls the latest spectrum frame once per frame).
// the serialized native scope worker (or a compatibility JS getter).
//
// Threading contract:
// - pushInterleaved() + configure() run on the AUDIO thread. They are
// allocation-free and lock-free: they only touch the ring (atomic write
// position) and an atomic pending-sample-rate. They NEVER touch the
// analyzer (no FFT on the audio callback).
// - fillSpectrum() runs on the single JS/render thread. It owns the analyzer
// and all consumer-only state. It snapshots the most recent fftSize mono
// samples from the ring and runs Visualizer::Spectrum::process there.
// - fill methods own analyzer/consumer state. A consumer mutex serializes the
// native renderer with legacy synchronous JS getters without ever touching
// the audio-thread producer path.
//
// The ring holds mono samples (the producer downmixes), sized well above the
// FFT window so a 60fps consumer never misses recent audio; on a snapshot we
@@ -27,6 +27,7 @@
#include <cmath>
#include <cstddef>
#include <cstring>
#include <mutex>
#include <vector>
namespace astra {
@@ -69,6 +70,7 @@ class ScopeDriver {
// samples, run the FFT, copy up to `cap` dB magnitudes into `out`.
// Returns the number of bins written.
size_t fillSpectrum(float* out, size_t cap, float smoothing) {
std::lock_guard<std::mutex> lock(consumerMutex_);
if (out == nullptr || cap == 0) {
return 0;
}
@@ -110,6 +112,7 @@ class ScopeDriver {
// pitch-locked trigger. We drain a bounded recent slice into its internal
// circular buffer, then return render-ready points from the triggered window.
size_t fillOscilloscope(float* out, size_t cap) {
std::lock_guard<std::mutex> lock(consumerMutex_);
if (out == nullptr || cap == 0) {
return 0;
}
@@ -212,6 +215,7 @@ class ScopeDriver {
// Render thread. Latest post-EQ spectrum window -> `out` (dB magnitudes).
size_t fillSpectrumPostEq(float* out, size_t cap, float smoothing) {
std::lock_guard<std::mutex> lock(consumerMutex_);
if (out == nullptr || cap == 0) {
return 0;
}
@@ -250,6 +254,7 @@ class ScopeDriver {
size_t binCount() const { return spectrum_.getFFTSize() / 2; }
void reset() {
std::lock_guard<std::mutex> lock(consumerMutex_);
spectrum_.reset();
postEqSpectrum_.reset();
osc_.reset();
@@ -339,6 +344,7 @@ class ScopeDriver {
std::atomic<int> pendingSampleRate_{44100};
// Consumer-only state.
std::mutex consumerMutex_;
std::vector<float> scratch_;
Visualizer::Spectrum spectrum_;
int appliedSampleRate_{0};
+35 -1
View File
@@ -1,4 +1,9 @@
import { requireNativeModule, type NativeModule } from 'expo-modules-core';
import {
requireNativeModule,
requireNativeViewManager,
type NativeModule,
} from 'expo-modules-core';
import type { ViewProps } from 'react-native';
/** Number of spectrum bins returned by getSpectrumFrame (fftSize/2, fftSize=2048). */
export const SPECTRUM_BINS = 1024;
@@ -70,3 +75,32 @@ declare class AstraScopeModuleType extends NativeModule {
}
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
/** Internal prop contract for the allocation-free Android scope surface. */
export interface AstraScopeViewProps extends ViewProps {
mode: 'spectrum' | 'oscilloscope';
source?: 'pre' | 'post';
active: boolean;
reducedMotion?: boolean;
frameMs: number;
analysisFrameMs?: number;
smoothing?: number;
pointCount?: number;
dbMin?: number;
dbMax?: number;
tiltDbPerOctave?: number;
/** Android ARGB color produced by React Native's processColor(). */
color: number;
lineWidth?: number;
lineOpacity?: number;
fillOpacity?: number;
glow?: boolean;
glowOpacity?: number;
edgeFade?: boolean;
edgeFadeWidth?: number;
gain?: number;
values?: number[];
}
export const AstraScopeView =
requireNativeViewManager<AstraScopeViewProps>('AstraScope');
+3 -3
View File
@@ -64,7 +64,7 @@
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts",
"test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts",
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts",
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
@@ -75,14 +75,14 @@
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts",
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/components/waveformScrubDetents.test.mts",
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts",
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts",
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/tabTransition.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
+4 -3
View File
@@ -2,6 +2,7 @@ import { useMemo, useRef } from 'react';
import { Tabs } from 'expo-router';
import { TabBar, type TabItem } from '@/components/TabBar';
import {
TAB_SCENE_ANIMATION,
TAB_TRANSITION_SETTLE_MS,
TAB_TRANSITION_SPEC,
} from '@/navigation/tabTransition';
@@ -19,8 +20,8 @@ export default function TabsLayout() {
headerShown: false,
freezeOnBlur: false,
sceneStyle: { backgroundColor: colors.bgPrimary },
// Directional slide + cross-fade between tabs, following tab order.
animation: 'shift' as const,
// Retained scenes cross-fade without translating two full pages.
animation: TAB_SCENE_ANIMATION,
transitionSpec: TAB_TRANSITION_SPEC,
}),
[colors.bgPrimary]
@@ -37,7 +38,7 @@ export default function TabsLayout() {
}));
const handlePress = (item: TabItem) => {
// Interrupting the native-driver shift animation can drop its
// Interrupting the native-driver scene animation can drop its
// completion frame and leave the incoming scene invisible; swallow
// taps until the current transition has finished.
const now = Date.now();
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clampPlaybackFraction,
reconcilePlaybackProgress,
} from './playbackProgressProjection.ts';
test('playing snapshots animate linearly from the reconciled position', () => {
const command = reconcilePlaybackProgress({
currentTime: 25,
duration: 100,
isPlaying: true,
active: true,
trackKey: 'a',
});
assert.deepEqual(command, {
fraction: 0.25,
animate: true,
animationDurationMs: 75_000,
trackChanged: false,
});
});
test('pause snaps immediately and schedules no projection', () => {
const command = reconcilePlaybackProgress({
currentTime: 25,
duration: 100,
isPlaying: false,
active: true,
});
assert.equal(command.fraction, 0.25);
assert.equal(command.animate, false);
assert.equal(command.animationDurationMs, 0);
});
test('scrub and pending-seek overrides take precedence over stale live time', () => {
const command = reconcilePlaybackProgress({
currentTime: 10,
duration: 100,
isPlaying: true,
active: true,
overrideFraction: 0.8,
});
assert.equal(command.fraction, 0.8);
assert.equal(command.animate, false);
});
test('duration changes reconcile the fraction and remaining animation time', () => {
const command = reconcilePlaybackProgress({
currentTime: 50,
duration: 200,
isPlaying: true,
active: true,
});
assert.equal(command.fraction, 0.25);
assert.equal(command.animationDurationMs, 150_000);
});
test('track changes are identified and snap to the new track snapshot', () => {
const command = reconcilePlaybackProgress(
{
currentTime: 2,
duration: 80,
isPlaying: true,
active: true,
trackKey: 'new',
},
'old'
);
assert.equal(command.trackChanged, true);
assert.equal(command.fraction, 0.025);
});
test('hidden surfaces and invalid values are pinned without animation', () => {
const command = reconcilePlaybackProgress({
currentTime: 10,
duration: 100,
isPlaying: true,
active: false,
});
assert.equal(command.animate, false);
assert.equal(clampPlaybackFraction(Number.NaN), 0);
assert.equal(clampPlaybackFraction(2), 1);
});
+60
View File
@@ -0,0 +1,60 @@
export interface PlaybackProgressSnapshot {
currentTime: number;
duration: number;
isPlaying: boolean;
active: boolean;
trackKey?: string | number | null;
overrideFraction?: number | null;
}
export interface PlaybackProgressReconciliation {
fraction: number;
animate: boolean;
animationDurationMs: number;
trackChanged: boolean;
}
export function clampPlaybackFraction(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0;
if (value >= 1) return 1;
return value;
}
/**
* Turns an RNTP snapshot into one UI-thread command. Seek/scrub overrides win,
* pauses and hidden surfaces snap, and active playback runs linearly to the end
* until the next authoritative snapshot reconciles it.
*/
export function reconcilePlaybackProgress(
snapshot: PlaybackProgressSnapshot,
previousTrackKey?: string | number | null
): PlaybackProgressReconciliation {
const duration = Number.isFinite(snapshot.duration) ? Math.max(0, snapshot.duration) : 0;
const currentTime = Number.isFinite(snapshot.currentTime)
? Math.max(0, snapshot.currentTime)
: 0;
const liveFraction = duration > 0
? clampPlaybackFraction(currentTime / duration)
: 0;
const hasOverride = snapshot.overrideFraction != null;
const fraction = hasOverride
? clampPlaybackFraction(snapshot.overrideFraction as number)
: liveFraction;
const trackChanged =
previousTrackKey !== undefined && previousTrackKey !== snapshot.trackKey;
const animate =
!hasOverride &&
snapshot.active &&
snapshot.isPlaying &&
duration > 0 &&
fraction < 1;
return {
fraction,
animate,
animationDurationMs: animate
? Math.max(0, Math.round((duration - currentTime) * 1_000))
: 0,
trackChanged,
};
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react';
import {
Easing,
ReduceMotion,
cancelAnimation,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import {
reconcilePlaybackProgress,
type PlaybackProgressSnapshot,
} from './playbackProgressProjection';
/**
* Reconciles coarse RNTP snapshots while projecting the visible progress
* continuously on Reanimated's UI thread.
*/
export function useAnimatedPlaybackProgress(
snapshot: PlaybackProgressSnapshot
): SharedValue<number> {
const {
active,
currentTime,
duration,
isPlaying,
overrideFraction,
trackKey,
} = snapshot;
const initial = reconcilePlaybackProgress(snapshot);
const progress = useSharedValue(initial.fraction);
const previousTrackKey = useRef(trackKey);
useEffect(() => {
const command = reconcilePlaybackProgress(
{
active,
currentTime,
duration,
isPlaying,
overrideFraction,
trackKey,
},
previousTrackKey.current
);
previousTrackKey.current = trackKey;
cancelAnimation(progress);
progress.value = command.fraction;
if (command.animate) {
progress.value = withTiming(1, {
duration: command.animationDurationMs,
easing: Easing.linear,
reduceMotion: ReduceMotion.Never,
});
}
}, [
progress,
active,
currentTime,
duration,
isPlaying,
overrideFraction,
trackKey,
]);
useEffect(() => () => cancelAnimation(progress), [progress]);
return progress;
}
+35 -7
View File
@@ -33,7 +33,7 @@ import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
import { useScopeActive } from '@/scope/scopeStore';
import { artworkThumbFromSource } from '@/library/artwork';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress';
import { useAppForeground } from '@/lib/useAppForeground';
import { playHaptic } from '@/lib/haptics';
import { PlaybackTargetPicker } from './PlaybackTargetPicker';
@@ -87,26 +87,47 @@ function MiniProgress({
currentTime,
duration,
isPlaying,
active,
trackKey,
}: {
currentTime: number;
duration: number;
isPlaying: boolean;
active: boolean;
trackKey: string | null;
}) {
const styles = useStyles();
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
const progress = duration > 0 ? Math.min(1, smoothTime / duration) : 0;
const progress = useAnimatedPlaybackProgress({
currentTime,
duration,
isPlaying,
active,
trackKey,
});
const progressStyle = useAnimatedStyle(() => ({
transform: [{ scaleX: progress.value }],
}));
return (
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
<Animated.View style={[styles.progressFill, progressStyle]} />
</View>
);
}
/** Phone-target progress: subscribes here so the 2Hz tick skips the whole pill. */
function PhoneMiniProgress({ isPlaying }: { isPlaying: boolean }) {
function PhoneMiniProgress({ isPlaying, active }: { isPlaying: boolean; active: boolean }) {
const currentTime = usePlayerStore((s) => s.currentTime);
const duration = usePlayerStore((s) => s.duration);
return <MiniProgress currentTime={currentTime} duration={duration} isPlaying={isPlaying} />;
const trackKey = usePlayerStore((s) => s.currentTrack?.path ?? null);
return (
<MiniProgress
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
active={active}
trackKey={trackKey}
/>
);
}
/**
@@ -520,9 +541,11 @@ export function MiniPlayer() {
currentTime={presentation.currentTime}
duration={presentation.duration}
isPlaying={isPlaying}
active={!playerOpen}
trackKey={presentation.trackKey}
/>
) : (
<PhoneMiniProgress isPlaying={isPlaying} />
<PhoneMiniProgress isPlaying={isPlaying} active={!playerOpen} />
)
) : null}
</Pressable>
@@ -632,6 +655,11 @@ const useStyles = createThemedStyles((colors) => ({
backgroundColor: colors.glassBorder,
},
progressFill: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
transformOrigin: 'left center',
height: 2,
backgroundColor: colors.accent,
},
+31 -326
View File
@@ -1,23 +1,7 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useRef
} from 'react';
import { processColor } from 'react-native';
import { useReducedMotion } from 'react-native-reanimated';
import {
PaintStyle,
Skia,
SkiaPictureView,
StrokeCap,
StrokeJoin,
TileMode,
type SkPath,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
import { AstraScopeView } from '../../modules/astra-scope';
import { useScopeStore } from '@/scope/scopeStore';
import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
import { useColors } from '@/theme/themed';
interface OscilloscopeWaveProps {
@@ -33,157 +17,11 @@ interface OscilloscopeWaveProps {
edgeFadeWidth?: number;
}
type SkiaViewApiShape = {
setJsiProperty: <T>(nativeId: number, name: string, value: T) => void;
requestRedraw: (nativeId: number) => void;
};
const values = new Float32Array(OSCILLOSCOPE_POINTS);
// Deactivation decay: pull the last live frame toward the rest line over
// ~250ms instead of snapping flat, so pausing reads as powering down.
const DECAY_PER_FRAME = 0.72;
const REST_EPSILON = 0.004;
type SkiaDisposable = { dispose: () => void };
function disposeSkiaResources(resources: readonly (SkiaDisposable | null)[]) {
for (let i = resources.length - 1; i >= 0; i--) resources[i]?.dispose();
}
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;
}
const EDGE_FADE_WIDTH = 28;
/**
* Edge fade baked into the stroke paint: a horizontal gradient shader whose
* alpha ramps in from transparent at both ends, so the trace dissolves at its
* edges over any background — solid screen or blurred artwork.
*/
function makeFadedStrokeShader(
color: string,
alpha: number,
width: number,
fadeWidth: number
) {
const f = Math.min(fadeWidth, width * 0.5);
return Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: width, y: 0 },
[
Skia.Color(withAlpha(color, 0)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, 0)),
],
[0, f / width, 1 - f / width, 1],
TileMode.Clamp
);
}
function writeWavePath(
samples: Float32Array,
sampleCount: number,
width: number,
height: number,
lineWidth: number,
gain: number,
path: SkPath
) {
path.reset();
const n = Math.min(sampleCount, samples.length);
if (n < 2 || width <= 0 || height <= 0) return;
const mid = height / 2;
const amp = mid - lineWidth;
const xAt = (i: number) => (i / (n - 1)) * width;
const yAt = (i: number) => {
let v = samples[i] * gain;
// Per-track gain targets ~85% of full scale, so this only catches the rare
// intra-track peak that runs a touch hotter than the analyzed sample peak.
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));
}
}
function buildPicture(
samples: Float32Array,
sampleCount: number,
width: number,
height: number,
color: string,
lineWidth: number,
glow: boolean,
gain: number,
edgeFade: boolean,
edgeFadeWidth: number
): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, width, height));
const path = Skia.Path.Make();
const resources: SkiaDisposable[] = [recorder, path];
writeWavePath(samples, sampleCount, width, height, lineWidth, gain, path);
try {
if (glow) {
const glowPaint = makeStrokePaint(color, lineWidth * 3, 0.18);
resources.push(glowPaint);
if (edgeFade) {
const glowShader = makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth);
resources.push(glowShader);
glowPaint.setShader(glowShader);
}
canvas.drawPath(path, glowPaint);
}
const strokePaint = makeStrokePaint(color, lineWidth);
resources.push(strokePaint);
if (edgeFade) {
const strokeShader = makeFadedStrokeShader(color, 1, width, edgeFadeWidth);
resources.push(strokeShader);
strokePaint.setShader(strokeShader);
}
canvas.drawPath(path, strokePaint);
return recorder.finishRecordingAsPicture();
} finally {
disposeSkiaResources(resources);
}
}
/**
* 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.
*
* Amplitude uses a per-track display gain (scopeStore.oscGain, set once per track by
* useNormalizationSync) — read fresh each frame so it tracks song changes, but held
* constant within a track so the music's own dynamics are preserved.
* Thin React wrapper for the native oscilloscope. Gain changes happen only at
* track boundaries; audio frames and drawing never cross React or the JS thread.
*/
export function OscilloscopeWave({
active,
@@ -196,167 +34,34 @@ export function OscilloscopeWave({
edgeFade = false,
edgeFadeWidth = EDGE_FADE_WIDTH,
}: OscilloscopeWaveProps) {
const themeColors = useColors();
const color = colorProp ?? themeColors.accent;
const reduceMotion = useReducedMotion();
const viewRef = useRef<SkiaPictureView | null>(null);
const initialPicture = useMemo(
() =>
buildPicture(
values,
values.length,
Math.max(1, width),
Math.max(1, height),
color,
lineWidth,
glow,
DEFAULT_OSC_GAIN,
edgeFade,
edgeFadeWidth
),
[color, edgeFade, edgeFadeWidth, glow, height, lineWidth, width]
const colors = useColors();
const reducedMotion = useReducedMotion();
const gain = useScopeStore((state) => state.oscGain);
const color = processColor(colorProp ?? colors.accent);
if (width <= 0 || height <= 0 || typeof color !== 'number') return null;
return (
<AstraScopeView
mode="oscilloscope"
source="pre"
active={active && !reducedMotion}
reducedMotion={reducedMotion}
frameMs={frameMs}
analysisFrameMs={frameMs}
color={color}
lineWidth={lineWidth}
lineOpacity={1}
fillOpacity={0}
glow={glow}
glowOpacity={0.18}
edgeFade={edgeFade}
edgeFadeWidth={edgeFadeWidth}
gain={gain}
pointerEvents="none"
collapsable={false}
style={{ width, height }}
/>
);
useEffect(() => () => initialPicture.dispose(), [initialPicture]);
useLayoutEffect(
() => () => {
const view = viewRef.current;
const api = skiaViewApi();
if (!view || !api) return;
api.setJsiProperty(view.nativeId, 'picture', null);
api.requestRedraw(view.nativeId);
},
[]
);
useLayoutEffect(() => {
const view = viewRef.current;
const api = skiaViewApi();
if (!view || !api || width <= 0 || height <= 0) return;
let mounted = true;
let raf = 0;
let lastDraw = 0;
const drawThreshold = frameMs > 0 ? Math.max(0, frameMs - 0.5) : 0;
// Paints and the path live for the whole effect run; per-frame allocation
// was measurable GC/JSI churn at 60fps.
const strokePaint = makeStrokePaint(color, lineWidth);
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, 0.18) : null;
const effectResources: SkiaDisposable[] = [strokePaint];
if (glowPaint) effectResources.push(glowPaint);
if (edgeFade) {
const strokeShader = makeFadedStrokeShader(color, 1, width, edgeFadeWidth);
effectResources.push(strokeShader);
strokePaint.setShader(strokeShader);
if (glowPaint) {
const glowShader = makeFadedStrokeShader(color, 0.18, width, edgeFadeWidth);
effectResources.push(glowShader);
glowPaint.setShader(glowShader);
}
}
const bounds = Skia.XYWHRect(0, 0, width, height);
const path = Skia.Path.Make();
effectResources.push(path);
let currentPicture: SkPicture | null = null;
const draw = (sampleCount: number) => {
const gain = useScopeStore.getState().oscGain;
writeWavePath(values, sampleCount, width, height, lineWidth, gain, path);
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(bounds);
if (glowPaint) canvas.drawPath(path, glowPaint);
canvas.drawPath(path, strokePaint);
const nextPicture = recorder.finishRecordingAsPicture();
recorder.dispose();
api.setJsiProperty(view.nativeId, 'picture', nextPicture);
api.requestRedraw(view.nativeId);
currentPicture?.dispose();
currentPicture = nextPicture;
};
const cleanup = () => {
mounted = false;
cancelAnimationFrame(raf);
api.setJsiProperty(view.nativeId, 'picture', null);
api.requestRedraw(view.nativeId);
currentPicture?.dispose();
currentPicture = null;
disposeSkiaResources(effectResources);
};
if (!active) {
// Deactivation (pause, occlusion): decay whatever the tap last wrote
// toward the rest line, then settle flat and schedule nothing.
let peak = 0;
for (let i = 0; i < values.length; i++) {
const a = Math.abs(values[i]);
if (a > peak) peak = a;
}
if (reduceMotion || peak < REST_EPSILON) {
values.fill(0);
draw(values.length);
return cleanup;
}
const decayTick = (t: number) => {
if (!mounted) return;
if (drawThreshold > 0 && t - lastDraw < drawThreshold) {
raf = requestAnimationFrame(decayTick);
return;
}
lastDraw = t;
let max = 0;
for (let i = 0; i < values.length; i++) {
const v = values[i] * DECAY_PER_FRAME;
values[i] = v;
const a = Math.abs(v);
if (a > max) max = a;
}
if (max < REST_EPSILON) {
values.fill(0);
draw(values.length);
return;
}
draw(values.length);
raf = requestAnimationFrame(decayTick);
};
raf = requestAnimationFrame(decayTick);
return cleanup;
}
values.fill(0);
draw(values.length);
const tick = (t: number) => {
if (!mounted) return;
raf = requestAnimationFrame(tick);
if (drawThreshold > 0 && t - lastDraw < drawThreshold) return;
const n = AstraScope.getOscilloscopeFrame(values);
if (n > 0) {
lastDraw = t;
draw(n);
}
};
raf = requestAnimationFrame(tick);
return cleanup;
}, [
active,
color,
edgeFade,
edgeFadeWidth,
frameMs,
glow,
height,
lineWidth,
reduceMotion,
width,
]);
if (width <= 0 || height <= 0) return null;
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
}
export default OscilloscopeWave;
+44 -550
View File
@@ -1,22 +1,7 @@
import {
useEffect,
useLayoutEffect,
useMemo,
useRef
} from 'react';
import { useMemo } from 'react';
import { processColor } from 'react-native';
import { useReducedMotion } from 'react-native-reanimated';
import {
BlendMode,
PaintStyle,
Skia,
SkiaPictureView,
StrokeCap,
StrokeJoin,
TileMode,
type SkPath,
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
import { AstraScopeView } from '../../modules/astra-scope';
import { useColors } from '@/theme/themed';
interface SpectrumCurveProps {
@@ -28,11 +13,11 @@ interface SpectrumCurveProps {
active?: boolean;
/** Which native tap to pull from. 'post' is the post-EQ ring (EQ screen). */
source?: 'pre' | 'post';
/** Number of render points when active. Defaults to one point per rendered pixel. */
/** Number of log-frequency render points. */
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. */
/** Native analysis cadence. Defaults to frameMs. */
analysisFrameMs?: number;
/** Previous native spectrum-frame retention in [0, 0.99]. */
smoothing?: number;
@@ -49,326 +34,16 @@ interface SpectrumCurveProps {
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 DEFAULT_SMOOTHING = 0.92;
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;
// Deactivation decay: let the last live curve fall to the floor over ~250ms
// instead of freezing mid-song, so pausing reads as powering down.
const DECAY_PER_FRAME = 0.72;
const REST_EPSILON = 0.004;
const spectrumBins = new Float32Array(SPECTRUM_BINS);
type SkiaDisposable = { dispose: () => void };
function disposeSkiaResources(resources: readonly (SkiaDisposable | null)[]) {
for (let i = resources.length - 1; i >= 0; i--) resources[i]?.dispose();
}
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);
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;
}
const TILT_DB_PER_OCTAVE = 3.5;
/**
* Edge fade baked into the paints: a horizontal alpha ramp so the curve
* dissolves at its ends over any background — solid screen or blurred artwork.
*/
function makeFadedStrokeShader(
color: string,
alpha: number,
width: number,
fadeWidth: number
) {
const f = Math.min(fadeWidth, width * 0.5);
return Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: width, y: 0 },
[
Skia.Color(withAlpha(color, 0)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, alpha)),
Skia.Color(withAlpha(color, 0)),
],
[0, f / width, 1 - f / width, 1],
TileMode.Clamp
);
}
/** White-with-alpha horizontal ramp; Modulate-blending it onto another shader
* multiplies alphas while leaving color untouched. */
function makeFadeMaskShader(width: number, fadeWidth: number) {
const f = Math.min(fadeWidth, width * 0.5);
return Skia.Shader.MakeLinearGradient(
{ x: 0, y: 0 },
{ x: width, y: 0 },
[
Skia.Color('rgba(255, 255, 255, 0)'),
Skia.Color('rgba(255, 255, 255, 1)'),
Skia.Color('rgba(255, 255, 255, 1)'),
Skia.Color('rgba(255, 255, 255, 0)'),
],
[0, f / width, 1 - f / width, 1],
TileMode.Clamp
);
}
function makeFillPaint(
color: string,
height: number,
opacity: number,
fade: { width: number; fadeWidth: number } | null = null
) {
const paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setStyle(PaintStyle.Fill);
const vertical = 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
);
const shaders: SkiaDisposable[] = [vertical];
if (fade) {
const mask = makeFadeMaskShader(fade.width, fade.fadeWidth);
const blended = Skia.Shader.MakeBlend(BlendMode.Modulate, vertical, mask);
shaders.push(mask, blended);
paint.setShader(blended);
} else {
paint.setShader(vertical);
}
return { paint, shaders };
}
function writePaths(
values: ArrayLike<number>,
width: number,
height: number,
pad: number,
line: SkPath,
fill: SkPath
) {
line.reset();
fill.reset();
const n = values.length;
if (n < 2 || width <= 0 || height <= 0) return;
const usableH = height - pad * 2;
const xAt = (i: number) => (i / (n - 1)) * width;
const yAt = (i: number) => {
const v = values[i] < 0 ? 0 : values[i] > 1 ? 1 : values[i];
return pad + (1 - v) * usableH;
};
line.moveTo(xAt(0), yAt(0));
for (let i = 1; i < n; i++) {
const midX = (xAt(i - 1) + xAt(i)) * 0.5;
const midY = (yAt(i - 1) + yAt(i)) * 0.5;
line.quadTo(xAt(i - 1), yAt(i - 1), midX, midY);
}
line.lineTo(xAt(n - 1), yAt(n - 1));
fill.addPath(line);
fill.lineTo(width, height);
fill.lineTo(0, height);
fill.close();
}
function buildPaths(values: ArrayLike<number>, width: number, height: number, pad: number) {
const line = Skia.Path.Make();
const fill = Skia.Path.Make();
writePaths(values, width, height, pad, line, fill);
return { line, fill };
}
function buildPicture(
values: ArrayLike<number>,
width: number,
height: number,
color: string,
lineWidth: number,
lineOpacity: number,
fillOpacity: number,
glow: boolean,
glowOpacity: number,
edgeFade: boolean,
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);
const resources: SkiaDisposable[] = [recorder, line, fill];
try {
if (values.length >= 2 && width > 0 && height > 0) {
const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null;
const fillResources = makeFillPaint(color, height, fillOpacity, fade);
resources.push(fillResources.paint, ...fillResources.shaders);
canvas.drawPath(fill, fillResources.paint);
if (glow) {
const glowPaint = makeStrokePaint(color, lineWidth * 3, glowOpacity);
resources.push(glowPaint);
if (fade) {
const glowShader = makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth);
resources.push(glowShader);
glowPaint.setShader(glowShader);
}
canvas.drawPath(line, glowPaint);
}
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
resources.push(strokePaint);
if (fade) {
const strokeShader = makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth);
resources.push(strokeShader);
strokePaint.setShader(strokeShader);
}
canvas.drawPath(line, strokePaint);
}
return recorder.finishRecordingAsPicture();
} finally {
disposeSkiaResources(resources);
}
}
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. When `active` is true this mirrors the oscilloscope hot
* path: a frame loop pulls native data and updates the Skia view imperatively.
* Thin React wrapper. FFT projection, pause decay, path preparation, and frame
* scheduling all live in AstraScopeView's serialized Android worker.
*/
export function SpectrumCurve({
values,
@@ -382,7 +57,7 @@ export function SpectrumCurve({
smoothing = DEFAULT_SMOOTHING,
dbMin = DISPLAY_DB_MIN,
dbMax = DISPLAY_DB_MAX,
tiltDbPerOctave = TILT_DB_PER_OCT,
tiltDbPerOctave = TILT_DB_PER_OCTAVE,
color: colorProp,
lineWidth = 2,
lineOpacity = 1,
@@ -392,226 +67,45 @@ export function SpectrumCurve({
edgeFade = false,
edgeFadeWidth = 28,
}: SpectrumCurveProps) {
const themeColors = useColors();
const color = colorProp ?? themeColors.accent;
const reduceMotion = useReducedMotion();
const viewRef = useRef<SkiaPictureView | null>(null);
// Last live curve, kept across effect re-runs so deactivation can decay it
// to the floor instead of freezing the final frame.
const lastLiveValuesRef = useRef<Float32Array | null>(null);
// Half a point per pixel, capped: the quadTo midpoint smoothing makes denser
// sampling visually indistinguishable while doubling per-frame path cost.
const colors = useColors();
const reducedMotion = useReducedMotion();
const activePointCount = Math.min(160, Math.max(96, Math.floor(width / 2)));
const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
const resolvedPointCount =
pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
const staticValues = useMemo(
() => values ?? new Float32Array(resolvedPointCount),
[resolvedPointCount, values]
() => (values ? Array.from(values) : undefined),
[values]
);
const initialPicture = useMemo(
() =>
buildPicture(
staticValues,
Math.max(1, width),
Math.max(1, height),
color,
lineWidth,
lineOpacity,
fillOpacity,
glow,
glowOpacity,
edgeFade,
edgeFadeWidth
),
[
color,
edgeFade,
edgeFadeWidth,
fillOpacity,
glow,
glowOpacity,
height,
lineOpacity,
lineWidth,
staticValues,
width,
]
const color = processColor(colorProp ?? colors.accent);
if (width <= 0 || height <= 0 || typeof color !== 'number') return null;
return (
<AstraScopeView
mode="spectrum"
source={source}
active={active && !reducedMotion}
reducedMotion={reducedMotion}
frameMs={frameMs}
analysisFrameMs={analysisFrameMs ?? frameMs}
smoothing={smoothing}
pointCount={resolvedPointCount}
dbMin={dbMin}
dbMax={dbMax}
tiltDbPerOctave={tiltDbPerOctave}
color={color}
lineWidth={lineWidth}
lineOpacity={lineOpacity}
fillOpacity={fillOpacity}
glow={glow}
glowOpacity={glowOpacity}
edgeFade={edgeFade}
edgeFadeWidth={edgeFadeWidth}
values={staticValues}
pointerEvents="none"
collapsable={false}
style={{ width, height }}
/>
);
useEffect(() => () => initialPicture.dispose(), [initialPicture]);
useLayoutEffect(
() => () => {
const view = viewRef.current;
const api = skiaViewApi();
if (!view || !api) return;
api.setJsiProperty(view.nativeId, 'picture', null);
api.requestRedraw(view.nativeId);
},
[]
);
useLayoutEffect(() => {
const view = viewRef.current;
const api = skiaViewApi();
if (!view || !api || width <= 0 || height <= 0 || resolvedPointCount < 2) return;
const priorLive = lastLiveValuesRef.current;
// Static usage (values prop, never went live): leave the initial picture.
if (!active && !priorLive) 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 =
!active && priorLive && priorLive.length === resolvedPointCount
? priorLive
: new Float32Array(resolvedPointCount);
const pointOptions = { dbMin, dbMax, tiltDbPerOctave };
// Paints, shaders, and paths live for the whole effect run: allocating them
// (and the gradient shaders) per frame was measurable GC/JSI churn at 60fps.
const fade = edgeFade && edgeFadeWidth > 0 ? { width, fadeWidth: edgeFadeWidth } : null;
const strokePaint = makeStrokePaint(color, lineWidth, lineOpacity);
const glowPaint = glow ? makeStrokePaint(color, lineWidth * 3, glowOpacity) : null;
const effectResources: SkiaDisposable[] = [strokePaint];
if (glowPaint) effectResources.push(glowPaint);
if (fade) {
const strokeShader = makeFadedStrokeShader(color, lineOpacity, width, edgeFadeWidth);
effectResources.push(strokeShader);
strokePaint.setShader(strokeShader);
if (glowPaint) {
const glowShader = makeFadedStrokeShader(color, glowOpacity, width, edgeFadeWidth);
effectResources.push(glowShader);
glowPaint.setShader(glowShader);
}
}
const fillResources = makeFillPaint(color, height, fillOpacity, fade);
const fillPaint = fillResources.paint;
effectResources.push(fillPaint, ...fillResources.shaders);
const bounds = Skia.XYWHRect(0, 0, width, height);
const linePath = Skia.Path.Make();
const fillPath = Skia.Path.Make();
effectResources.push(linePath, fillPath);
let currentPicture: SkPicture | null = null;
const draw = () => {
writePaths(renderValues, width, height, lineWidth, linePath, fillPath);
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(bounds);
canvas.drawPath(fillPath, fillPaint);
if (glowPaint) canvas.drawPath(linePath, glowPaint);
canvas.drawPath(linePath, strokePaint);
const nextPicture = recorder.finishRecordingAsPicture();
recorder.dispose();
api.setJsiProperty(view.nativeId, 'picture', nextPicture);
api.requestRedraw(view.nativeId);
currentPicture?.dispose();
currentPicture = nextPicture;
};
const cleanup = () => {
mounted = false;
cancelAnimationFrame(raf);
api.setJsiProperty(view.nativeId, 'picture', null);
api.requestRedraw(view.nativeId);
currentPicture?.dispose();
currentPicture = null;
disposeSkiaResources(effectResources);
};
if (!active) {
// Deactivation: decay the last live curve to the floor, then rest.
lastLiveValuesRef.current = null;
let peak = 0;
for (let i = 0; i < renderValues.length; i++) {
if (renderValues[i] > peak) peak = renderValues[i];
}
if (reduceMotion || peak < REST_EPSILON) {
renderValues.fill(0);
draw();
return cleanup;
}
const decayTick = (t: number) => {
if (!mounted) return;
if (drawThreshold > 0 && t - lastDraw < drawThreshold) {
raf = requestAnimationFrame(decayTick);
return;
}
lastDraw = t;
let max = 0;
for (let i = 0; i < renderValues.length; i++) {
const v = renderValues[i] * DECAY_PER_FRAME;
renderValues[i] = v;
if (v > max) max = v;
}
if (max < REST_EPSILON) {
renderValues.fill(0);
draw();
return;
}
draw();
raf = requestAnimationFrame(decayTick);
};
raf = requestAnimationFrame(decayTick);
return cleanup;
}
lastLiveValuesRef.current = renderValues;
renderValues.fill(0);
draw();
const tick = (t: number) => {
if (!mounted) return;
raf = requestAnimationFrame(tick);
if (analysisThreshold <= 0 || t - lastAnalysis >= analysisThreshold) {
lastAnalysis = t;
const got =
source === 'post'
? AstraScope.getSpectrumFramePostEq(spectrumBins, smoothing)
: AstraScope.getSpectrumFrame(spectrumBins, smoothing);
if (got > 0) {
writeSpectrumPoints(spectrumBins, renderValues, pointOptions);
hasNewFrame = true;
}
}
if (!hasNewFrame || (drawThreshold > 0 && t - lastDraw < drawThreshold)) return;
lastDraw = t;
hasNewFrame = false;
draw();
};
raf = requestAnimationFrame(tick);
return cleanup;
}, [
active,
analysisFrameMs,
color,
dbMax,
dbMin,
edgeFade,
edgeFadeWidth,
fillOpacity,
frameMs,
glow,
glowOpacity,
height,
lineOpacity,
lineWidth,
reduceMotion,
resolvedPointCount,
source,
smoothing,
tiltDbPerOctave,
width,
]);
if (width <= 0 || height <= 0) return null;
return <SkiaPictureView ref={viewRef} picture={initialPicture} style={{ width, height }} />;
}
export default SpectrumCurve;
+48 -33
View File
@@ -1,8 +1,10 @@
import { useState, type ReactNode } from 'react';
import {
useCallback,
type ReactNode
} from 'react';
import {
StyleSheet,
View,
type LayoutChangeEvent
View
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
@@ -24,8 +26,15 @@ type IconName = keyof typeof Ionicons.glyphMap;
const SWIPE_ACTIVE_OFFSET_X = 10;
// Scroll-slop-sized: at 30 every vertical drag starting on a row had to travel
// 30px before the pan failed and the surrounding scrollable could win.
const SWIPE_FAIL_OFFSET_Y = 12;
// 30px before the pan failed and the surrounding scrollable could win. Keep
// this tighter than the horizontal activation threshold so vertical intent
// yields immediately, especially inside the queue's BottomSheet scrollable.
const SWIPE_FAIL_OFFSET_Y = 6;
// A fixed reveal distance avoids an onLayout -> setState -> gesture rebuild for
// every recycled list row. It is also more predictable on wide tablet rows than
// using half of the full row width.
const SWIPE_MAX_TRANSLATION = 168;
const SWIPE_ARM_TRANSLATION = 84;
export interface SwipeAction {
icon: IconName;
@@ -67,32 +76,32 @@ export function SwipeableRow({
const colors = useColors();
const tx = useSharedValue(0);
const armed = useSharedValue(false);
const [rowWidth, setRowWidth] = useState(0);
const max = rowWidth / 2;
const arm = rowWidth / 4;
const hasRight = !!swipeRight;
const hasLeft = !!swipeLeft;
const rightCommit = swipeRight?.onCommit;
const leftCommit = swipeLeft?.onCommit;
const onLayout = (e: LayoutChangeEvent) => setRowWidth(e.nativeEvent.layout.width);
const onCommit = (direction: 'right' | 'left') => {
if (direction === 'right') swipeRight?.onCommit();
else swipeLeft?.onCommit();
playHaptic('confirm');
};
const onCommit = useCallback(
(direction: 'right' | 'left') => {
if (direction === 'right') rightCommit?.();
else leftCommit?.();
playHaptic('confirm');
},
[leftCommit, rightCommit]
);
const pan = Gesture.Pan()
.enabled(enabled && rowWidth > 0 && (hasRight || hasLeft))
.enabled(enabled && (hasRight || hasLeft))
.activeOffsetX([-SWIPE_ACTIVE_OFFSET_X, SWIPE_ACTIVE_OFFSET_X])
.failOffsetY([-SWIPE_FAIL_OFFSET_Y, SWIPE_FAIL_OFFSET_Y])
.onUpdate((e) => {
let t = e.translationX;
if (t > 0 && !hasRight) t = 0;
if (t < 0 && !hasLeft) t = 0;
t = Math.max(-max, Math.min(max, t));
t = Math.max(-SWIPE_MAX_TRANSLATION, Math.min(SWIPE_MAX_TRANSLATION, t));
tx.value = t;
const nowArmed = Math.abs(t) >= arm;
const nowArmed = Math.abs(t) >= SWIPE_ARM_TRANSLATION;
if (nowArmed !== armed.value) {
armed.value = nowArmed;
runOnJS(playHaptic)('threshold');
@@ -100,8 +109,13 @@ export function SwipeableRow({
})
.onEnd(() => {
const t = tx.value;
if (t >= arm && hasRight) runOnJS(onCommit)('right');
else if (t <= -arm && hasLeft) runOnJS(onCommit)('left');
if (t >= SWIPE_ARM_TRANSLATION && hasRight) runOnJS(onCommit)('right');
else if (t <= -SWIPE_ARM_TRANSLATION && hasLeft) runOnJS(onCommit)('left');
armed.value = false;
tx.value = withTiming(0, motion.quick);
})
.onFinalize((_event, success) => {
if (success) return;
armed.value = false;
tx.value = withTiming(0, motion.quick);
});
@@ -111,26 +125,24 @@ export function SwipeableRow({
const gesture = dragGesture ? Gesture.Race(dragGesture, pan) : pan;
const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: tx.value }] }));
const leftLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value > 1 ? 1 : 0 }));
const rightLaneStyle = useAnimatedStyle(() => ({ opacity: tx.value < -1 ? 1 : 0 }));
return (
<View style={styles.wrap} onLayout={onLayout}>
<View style={styles.wrap}>
{swipeRight ? (
<Animated.View
<View
pointerEvents="none"
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }, leftLaneStyle]}
style={[styles.lane, styles.laneLeft, { backgroundColor: swipeRight.color }]}
>
<Ionicons name={swipeRight.icon} size={22} color={swipeRight.iconColor ?? colors.bgPrimary} />
</Animated.View>
</View>
) : null}
{swipeLeft ? (
<Animated.View
<View
pointerEvents="none"
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }, rightLaneStyle]}
style={[styles.lane, styles.laneRight, { backgroundColor: swipeLeft.color }]}
>
<Ionicons name={swipeLeft.icon} size={22} color={swipeLeft.iconColor ?? colors.bgPrimary} />
</Animated.View>
</View>
) : null}
<GestureDetector gesture={gesture}>
<Animated.View style={contentStyle}>{children}</Animated.View>
@@ -146,18 +158,21 @@ const styles = StyleSheet.create({
},
lane: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
// Keep the always-mounted action colors out from under translucent row
// separators; otherwise each half of the lane tints the resting hairline.
top: StyleSheet.hairlineWidth,
bottom: StyleSheet.hairlineWidth,
width: '50%',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
laneLeft: {
left: 0,
justifyContent: 'flex-start',
},
laneRight: {
right: 0,
justifyContent: 'flex-end',
},
});
+28 -10
View File
@@ -17,6 +17,7 @@ import {
Skia,
rect
} from '@shopify/react-native-skia';
import { useDerivedValue } from 'react-native-reanimated';
import { Text } from './Text';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
@@ -27,7 +28,7 @@ import {
mergeProgressiveWaveform,
subscribeWaveformProgress,
} from '@/scope/waveform';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress';
import { usePlayerStore } from '@/stores/playerStore';
import { playHaptic } from '@/lib/haptics';
import {
@@ -95,7 +96,15 @@ export function WaveformSeekBar({
const scrubRef = useRef<number | null>(null);
const grantRef = useRef({ fraction: 0, pageX: 0 });
const detentRef = useRef<ScrubDetentState | null>(null);
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
const heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null;
const progress = useAnimatedPlaybackProgress({
currentTime,
duration,
isPlaying,
active,
trackKey: trackPath,
overrideFraction: scrubFraction ?? heldFraction,
});
// The coarse preview is kept aside as well as rendered: it's the amplitude reference the
// partially-decoded prefix is scaled against, and it supplies the not-yet-decoded tail.
const previewRef = useRef<{ path: string; peaks: Float32Array } | null>(null);
@@ -209,8 +218,7 @@ export function WaveformSeekBar({
// Displayed position: scrub > pending seek target > live progress. The player
// store clears pendingSeek only after native progress acknowledges the target
// or the guard times out, so stale RNTP progress cannot bounce the UI back.
const liveFraction = duration > 0 ? Math.min(1, smoothTime / duration) : 0;
const heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null;
const liveFraction = duration > 0 ? Math.min(1, currentTime / duration) : 0;
const fraction = scrubFraction ?? heldFraction ?? liveFraction;
const shownTime = fraction * duration;
@@ -235,11 +243,21 @@ export function WaveformSeekBar({
return path;
}, [source, barCount, barWidth, height]);
const splitX = fraction * barWidth;
const playheadX = Math.min(
Math.max(0, barWidth - PLAYHEAD_WIDTH),
Math.max(0, splitX - PLAYHEAD_WIDTH / 2)
const playedClip = useDerivedValue(
() => rect(0, 0, progress.value * barWidth, height),
[barWidth, height]
);
const unplayedClip = useDerivedValue(() => {
const splitX = progress.value * barWidth;
return rect(splitX, 0, Math.max(0, barWidth - splitX), height);
}, [barWidth, height]);
const playheadX = useDerivedValue(() => {
const splitX = progress.value * barWidth;
return Math.min(
Math.max(0, barWidth - PLAYHEAD_WIDTH),
Math.max(0, splitX - PLAYHEAD_WIDTH / 2)
);
}, [barWidth]);
return (
<View>
@@ -258,10 +276,10 @@ export function WaveformSeekBar({
accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }}
>
<Canvas style={{ width: '100%', height }}>
<Group clip={rect(0, 0, splitX, height)}>
<Group clip={playedClip}>
<Path path={barsPath} color={colors.accent} />
</Group>
<Group clip={rect(splitX, 0, Math.max(0, barWidth - splitX), height)}>
<Group clip={unplayedClip}>
<Path path={barsPath} color={colors.glassBorder} />
</Group>
{barWidth > 0 ? (
+22 -9
View File
@@ -425,7 +425,10 @@ export function NowPlayingOverlay() {
useEffect(() => {
stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap);
}, [effectiveScopeStageVisible, stageProgress]);
const commitClosed = () => usePlayerUiStore.getState().commitClosed();
const commitClosed = useCallback(
() => usePlayerUiStore.getState().commitClosed(),
[]
);
/**
* Enter the closing phase and drop the inner layers. Split out from
* `dismissSheet` so the pan gesture can commit the phase without handing the
@@ -433,13 +436,13 @@ export function NowPlayingOverlay() {
* Clearing the layers here rather than at the end also unpins the menu card,
* which renders outside the translating content.
*/
const beginDismiss = () => {
const beginDismiss = useCallback(() => {
setMenuOpen(false);
setQueueOpen(false);
// `true`: this path drives the sheet away itself, so the effect below must
// not overwrite the offset with a competing generic slide-out.
usePlayerUiStore.getState().closePlayer(true);
};
}, []);
const finishCloseMenu = () => setMenuOpen(false);
function openMenu() {
@@ -478,6 +481,10 @@ export function NowPlayingOverlay() {
};
const pan = Gesture.Pan()
// A child sheet owns vertical gestures while it is visible. Replacing this
// gesture during the queue-button touch used to cancel a partially active
// pan and leave translateY off-screen while phase still said "open".
.enabled(playerOpen && !queueOpen)
.activeOffsetY(14) // engage only on a downward drag
.failOffsetY(-14)
.failOffsetX([-24, 24]) // let the horizontal seek drag through
@@ -509,15 +516,21 @@ export function NowPlayingOverlay() {
} else {
translateY.value = withTiming(0, motion.snap);
}
})
.onFinalize((_event, success) => {
// RNGH does not call onEnd for a cancelled gesture. Never leave the
// overlay at its last partial translation in that path.
if (!success) translateY.value = withTiming(0, motion.snap);
});
// Enter animation. Keyed on `openRequest` as well as the phase, so asking for
// a player that already believes it is open still re-runs the slide-in — that
// is the recovery path for a sheet stranded off-screen by an interrupted
// close. `windowHeight` is deliberately NOT a dependency: a dimension change
// (rotation, or an RN Modal like the output picker) would re-run this effect
// and cancel an in-flight exit spring. NOTE: this effect must stay BELOW
// every direct `translateY.value` write — the react compiler forbids
// close. `queueOpen` also re-anchors the player before its modal BottomSheet
// appears. `windowHeight` is deliberately NOT a dependency: a dimension
// change (rotation, or an RN Modal like the output picker) would re-run this
// effect and cancel an in-flight exit spring. NOTE: this effect must stay
// BELOW every direct `translateY.value` write — the react compiler forbids
// mutations after an effect that depends on the value.
useEffect(() => {
if (phase === 'closing') {
@@ -531,7 +544,7 @@ export function NowPlayingOverlay() {
}
translateY.value = withTiming(0, { duration: 240 });
// eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
}, [phase, openRequest, exitAnimated, translateY]);
}, [phase, openRequest, exitAnimated, queueOpen, translateY]);
// `closing` → `closed`, and `opening` → `open`. Both are timers rather than
// animation callbacks, so a cancelled animation can never strand the phase.
@@ -666,7 +679,7 @@ export function NowPlayingOverlay() {
);
return (
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'auto' : 'none'}>
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'box-none' : 'none'}>
<GestureDetector gesture={pan}>
<Animated.View
style={[
+7 -3
View File
@@ -67,8 +67,12 @@ import {
resolveSelectedQueueAction,
type QueueIndexByKey,
} from './queueActions';
import {
QUEUE_RENDER_DISTANCE,
QUEUE_ROW_HEIGHT,
queuePreviewRowCount,
} from './queuePerformance';
const QUEUE_ROW_HEIGHT = 64;
const ART = 42;
const EMPTY_KEY_SET = new Set<string>();
@@ -183,7 +187,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
// fires a frame after first layout completes, so rows are already underneath).
const [listPainted, setListPainted] = useState(false);
const onListLoad = useCallback(() => setListPainted(true), []);
const previewCount = Math.ceil(windowHeight / QUEUE_ROW_HEIGHT);
const previewCount = queuePreviewRowCount(windowHeight);
// Bottom padding clears the gesture-nav inset so the last row is fully
// scrollable into view at the 100% snap.
const listContentStyle = useMemo(
@@ -856,7 +860,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }:
data={entries}
scrollEnabled
keyExtractor={(item) => item.key}
drawDistance={QUEUE_ROW_HEIGHT * 12}
drawDistance={QUEUE_RENDER_DISTANCE}
maintainVisibleContentPosition={{ disabled: true }}
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
renderItem={renderItem}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
QUEUE_RENDER_AHEAD_ROWS,
QUEUE_RENDER_DISTANCE,
QUEUE_ROW_HEIGHT,
queuePreviewRowCount,
} from './queuePerformance.ts';
test('queue render-ahead stays bounded to four rows', () => {
assert.equal(QUEUE_RENDER_AHEAD_ROWS, 4);
assert.equal(QUEUE_RENDER_DISTANCE, QUEUE_ROW_HEIGHT * 4);
});
test('initial queue preview covers the sheet viewport without duplicating a screen', () => {
assert.equal(queuePreviewRowCount(780), 4);
assert.equal(queuePreviewRowCount(900), 5);
assert.equal(queuePreviewRowCount(1400), 6);
});
test('queue preview remains safe for unusually short windows', () => {
assert.equal(queuePreviewRowCount(0), 1);
assert.equal(queuePreviewRowCount(320), 1);
});
+24
View File
@@ -0,0 +1,24 @@
export const QUEUE_ROW_HEIGHT = 64;
export const QUEUE_RENDER_AHEAD_ROWS = 4;
const QUEUE_SHEET_INITIAL_FRACTION = 0.58;
const QUEUE_PREVIEW_NON_LIST_HEIGHT = 220;
const QUEUE_PREVIEW_MAX_ROWS = 6;
/**
* The preview only fills the list portion of the initial sheet snap. Using the
* whole window height used to duplicate far more rows than could be visible
* while FlashList was mounting its own render-ahead window underneath.
*/
export function queuePreviewRowCount(windowHeight: number): number {
const initialListHeight = Math.max(
QUEUE_ROW_HEIGHT,
windowHeight * QUEUE_SHEET_INITIAL_FRACTION - QUEUE_PREVIEW_NON_LIST_HEIGHT
);
return Math.min(
QUEUE_PREVIEW_MAX_ROWS,
Math.max(1, Math.ceil(initialListHeight / QUEUE_ROW_HEIGHT))
);
}
export const QUEUE_RENDER_DISTANCE = QUEUE_ROW_HEIGHT * QUEUE_RENDER_AHEAD_ROWS;
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
TAB_SCENE_ANIMATION,
TAB_TRANSITION_SETTLE_MS,
TAB_TRANSITION_SPEC,
} from './tabTransition.ts';
test('tab scenes use a cross-fade and retain the short settle guard', () => {
assert.equal(TAB_SCENE_ANIMATION, 'fade');
assert.equal(TAB_TRANSITION_SETTLE_MS, 160);
});
test('tab fade uses a critically damped native spring with no overshoot', () => {
assert.equal(TAB_TRANSITION_SPEC.animation, 'spring');
assert.equal(TAB_TRANSITION_SPEC.config.overshootClamping, true);
const criticalDamping =
2 * Math.sqrt(TAB_TRANSITION_SPEC.config.stiffness * TAB_TRANSITION_SPEC.config.mass);
assert.equal(TAB_TRANSITION_SPEC.config.damping, criticalDamping);
});
+4 -4
View File
@@ -1,10 +1,11 @@
/**
* Bottom tabs use React Native's legacy native Animated driver. On Android,
* timing animations are pre-sampled at 60 fps, so their positions repeat on a
* 120 Hz display. A critically damped native spring is evaluated from each
* display frame instead while preserving the current ~160 ms ease-out feel.
* timing animations are pre-sampled at 60 fps. A short, critically damped
* native spring avoids translating two retained full-page scenes and leaves
* only an intentional cross-fade at the tab boundary.
*/
export const TAB_TRANSITION_SETTLE_MS = 160;
export const TAB_SCENE_ANIMATION = 'fade' as const;
export const TAB_TRANSITION_SPEC = {
animation: 'spring',
@@ -17,4 +18,3 @@ export const TAB_TRANSITION_SPEC = {
restSpeedThreshold: 0.15,
},
} as const;