mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-16 08:10:43 +02:00
performance improvements + ui/ux fixes
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
@@ -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 Hz–20 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
|
||||
}
|
||||
}
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user