mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 11:12:33 +02:00
new scheduling for scopes
This commit is contained in:
+52
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+185
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user