Address Codex audit of ticking (36c0cfa): dynamic + init-race fixes
P1 - Tick cadence now reacts immediately to changing tilt. The due time is recomputed every update from the last-tick anchor plus the CURRENT interval, so dropping from 5deg to 0.4deg accelerates to the fast cadence at once instead of waiting out the stale slow interval. Anchor advances by whole intervals (no drift) and resyncs to now if a full interval behind (no burst). Added far->near and near->far transition tests. P1 - One-shot cues no longer lost to async loading. SonarSoundPool queues a Level asked for before its sample loads and plays it on load-complete (and cancels the queued Level on unlock/gate). VoiceSpeaker queues the latest phrase requested before TTS finishes init and speaks it on ready. The policy fires these once, so they can't rely on retries. P2 - Unlock stops the level.wav tail BEFORE playing the resumed tick (order was reversed, allowing a brief overlap). P2 - Audio-mode preference write moved from the screen's rememberCoroutineScope to container.applicationScope, so a quick navigation can't cancel it (matches the earlier persistence hardening). Cleanup: AudioAssistMode and LevelPipeline comments now describe ticking, not the retired radar pings/homing bands; AUDIO_DESIGN_LOG cadence principle reconciled with the 125 ms continuous-tick near rate. 78 tests passing; assembleDebug clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,11 +14,14 @@ import kotlin.math.pow
|
||||
* not volume). Centered reuses the real lock (LockDetector's dwell + hysteresis): on the
|
||||
* lock transition it emits [Cue.LEVEL] once, then stays silent until unlock.
|
||||
*
|
||||
* Scheduling is deadline-based off a MONOTONIC clock (caller passes nowMillis): it advances
|
||||
* a next-tick deadline rather than restarting from "now", so it neither drifts slow nor
|
||||
* fires catch-up bursts. At most one tick per update; if it ever falls a full interval
|
||||
* behind (a pause, a rate change), it resyncs the deadline into the future instead of
|
||||
* bursting. Re-entry — enabling TONES, returning FACE_UP, or unlocking — ticks immediately.
|
||||
* Scheduling is anchored to the last tick off a MONOTONIC clock (caller passes nowMillis).
|
||||
* The due time is recomputed EVERY update from that anchor plus the CURRENT interval, so a
|
||||
* change in tilt takes effect immediately — if you drop from 5° to 0.4°, the next tick
|
||||
* accelerates to the fast cadence instead of waiting out the stale slow interval. The anchor
|
||||
* advances by whole intervals (no drift, just a constant poll latency); if it ever falls a
|
||||
* full interval behind (a pause), it resyncs to "now" instead of firing a catch-up burst.
|
||||
* At most one tick per update. Re-entry — enabling TONES, returning FACE_UP, or unlocking —
|
||||
* ticks immediately.
|
||||
*/
|
||||
class SurfaceTickPolicy(
|
||||
private val nearDegrees: Double = NEAR_DEGREES,
|
||||
@@ -28,7 +31,7 @@ class SurfaceTickPolicy(
|
||||
) {
|
||||
enum class Cue { TICK, LEVEL }
|
||||
|
||||
private var deadlineMillis: Long? = null
|
||||
private var lastTickAtMillis: Long? = null // anchor: when the last tick actually fired
|
||||
private var wasLocked = false
|
||||
|
||||
fun update(input: Input): Cue? {
|
||||
@@ -40,7 +43,7 @@ class SurfaceTickPolicy(
|
||||
}
|
||||
|
||||
if (input.isLocked) {
|
||||
deadlineMillis = null // cancel any pending tick
|
||||
lastTickAtMillis = null // cancel any pending tick
|
||||
val justLocked = !wasLocked
|
||||
wasLocked = true
|
||||
return if (justLocked) Cue.LEVEL else null
|
||||
@@ -48,17 +51,18 @@ class SurfaceTickPolicy(
|
||||
wasLocked = false
|
||||
|
||||
val now = input.nowMillis
|
||||
val deadline = deadlineMillis
|
||||
if (deadline == null) {
|
||||
// Re-entry: tick immediately, then schedule the next.
|
||||
deadlineMillis = now + intervalMillis(input.errorDegrees)
|
||||
val anchor = lastTickAtMillis
|
||||
if (anchor == null) {
|
||||
// Re-entry: tick immediately.
|
||||
lastTickAtMillis = now
|
||||
return Cue.TICK
|
||||
}
|
||||
if (now >= deadline) {
|
||||
val interval = intervalMillis(input.errorDegrees)
|
||||
var next = deadline + interval
|
||||
if (next <= now) next = now + interval // fell a full interval behind: resync, no burst
|
||||
deadlineMillis = next
|
||||
val interval = intervalMillis(input.errorDegrees) // current interval, recomputed each frame
|
||||
val due = anchor + interval
|
||||
if (now >= due) {
|
||||
// Advance the anchor by one whole interval (no drift). If we're a full interval
|
||||
// or more behind (a pause), resync to now so we don't fire a catch-up burst.
|
||||
lastTickAtMillis = if (now - due >= interval) now else due
|
||||
return Cue.TICK
|
||||
}
|
||||
return null
|
||||
@@ -73,7 +77,7 @@ class SurfaceTickPolicy(
|
||||
fun intervalMillis(errorDegrees: Double): Long = (1000.0 / tickRatePerSecond(errorDegrees)).toLong()
|
||||
|
||||
fun reset() {
|
||||
deadlineMillis = null
|
||||
lastTickAtMillis = null
|
||||
wasLocked = false
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ package com.onthelevel.core.settings
|
||||
* would talk over each other, so it's one choice, not two toggles.
|
||||
*
|
||||
* OFF — silent.
|
||||
* TONES — radar-style sonar homing pings (pitch steps up as you near level) + an arrival
|
||||
* hit at lock.
|
||||
* TONES — continuous proximity ticking that speeds up as you near level, then `level.wav`
|
||||
* once at lock.
|
||||
* VOICE — spoken corrections ("raise the right") + "that's level", announced only on a
|
||||
* settled change. No bed underneath — speech stays sparse.
|
||||
*/
|
||||
|
||||
@@ -53,27 +53,26 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
||||
LaunchedEffect(reading, mode, isForeground, player, speaker) {
|
||||
val current = reading ?: return@LaunchedEffect
|
||||
|
||||
// TONES: proximity ticks off a monotonic deadline; `level.wav` at lock.
|
||||
when (
|
||||
tickPolicy.update(
|
||||
SurfaceTickPolicy.Input(
|
||||
isEnabled = mode == AudioAssistMode.TONES,
|
||||
isAppForeground = isForeground,
|
||||
placementOk = current.placementOk,
|
||||
surfacePresentation = current.surfacePresentation,
|
||||
errorDegrees = current.stableTiltDegrees ?: current.displayPrimaryDegrees,
|
||||
isLocked = current.isLocked,
|
||||
nowMillis = SystemClock.elapsedRealtime(),
|
||||
),
|
||||
)
|
||||
) {
|
||||
// TONES: proximity ticks off a monotonic anchor; `level.wav` at lock.
|
||||
val tickCue = tickPolicy.update(
|
||||
SurfaceTickPolicy.Input(
|
||||
isEnabled = mode == AudioAssistMode.TONES,
|
||||
isAppForeground = isForeground,
|
||||
placementOk = current.placementOk,
|
||||
surfacePresentation = current.surfacePresentation,
|
||||
errorDegrees = current.stableTiltDegrees ?: current.displayPrimaryDegrees,
|
||||
isLocked = current.isLocked,
|
||||
nowMillis = SystemClock.elapsedRealtime(),
|
||||
),
|
||||
)
|
||||
// Stop the 1.39 s level.wav tail BEFORE playing a resumed tick, so an unlock can't
|
||||
// briefly overlap the two (order matters — Codex P2).
|
||||
if (!(mode == AudioAssistMode.TONES && current.isLocked)) player?.stopLevel()
|
||||
when (tickCue) {
|
||||
SurfaceTickPolicy.Cue.TICK -> player?.playTick()
|
||||
SurfaceTickPolicy.Cue.LEVEL -> player?.playLevel()
|
||||
null -> Unit
|
||||
}
|
||||
// Stop the 1.39 s level.wav tail the moment we're no longer holding level, so a
|
||||
// fresh tick after a quick unlock can't overlap it (Codex requirement).
|
||||
if (!(mode == AudioAssistMode.TONES && current.isLocked)) player?.stopLevel()
|
||||
|
||||
val phrase = voicePolicy.update(
|
||||
VoiceGuidancePolicy.Input(
|
||||
@@ -122,9 +121,20 @@ private class SonarSoundPool(context: Context) {
|
||||
private val tickSound: Int
|
||||
private val levelSound: Int
|
||||
private var levelStreamId: Int = 0
|
||||
private var levelPending = false // Level asked for before the sample finished loading
|
||||
|
||||
init {
|
||||
soundPool.setOnLoadCompleteListener { _, sampleId, status -> if (status == 0) loaded += sampleId }
|
||||
soundPool.setOnLoadCompleteListener { _, sampleId, status ->
|
||||
if (status == 0) {
|
||||
loaded += sampleId
|
||||
// The one-shot Level can't rely on a retry (the policy fires it once), so
|
||||
// play a queued Level the moment its sample lands.
|
||||
if (sampleId == levelSound && levelPending) {
|
||||
levelPending = false
|
||||
startLevel()
|
||||
}
|
||||
}
|
||||
}
|
||||
tickSound = soundPool.load(context, R.raw.tick, 1)
|
||||
levelSound = soundPool.load(context, R.raw.level, 1)
|
||||
}
|
||||
@@ -134,15 +144,18 @@ private class SonarSoundPool(context: Context) {
|
||||
if (tickSound in loaded) soundPool.play(tickSound, TICK_VOLUME, TICK_VOLUME, 1, 0, 1f)
|
||||
}
|
||||
|
||||
/** The "you're level" arrival; retained so it can be stopped on unlock. */
|
||||
/** The "you're level" arrival; retained so it can be stopped on unlock. Queues if not loaded. */
|
||||
fun playLevel() {
|
||||
stopLevel()
|
||||
if (levelSound in loaded) {
|
||||
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f)
|
||||
}
|
||||
if (levelSound in loaded) startLevel() else levelPending = true
|
||||
}
|
||||
|
||||
private fun startLevel() {
|
||||
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f)
|
||||
}
|
||||
|
||||
fun stopLevel() {
|
||||
levelPending = false // cancel a queued Level (unlock/gate invalidated it)
|
||||
if (levelStreamId != 0) {
|
||||
soundPool.stop(levelStreamId)
|
||||
levelStreamId = 0
|
||||
@@ -164,6 +177,7 @@ private class SonarSoundPool(context: Context) {
|
||||
*/
|
||||
private class VoiceSpeaker(context: Context) {
|
||||
private var ready = false
|
||||
private var pendingText: String? = null // latest phrase asked for before TTS finished init
|
||||
private lateinit var tts: TextToSpeech
|
||||
|
||||
init {
|
||||
@@ -177,13 +191,17 @@ private class VoiceSpeaker(context: Context) {
|
||||
.build(),
|
||||
)
|
||||
ready = true
|
||||
// The first phrase often arrives before init finishes; the policy won't
|
||||
// re-issue it, so speak the latest queued phrase now.
|
||||
pendingText?.let { tts.speak(it, TextToSpeech.QUEUE_FLUSH, null, "level-voice") }
|
||||
pendingText = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun speak(phrase: VoiceGuidancePolicy.Phrase) {
|
||||
if (!ready) return
|
||||
tts.speak(textFor(phrase), TextToSpeech.QUEUE_FLUSH, null, "level-voice")
|
||||
val text = textFor(phrase)
|
||||
if (ready) tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "level-voice") else pendingText = text
|
||||
}
|
||||
|
||||
private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) {
|
||||
|
||||
@@ -28,7 +28,7 @@ data class LevelReading(
|
||||
/** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */
|
||||
val stableSurfacePitchDegrees: Double? = null,
|
||||
val stableSurfaceRollDegrees: Double? = null,
|
||||
/** Stable (pre-deadband) Surface tilt magnitude; drives audio homing bands. Null for Edge. */
|
||||
/** Stable (pre-deadband) Surface tilt magnitude; drives the audio tick rate. Null for Edge. */
|
||||
val stableTiltDegrees: Double? = null,
|
||||
/** Surface-only presentation state; null for Edge mode. */
|
||||
val surfacePresentation: SurfacePresentation? = null,
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -95,7 +94,6 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
|
||||
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
|
||||
|
||||
val view = LocalView.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val lockDetector = remember { LockDetector() }
|
||||
LaunchedEffect(mode) { lockDetector.reset() }
|
||||
|
||||
@@ -140,7 +138,7 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
|
||||
AudioAssistMode.TONES -> AudioAssistMode.VOICE
|
||||
AudioAssistMode.VOICE -> AudioAssistMode.OFF
|
||||
}
|
||||
scope.launch { container.settings.setAudioAssistMode(next) }
|
||||
container.applicationScope.launch { container.settings.setAudioAssistMode(next) }
|
||||
}) {
|
||||
Text(
|
||||
text = when (audioMode) {
|
||||
|
||||
@@ -96,6 +96,27 @@ class SurfaceTickPolicyTest {
|
||||
assertTrue("expected ~10-11 ticks, got $ticks", ticks in 10..11)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `far to near accelerates immediately, not after the stale slow interval`() {
|
||||
val p = SurfaceTickPolicy()
|
||||
p.update(input(error = 5.0, now = 0)) // first tick at 5°, anchor = 0
|
||||
// Board drops to 0.4° at t=100. The next tick must fire on the FAST cadence
|
||||
// (~125 ms from the anchor), not wait out the old ~667 ms interval.
|
||||
assertNull(p.update(input(error = 0.4, now = 124)))
|
||||
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 125)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `near to far slows immediately - no leftover fast tick`() {
|
||||
val p = SurfaceTickPolicy()
|
||||
p.update(input(error = 0.4, now = 0)) // first tick at 0.4°, anchor = 0
|
||||
// Board jumps to 5° at t=50. The old fast interval (125 ms) must NOT fire; the
|
||||
// next tick waits for the slow interval (~667 ms).
|
||||
assertNull(p.update(input(error = 5.0, now = 125)))
|
||||
assertNull(p.update(input(error = 5.0, now = 500)))
|
||||
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 5.0, now = 667)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `level fires once per lock, then silence, then ticks resume on unlock`() {
|
||||
val p = SurfaceTickPolicy()
|
||||
|
||||
Reference in New Issue
Block a user