diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt index e0df21e..7959ce1 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeProjection.kt @@ -5,6 +5,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.pow import kotlin.math.roundToInt +import kotlin.math.roundToLong internal enum class ScopeMode { SPECTRUM, @@ -34,6 +35,7 @@ internal object AstraScopeProjection { const val OSCILLOSCOPE_POINTS = 256 const val DECAY_PER_FRAME = 0.72f const val REST_EPSILON = 0.004f + const val MAX_ADAPTIVE_OSCILLOSCOPE_FPS = 90f private const val MIN_FREQUENCY = 20.0 private const val MAX_FREQUENCY = 20_000.0 @@ -42,10 +44,27 @@ internal object AstraScopeProjection { 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 + val safeRate = safeRefreshRate(refreshRate) return max(1L, (1_000.0 / safeRate).roundToInt().toLong()) } + fun cadenceNanos(requestedMs: Double, refreshRate: Float): Long { + if (requestedMs > 0.0) { + return max(1L, (requestedMs * NANOS_PER_MILLISECOND).roundToLong()) + } + return max(1L, (NANOS_PER_SECOND / safeRefreshRate(refreshRate)).roundToLong()) + } + + /** + * Follows 60 Hz displays directly and uses the measured 90 fps scope budget + * on faster panels. Power or thermal pressure constrains rendering to 60 fps. + */ + fun adaptiveOscilloscopeFps(refreshRate: Float, constrained: Boolean): Float { + val safeRate = safeRefreshRate(refreshRate) + val limit = if (constrained) 60f else MAX_ADAPTIVE_OSCILLOSCOPE_FPS + return min(safeRate, limit) + } + fun clamp01(value: Float): Float = when { !value.isFinite() || value <= 0f -> 0f value >= 1f -> 1f @@ -115,6 +134,9 @@ internal object AstraScopeProjection { private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double = minFrequency * (maxFrequency / minFrequency).pow(t) + private fun safeRefreshRate(refreshRate: Float): Float = + if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f + private fun interpolated( values: java.nio.FloatBuffer, count: Int, @@ -138,6 +160,9 @@ internal object AstraScopeProjection { for (index in (start + 1)..end) result = max(result, values.get(index)) return result } + + private const val NANOS_PER_MILLISECOND = 1_000_000.0 + private const val NANOS_PER_SECOND = 1_000_000_000.0 } /** @@ -163,3 +188,29 @@ internal class ScopeRenderGate { fun currentGeneration(): Int = generation } + +/** + * Keeps fractional target cadences phase-locked to vsync instead of resetting + * the deadline after each rendered frame (which would turn 90-on-120 into 60). + */ +internal class AdaptiveFrameDeadline { + private var nextFrameAtNanos = Long.MIN_VALUE + + fun reset() { + nextFrameAtNanos = Long.MIN_VALUE + } + + fun isDue(frameTimeNanos: Long, cadenceNanos: Long, toleranceNanos: Long = 0L): Boolean { + val cadence = max(1L, cadenceNanos) + if (nextFrameAtNanos == Long.MIN_VALUE) { + nextFrameAtNanos = frameTimeNanos + cadence + return true + } + if (frameTimeNanos + max(0L, toleranceNanos) < nextFrameAtNanos) return false + + do { + nextFrameAtNanos += cadence + } while (nextFrameAtNanos <= frameTimeNanos) + return true + } +} diff --git a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt index 2ef5b2d..523f989 100644 --- a/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt +++ b/modules/astra-scope/android/src/main/java/expo/modules/astrascope/AstraScopeView.kt @@ -10,9 +10,13 @@ import android.graphics.Path import android.graphics.PorterDuff import android.graphics.Shader import android.graphics.SurfaceTexture +import android.os.Build import android.os.Handler import android.os.HandlerThread +import android.os.PowerManager import android.os.SystemClock +import android.view.Choreographer +import android.view.Surface import android.view.View import android.view.ViewGroup import android.view.TextureView @@ -29,12 +33,24 @@ import kotlin.math.roundToInt private object ScopeRenderDispatcher { private val thread = HandlerThread("AstraScopeRender").apply { start() } val handler = Handler(thread.looper) + + fun postFrameCallback(callback: Choreographer.FrameCallback) { + handler.post { + Choreographer.getInstance().postFrameCallback(callback) + } + } + + fun removeFrameCallback(callback: Choreographer.FrameCallback) { + handler.post { + Choreographer.getInstance().removeFrameCallback(callback) + } + } } /** * 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. + * FFT read, path mutation, and hardware-surface draw; no audio frame crosses + * React or the JavaScript thread. */ internal class AstraScopeView( context: Context, @@ -84,16 +100,66 @@ internal class AstraScopeView( private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val textureView = TextureView(context) private val renderGate = ScopeRenderGate() + private val powerManager = + context.getSystemService(Context.POWER_SERVICE) as PowerManager private var attached = false private var windowVisible = false @Volatile private var surfaceAvailable = false + @Volatile + private var renderSurface: Surface? = null private var scheduledToken = 0 private var lastAnalysisAt = 0L private var lastDrawAt = 0L + private var lastAnalysisAtNanos = 0L + private var lastAdaptivePolicyAt = 0L + private var appliedFrameRate = Float.NaN private var hasNewFrame = false + private val adaptiveFrameDeadline = AdaptiveFrameDeadline() @Volatile private var displayRefreshRate = 60f + @Volatile + private var cadenceConstrained = false + + private val adaptiveFrameCallback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + val token = scheduledToken + if (!renderGate.isCurrent(token)) return + + refreshAdaptivePolicy(SystemClock.uptimeMillis()) + val targetFps = AstraScopeProjection.adaptiveOscilloscopeFps( + displayRefreshRate, + cadenceConstrained + ) + val analysisCadence = + AstraScopeProjection.cadenceNanos(analysisFrameMs, targetFps) + val renderThisVsync = adaptiveFrameDeadline.isDue( + frameTimeNanos, + AstraScopeProjection.cadenceNanos(frameMs, targetFps), + VSYNC_TOLERANCE_NANOS + ) + + val analyzeThisVsync = if (analysisFrameMs <= 0.0) { + renderThisVsync + } else { + isFrameDue(frameTimeNanos, lastAnalysisAtNanos, analysisCadence) + } + if (analyzeThisVsync) { + lastAnalysisAtNanos = frameTimeNanos + hasNewFrame = readScopeFrame() + } + if (hasNewFrame && renderThisVsync) { + if (!renderGate.isCurrent(token)) return + preparePaths() + hasNewFrame = false + publishFrame() + } + + if (renderGate.isCurrent(token)) { + Choreographer.getInstance().postFrameCallback(this) + } + } + } private val renderRunnable = object : Runnable { override fun run() { @@ -134,6 +200,9 @@ internal class AstraScopeView( textureView.isOpaque = false textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener { override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + renderSurface?.release() + renderSurface = Surface(surface) + appliedFrameRate = Float.NaN surfaceAvailable = true restartRendering() } @@ -146,6 +215,8 @@ internal class AstraScopeView( override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { surfaceAvailable = false cancelRendering() + renderSurface?.release() + renderSurface = null return true } @@ -195,19 +266,33 @@ internal class AstraScopeView( private fun restartRendering() { ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable) + ScopeRenderDispatcher.removeFrameCallback(adaptiveFrameCallback) lastAnalysisAt = 0L lastDrawAt = 0L + lastAnalysisAtNanos = 0L + adaptiveFrameDeadline.reset() hasNewFrame = false val eligible = attached && windowVisible && surfaceAvailable && width > 0 && height > 0 - displayRefreshRate = display?.refreshRate ?: 60f + refreshAdaptivePolicy(SystemClock.uptimeMillis(), force = true) scheduledToken = renderGate.update(eligible) - if (eligible) ScopeRenderDispatcher.handler.post(renderRunnable) + applyFrameRateVote( + if (eligible && usesAdaptiveVsync()) adaptiveOscilloscopeFps() else 0f + ) + if (eligible) { + if (usesAdaptiveVsync()) { + ScopeRenderDispatcher.postFrameCallback(adaptiveFrameCallback) + } else { + ScopeRenderDispatcher.handler.post(renderRunnable) + } + } } private fun cancelRendering() { scheduledToken = renderGate.update(false) + applyFrameRateVote(0f) ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable) + ScopeRenderDispatcher.removeFrameCallback(adaptiveFrameCallback) } private fun readScopeFrame(): Boolean { @@ -295,11 +380,83 @@ internal class AstraScopeView( publishFrame() if (peak >= AstraScopeProjection.REST_EPSILON && renderGate.isCurrent(token)) { - val cadence = AstraScopeProjection.cadenceMs(frameMs, displayRefreshRate) + val decayFrameMs = if (frameMs > 0.0) frameMs else FALLBACK_FRAME_MS + val cadence = AstraScopeProjection.cadenceMs(decayFrameMs, displayRefreshRate) ScopeRenderDispatcher.handler.postDelayed(renderRunnable, cadence) } } + private fun usesAdaptiveVsync(): Boolean = + requestedActive && mode == ScopeMode.OSCILLOSCOPE && frameMs <= 0.0 + + private fun refreshAdaptivePolicy(now: Long, force: Boolean = false) { + if (!force && now - lastAdaptivePolicyAt < ADAPTIVE_POLICY_POLL_MS) return + val previousTarget = adaptiveOscilloscopeFps() + lastAdaptivePolicyAt = now + displayRefreshRate = display?.refreshRate ?: 60f + cadenceConstrained = powerManager.isPowerSaveMode || + ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + powerManager.currentThermalStatus >= PowerManager.THERMAL_STATUS_MODERATE + ) + val nextTarget = adaptiveOscilloscopeFps() + if (!force && abs(nextTarget - previousTarget) >= FRAME_RATE_CHANGE_EPSILON) { + textureView.post { + applyFrameRateVote( + if (renderGate.eligible && usesAdaptiveVsync()) nextTarget else 0f + ) + } + } + } + + private fun adaptiveOscilloscopeFps(): Float = + AstraScopeProjection.adaptiveOscilloscopeFps( + displayRefreshRate, + cadenceConstrained + ) + + private fun applyFrameRateVote(requestedRate: Float) { + if ( + appliedFrameRate.isFinite() && + abs(appliedFrameRate - requestedRate) < FRAME_RATE_CHANGE_EPSILON + ) { + return + } + appliedFrameRate = requestedRate + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + textureView.setRequestedFrameRate( + if (requestedRate > 0f) { + requestedRate + } else { + View.REQUESTED_FRAME_RATE_CATEGORY_NO_PREFERENCE + } + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val surface = renderSurface ?: return + if (!surface.isValid) return + try { + if (requestedRate > 0f) { + surface.setFrameRate( + requestedRate, + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE + ) + } else { + surface.clearFrameRate() + } + } catch (_: IllegalArgumentException) { + // The TextureView may detach while a frame-rate vote is being updated. + } catch (_: IllegalStateException) { + // The TextureView may detach while a frame-rate vote is being updated. + } + } + } + + private fun isFrameDue(now: Long, previous: Long, cadence: Long): Boolean = + previous == 0L || now - previous >= max(1L, cadence - VSYNC_TOLERANCE_NANOS) + private fun preparePaths() { val count = renderedPointCount val canvasWidth = width.toFloat() @@ -361,13 +518,21 @@ internal class AstraScopeView( } /** - * 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. + * The serialized scope worker prepares and hardware-rasterizes this small + * transparent layer. TextureView publication may schedule a platform frame, + * but it never schedules React work or rebuilds the surrounding scene. */ private fun publishFrame() { if (!surfaceAvailable) return - val canvas = textureView.lockCanvas() ?: return + val surface = renderSurface ?: return + if (!surface.isValid) return + val canvas = try { + surface.lockHardwareCanvas() + } catch (_: IllegalArgumentException) { + return + } catch (_: IllegalStateException) { + return + } try { canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) synchronized(pathLock) { @@ -378,7 +543,13 @@ internal class AstraScopeView( canvas.drawPath(linePaths[frontPath], strokePaint) } } finally { - textureView.unlockCanvasAndPost(canvas) + try { + surface.unlockCanvasAndPost(canvas) + } catch (_: IllegalArgumentException) { + // The TextureView may detach between lock and post. + } catch (_: IllegalStateException) { + // The TextureView may detach between lock and post. + } } } @@ -447,5 +618,9 @@ internal class AstraScopeView( companion object { private const val MAX_RENDER_POINTS = 512 + private const val ADAPTIVE_POLICY_POLL_MS = 1_000L + private const val VSYNC_TOLERANCE_NANOS = 1_000_000L + private const val FALLBACK_FRAME_MS = 1_000.0 / 60.0 + private const val FRAME_RATE_CHANGE_EPSILON = 0.5f } } diff --git a/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt index 194dc0d..bc188a2 100644 --- a/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt +++ b/modules/astra-scope/android/src/test/java/expo/modules/astrascope/AstraScopeProjectionTest.kt @@ -46,6 +46,37 @@ class AstraScopeProjectionTest { assertEquals(16L, AstraScopeProjection.cadenceMs(16.0, 120f)) assertEquals(8L, AstraScopeProjection.cadenceMs(0.0, 120f)) assertEquals(17L, AstraScopeProjection.cadenceMs(0.0, 60f)) + assertEquals(16_000_000L, AstraScopeProjection.cadenceNanos(16.0, 120f)) + assertEquals(8_333_333L, AstraScopeProjection.cadenceNanos(0.0, 120f)) + assertEquals(11_111_111L, AstraScopeProjection.cadenceNanos(0.0, 90f)) + } + + @Test + fun adaptiveOscilloscopeUsesNinetyFpsOnFastDisplaysAndSixtyWhenConstrained() { + assertEquals(60f, AstraScopeProjection.adaptiveOscilloscopeFps(60f, false), 0f) + assertEquals(90f, AstraScopeProjection.adaptiveOscilloscopeFps(90f, false), 0f) + assertEquals(90f, AstraScopeProjection.adaptiveOscilloscopeFps(120f, false), 0f) + assertEquals(90f, AstraScopeProjection.adaptiveOscilloscopeFps(144f, false), 0f) + + assertEquals(60f, AstraScopeProjection.adaptiveOscilloscopeFps(120f, true), 0f) + assertEquals(60f, AstraScopeProjection.adaptiveOscilloscopeFps(90f, true), 0f) + assertEquals(60f, AstraScopeProjection.adaptiveOscilloscopeFps(Float.NaN, false), 0f) + } + + @Test + fun fractionalDeadlineProducesNineFramesAcrossTwelve120HzVsyncs() { + val deadline = AdaptiveFrameDeadline() + val vsyncCadence = AstraScopeProjection.cadenceNanos(0.0, 120f) + val renderCadence = AstraScopeProjection.cadenceNanos(0.0, 90f) + var rendered = 0 + + repeat(12) { frame -> + if (deadline.isDue(frame * vsyncCadence, renderCadence, 500_000L)) rendered += 1 + } + + assertEquals(9, rendered) + deadline.reset() + assertTrue(deadline.isDue(200_000_000L, renderCadence)) } @Test diff --git a/modules/astra-scope/index.ts b/modules/astra-scope/index.ts index 62f323f..a5dc9f0 100644 --- a/modules/astra-scope/index.ts +++ b/modules/astra-scope/index.ts @@ -82,6 +82,7 @@ export interface AstraScopeViewProps extends ViewProps { source?: 'pre' | 'post'; active: boolean; reducedMotion?: boolean; + /** Positive milliseconds for a fixed cadence; 0 enables native display sync. */ frameMs: number; analysisFrameMs?: number; smoothing?: number; diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx index d5545a3..4f76cfc 100644 --- a/src/components/OscilloscopeWave.tsx +++ b/src/components/OscilloscopeWave.tsx @@ -8,7 +8,7 @@ interface OscilloscopeWaveProps { active: boolean; width: number; height: number; - /** Live render cadence; 0 means display-sync. */ + /** Live render cadence; 0 uses native adaptive display synchronization. */ frameMs?: number; color?: string; lineWidth?: number; @@ -27,7 +27,7 @@ export function OscilloscopeWave({ active, width, height, - frameMs = 16, + frameMs = 0, color: colorProp, lineWidth = 2, glow = false, diff --git a/src/components/Visualizer.tsx b/src/components/Visualizer.tsx index dfadd38..f96f10e 100644 --- a/src/components/Visualizer.tsx +++ b/src/components/Visualizer.tsx @@ -12,9 +12,10 @@ import { createThemedStyles, useColors } from '@/theme/themed'; import { useScopeActive } from '@/scope/scopeStore'; const CANVAS_HEIGHT = 96; -// 60fps cap: display-sync (0) pinned the JS thread at 120Hz on high-refresh -// devices and starved every other animation. -const STAGE_FRAME_MS = 16; +// Spectrum remains intentionally capped; native oscilloscope rendering follows +// the display up to 90 Hz and falls back to at most 60 Hz under system pressure. +const SPECTRUM_FRAME_MS = 16; +const OSCILLOSCOPE_FRAME_MS = 0; type Mode = 'spectrum' | 'scope'; @@ -73,7 +74,7 @@ export function Visualizer({ {mode === 'spectrum' ? (