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:
Jay
2026-07-17 10:01:52 -04:00
parent 36c0cfa5ab
commit 3ebb36f265
7 changed files with 94 additions and 51 deletions
@@ -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()