mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 12:40:15 +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.min
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
internal enum class ScopeMode {
|
internal enum class ScopeMode {
|
||||||
SPECTRUM,
|
SPECTRUM,
|
||||||
@@ -34,6 +35,7 @@ internal object AstraScopeProjection {
|
|||||||
const val OSCILLOSCOPE_POINTS = 256
|
const val OSCILLOSCOPE_POINTS = 256
|
||||||
const val DECAY_PER_FRAME = 0.72f
|
const val DECAY_PER_FRAME = 0.72f
|
||||||
const val REST_EPSILON = 0.004f
|
const val REST_EPSILON = 0.004f
|
||||||
|
const val MAX_ADAPTIVE_OSCILLOSCOPE_FPS = 90f
|
||||||
|
|
||||||
private const val MIN_FREQUENCY = 20.0
|
private const val MIN_FREQUENCY = 20.0
|
||||||
private const val MAX_FREQUENCY = 20_000.0
|
private const val MAX_FREQUENCY = 20_000.0
|
||||||
@@ -42,10 +44,27 @@ internal object AstraScopeProjection {
|
|||||||
|
|
||||||
fun cadenceMs(requestedMs: Double, refreshRate: Float): Long {
|
fun cadenceMs(requestedMs: Double, refreshRate: Float): Long {
|
||||||
if (requestedMs > 0.0) return max(1L, requestedMs.roundToInt().toLong())
|
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())
|
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 {
|
fun clamp01(value: Float): Float = when {
|
||||||
!value.isFinite() || value <= 0f -> 0f
|
!value.isFinite() || value <= 0f -> 0f
|
||||||
value >= 1f -> 1f
|
value >= 1f -> 1f
|
||||||
@@ -115,6 +134,9 @@ internal object AstraScopeProjection {
|
|||||||
private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double =
|
private fun frequencyAt(t: Double, minFrequency: Double, maxFrequency: Double): Double =
|
||||||
minFrequency * (maxFrequency / minFrequency).pow(t)
|
minFrequency * (maxFrequency / minFrequency).pow(t)
|
||||||
|
|
||||||
|
private fun safeRefreshRate(refreshRate: Float): Float =
|
||||||
|
if (refreshRate.isFinite() && refreshRate >= 30f) refreshRate else 60f
|
||||||
|
|
||||||
private fun interpolated(
|
private fun interpolated(
|
||||||
values: java.nio.FloatBuffer,
|
values: java.nio.FloatBuffer,
|
||||||
count: Int,
|
count: Int,
|
||||||
@@ -138,6 +160,9 @@ internal object AstraScopeProjection {
|
|||||||
for (index in (start + 1)..end) result = max(result, values.get(index))
|
for (index in (start + 1)..end) result = max(result, values.get(index))
|
||||||
return result
|
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
|
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.PorterDuff
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.HandlerThread
|
import android.os.HandlerThread
|
||||||
|
import android.os.PowerManager
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
|
import android.view.Choreographer
|
||||||
|
import android.view.Surface
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.TextureView
|
import android.view.TextureView
|
||||||
@@ -29,12 +33,24 @@ import kotlin.math.roundToInt
|
|||||||
private object ScopeRenderDispatcher {
|
private object ScopeRenderDispatcher {
|
||||||
private val thread = HandlerThread("AstraScopeRender").apply { start() }
|
private val thread = HandlerThread("AstraScopeRender").apply { start() }
|
||||||
val handler = Handler(thread.looper)
|
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
|
* Android-native spectrum/oscilloscope surface. The shared worker owns every
|
||||||
* FFT read and path mutation; the UI thread only paints the most recently
|
* FFT read, path mutation, and hardware-surface draw; no audio frame crosses
|
||||||
* prepared front path.
|
* React or the JavaScript thread.
|
||||||
*/
|
*/
|
||||||
internal class AstraScopeView(
|
internal class AstraScopeView(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -84,16 +100,66 @@ internal class AstraScopeView(
|
|||||||
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
private val textureView = TextureView(context)
|
private val textureView = TextureView(context)
|
||||||
private val renderGate = ScopeRenderGate()
|
private val renderGate = ScopeRenderGate()
|
||||||
|
private val powerManager =
|
||||||
|
context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
private var attached = false
|
private var attached = false
|
||||||
private var windowVisible = false
|
private var windowVisible = false
|
||||||
@Volatile
|
@Volatile
|
||||||
private var surfaceAvailable = false
|
private var surfaceAvailable = false
|
||||||
|
@Volatile
|
||||||
|
private var renderSurface: Surface? = null
|
||||||
private var scheduledToken = 0
|
private var scheduledToken = 0
|
||||||
private var lastAnalysisAt = 0L
|
private var lastAnalysisAt = 0L
|
||||||
private var lastDrawAt = 0L
|
private var lastDrawAt = 0L
|
||||||
|
private var lastAnalysisAtNanos = 0L
|
||||||
|
private var lastAdaptivePolicyAt = 0L
|
||||||
|
private var appliedFrameRate = Float.NaN
|
||||||
private var hasNewFrame = false
|
private var hasNewFrame = false
|
||||||
|
private val adaptiveFrameDeadline = AdaptiveFrameDeadline()
|
||||||
@Volatile
|
@Volatile
|
||||||
private var displayRefreshRate = 60f
|
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 {
|
private val renderRunnable = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
@@ -134,6 +200,9 @@ internal class AstraScopeView(
|
|||||||
textureView.isOpaque = false
|
textureView.isOpaque = false
|
||||||
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||||
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
|
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
|
||||||
|
renderSurface?.release()
|
||||||
|
renderSurface = Surface(surface)
|
||||||
|
appliedFrameRate = Float.NaN
|
||||||
surfaceAvailable = true
|
surfaceAvailable = true
|
||||||
restartRendering()
|
restartRendering()
|
||||||
}
|
}
|
||||||
@@ -146,6 +215,8 @@ internal class AstraScopeView(
|
|||||||
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
||||||
surfaceAvailable = false
|
surfaceAvailable = false
|
||||||
cancelRendering()
|
cancelRendering()
|
||||||
|
renderSurface?.release()
|
||||||
|
renderSurface = null
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,19 +266,33 @@ internal class AstraScopeView(
|
|||||||
|
|
||||||
private fun restartRendering() {
|
private fun restartRendering() {
|
||||||
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
|
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
|
||||||
|
ScopeRenderDispatcher.removeFrameCallback(adaptiveFrameCallback)
|
||||||
lastAnalysisAt = 0L
|
lastAnalysisAt = 0L
|
||||||
lastDrawAt = 0L
|
lastDrawAt = 0L
|
||||||
|
lastAnalysisAtNanos = 0L
|
||||||
|
adaptiveFrameDeadline.reset()
|
||||||
hasNewFrame = false
|
hasNewFrame = false
|
||||||
|
|
||||||
val eligible = attached && windowVisible && surfaceAvailable && width > 0 && height > 0
|
val eligible = attached && windowVisible && surfaceAvailable && width > 0 && height > 0
|
||||||
displayRefreshRate = display?.refreshRate ?: 60f
|
refreshAdaptivePolicy(SystemClock.uptimeMillis(), force = true)
|
||||||
scheduledToken = renderGate.update(eligible)
|
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() {
|
private fun cancelRendering() {
|
||||||
scheduledToken = renderGate.update(false)
|
scheduledToken = renderGate.update(false)
|
||||||
|
applyFrameRateVote(0f)
|
||||||
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
|
ScopeRenderDispatcher.handler.removeCallbacks(renderRunnable)
|
||||||
|
ScopeRenderDispatcher.removeFrameCallback(adaptiveFrameCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readScopeFrame(): Boolean {
|
private fun readScopeFrame(): Boolean {
|
||||||
@@ -295,11 +380,83 @@ internal class AstraScopeView(
|
|||||||
publishFrame()
|
publishFrame()
|
||||||
|
|
||||||
if (peak >= AstraScopeProjection.REST_EPSILON && renderGate.isCurrent(token)) {
|
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)
|
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() {
|
private fun preparePaths() {
|
||||||
val count = renderedPointCount
|
val count = renderedPointCount
|
||||||
val canvasWidth = width.toFloat()
|
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 hardware-rasterizes this small
|
||||||
* The serialized scope worker prepares and rasterizes this small transparent
|
* transparent layer. TextureView publication may schedule a platform frame,
|
||||||
* layer; the platform compositor presents it with the retained scene.
|
* but it never schedules React work or rebuilds the surrounding scene.
|
||||||
*/
|
*/
|
||||||
private fun publishFrame() {
|
private fun publishFrame() {
|
||||||
if (!surfaceAvailable) return
|
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 {
|
try {
|
||||||
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||||
synchronized(pathLock) {
|
synchronized(pathLock) {
|
||||||
@@ -378,7 +543,13 @@ internal class AstraScopeView(
|
|||||||
canvas.drawPath(linePaths[frontPath], strokePaint)
|
canvas.drawPath(linePaths[frontPath], strokePaint)
|
||||||
}
|
}
|
||||||
} finally {
|
} 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 {
|
companion object {
|
||||||
private const val MAX_RENDER_POINTS = 512
|
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(16L, AstraScopeProjection.cadenceMs(16.0, 120f))
|
||||||
assertEquals(8L, AstraScopeProjection.cadenceMs(0.0, 120f))
|
assertEquals(8L, AstraScopeProjection.cadenceMs(0.0, 120f))
|
||||||
assertEquals(17L, AstraScopeProjection.cadenceMs(0.0, 60f))
|
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
|
@Test
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface AstraScopeViewProps extends ViewProps {
|
|||||||
source?: 'pre' | 'post';
|
source?: 'pre' | 'post';
|
||||||
active: boolean;
|
active: boolean;
|
||||||
reducedMotion?: boolean;
|
reducedMotion?: boolean;
|
||||||
|
/** Positive milliseconds for a fixed cadence; 0 enables native display sync. */
|
||||||
frameMs: number;
|
frameMs: number;
|
||||||
analysisFrameMs?: number;
|
analysisFrameMs?: number;
|
||||||
smoothing?: number;
|
smoothing?: number;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface OscilloscopeWaveProps {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
/** Live render cadence; 0 means display-sync. */
|
/** Live render cadence; 0 uses native adaptive display synchronization. */
|
||||||
frameMs?: number;
|
frameMs?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
lineWidth?: number;
|
lineWidth?: number;
|
||||||
@@ -27,7 +27,7 @@ export function OscilloscopeWave({
|
|||||||
active,
|
active,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
frameMs = 16,
|
frameMs = 0,
|
||||||
color: colorProp,
|
color: colorProp,
|
||||||
lineWidth = 2,
|
lineWidth = 2,
|
||||||
glow = false,
|
glow = false,
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
|||||||
import { useScopeActive } from '@/scope/scopeStore';
|
import { useScopeActive } from '@/scope/scopeStore';
|
||||||
|
|
||||||
const CANVAS_HEIGHT = 96;
|
const CANVAS_HEIGHT = 96;
|
||||||
// 60fps cap: display-sync (0) pinned the JS thread at 120Hz on high-refresh
|
// Spectrum remains intentionally capped; native oscilloscope rendering follows
|
||||||
// devices and starved every other animation.
|
// the display up to 90 Hz and falls back to at most 60 Hz under system pressure.
|
||||||
const STAGE_FRAME_MS = 16;
|
const SPECTRUM_FRAME_MS = 16;
|
||||||
|
const OSCILLOSCOPE_FRAME_MS = 0;
|
||||||
|
|
||||||
type Mode = 'spectrum' | 'scope';
|
type Mode = 'spectrum' | 'scope';
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export function Visualizer({
|
|||||||
{mode === 'spectrum' ? (
|
{mode === 'spectrum' ? (
|
||||||
<SpectrumCurve
|
<SpectrumCurve
|
||||||
active={spectrumActive}
|
active={spectrumActive}
|
||||||
frameMs={STAGE_FRAME_MS}
|
frameMs={SPECTRUM_FRAME_MS}
|
||||||
smoothing={spectrumSmoothing}
|
smoothing={spectrumSmoothing}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
@@ -83,7 +84,7 @@ export function Visualizer({
|
|||||||
) : (
|
) : (
|
||||||
<OscilloscopeWave
|
<OscilloscopeWave
|
||||||
active={scopeWaveActive}
|
active={scopeWaveActive}
|
||||||
frameMs={STAGE_FRAME_MS}
|
frameMs={OSCILLOSCOPE_FRAME_MS}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
glow
|
glow
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import { useScopeActive } from '@/scope/scopeStore';
|
|||||||
import { spacing } from '@/theme';
|
import { spacing } from '@/theme';
|
||||||
import { createThemedStyles } from '@/theme/themed';
|
import { createThemedStyles } from '@/theme/themed';
|
||||||
|
|
||||||
// 60fps cap, matching Visualizer — display-sync starved the JS thread on
|
// Spectrum stays capped at 60 fps. The native oscilloscope follows the display
|
||||||
// high-refresh devices.
|
// up to 90 Hz and falls back to at most 60 Hz under system pressure.
|
||||||
const STAGE_FRAME_MS = 16;
|
const SPECTRUM_FRAME_MS = 16;
|
||||||
|
const OSCILLOSCOPE_FRAME_MS = 0;
|
||||||
// The rack artwork is atmosphere, not a second cover card. It bleeds beyond
|
// The rack artwork is atmosphere, not a second cover card. It bleeds beyond
|
||||||
// the old frame, stays softly defocused, and contributes restrained color.
|
// the old frame, stays softly defocused, and contributes restrained color.
|
||||||
const BACKDROP_BLUR_RADIUS = 10;
|
const BACKDROP_BLUR_RADIUS = 10;
|
||||||
@@ -111,7 +112,7 @@ export function ScopeRack({
|
|||||||
<View style={[styles.strips, { width, left: (size - width) / 2 }]}>
|
<View style={[styles.strips, { width, left: (size - width) / 2 }]}>
|
||||||
<OscilloscopeWave
|
<OscilloscopeWave
|
||||||
active={active}
|
active={active}
|
||||||
frameMs={STAGE_FRAME_MS}
|
frameMs={OSCILLOSCOPE_FRAME_MS}
|
||||||
width={width}
|
width={width}
|
||||||
height={stripHeight}
|
height={stripHeight}
|
||||||
glow
|
glow
|
||||||
@@ -120,7 +121,7 @@ export function ScopeRack({
|
|||||||
/>
|
/>
|
||||||
<SpectrumCurve
|
<SpectrumCurve
|
||||||
active={active}
|
active={active}
|
||||||
frameMs={STAGE_FRAME_MS}
|
frameMs={SPECTRUM_FRAME_MS}
|
||||||
smoothing={spectrumSmoothing}
|
smoothing={spectrumSmoothing}
|
||||||
width={width}
|
width={width}
|
||||||
height={stripHeight}
|
height={stripHeight}
|
||||||
|
|||||||
Reference in New Issue
Block a user