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)
}
}