new scheduling for scopes

This commit is contained in:
Boof2015
2026-07-25 20:16:33 -04:00
parent 6dd82420c8
commit 629be4d9ef
7 changed files with 283 additions and 23 deletions
@@ -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
}
}
@@ -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
}
}
@@ -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
+1
View File
@@ -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;