Voice init-race fix + velocity-aware Surface lock

Voice (final P1 from audit): the policy is no longer consumed until TTS is
ready, so a phrase can't be queued and then spoken stale after init. When
ready, the policy evaluates the CURRENT reading (silent if moving, correct
instruction if settled). VoiceSpeaker drops its pending-text queue and just
exposes isReady; the policy is remember(speaker) so each VOICE session starts
fresh. Covers all of Codex's cancellation cases (movement, unlock, invalid
placement, foreground loss, mode change, shutdown).

Velocity-aware Surface lock (Jay's observation + Codex): the fixed 400 ms
dwell made a slowly-arriving bubble wait, delaying the level sound, while a
fast crossing could still lock. LockDetector now takes a movement rate:
- error <=0.2 deg AND rate <=0.3 deg/s -> lock after a short 175 ms confirm;
- faster than that -> dwell never accumulates (a center fly-through never
  locks); slowing to a stop inside the zone starts a fresh confirmation;
- 0.35 deg exit hysteresis and the single shared lock (sound/lime/haptic)
  unchanged.
LevelPipeline feeds SettlingDetector.movementRateDegreesPerSecond (was
discarded); Edge passes null -> classic fixed 400 ms dwell. Simple gated
machine, no adaptive-dwell formula.

Tests: slow-entry-locks-promptly, fast-traversal-never-locks, intermediate-
speed-no-accumulate, slowing-inside-zone-fresh-dwell, exit hysteresis,
feedback debounce, Edge fixed-dwell fallback. 82 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-17 10:27:47 -04:00
parent 3ebb36f265
commit 604d61ebb2
4 changed files with 133 additions and 66 deletions
@@ -4,25 +4,38 @@ import kotlin.math.abs
/**
* Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
* §Sensor and measurement architecture). Operates ONLY on the stable calibrated
* measurement — the same value the numeric readout shows. The lock must never
* disagree with the number on screen.
* §Sensor and measurement architecture). Operates on the stable calibrated measurement —
* the same value the numeric readout shows, so the lock never disagrees with the number.
*
* Time is injected (callers pass `nowMillis`) so transitions are unit-testable.
* Acquisition is VELOCITY-AWARE when a movement rate is supplied (Surface): a bubble eased
* gently into center (≤ [slowRateThresholdDegPerSec]) locks after a short [dwellMillis]
* confirmation, but a bubble flying across center never accumulates lock time — the dwell
* timer only runs while inside the zone AND moving slowly, and resets otherwise (so slowing
* to a stop inside the zone starts a fresh confirmation). This replaces a fixed long dwell,
* which forced even a slow, deliberate arrival to wait (and delayed the "level" sound).
*
* When no rate is supplied (`rateDegPerSec == null`, e.g. Edge), it falls back to the
* classic fixed [settledDwellMillis] with no velocity gate.
*
* The single lock drives sound, the lime visual, and the haptic together — there is no
* separate audio threshold. Time is injected (callers pass `nowMillis`) for testability.
*/
class LockDetector(
private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES,
private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES,
private val dwellMillis: Long = DEFAULT_DWELL_MILLIS,
private val settledDwellMillis: Long = DEFAULT_SETTLED_DWELL_MILLIS,
private val slowRateThresholdDegPerSec: Double = DEFAULT_SLOW_RATE_DEG_PER_SEC,
private val feedbackDebounceMillis: Long = DEFAULT_FEEDBACK_DEBOUNCE_MILLIS,
) {
companion object {
// BRIEF.md: enter at no more than 0.2°, exit at at least 0.35°. The exit
// threshold doubles as the tolerance stated next to the locked label, so the
// rounded readout and "level" claim can never contradict (Codex review).
// BRIEF.md: enter at no more than 0.2°, exit at at least 0.35°. The exit threshold
// doubles as the tolerance stated next to the locked label (Codex review).
const val DEFAULT_ENTER_DEGREES = 0.2
const val DEFAULT_EXIT_DEGREES = 0.35
const val DEFAULT_DWELL_MILLIS = 400L
const val DEFAULT_DWELL_MILLIS = 175L // velocity-gated: short, because a fast crossing can't accumulate
const val DEFAULT_SETTLED_DWELL_MILLIS = 400L // no-velocity fallback (Edge)
const val DEFAULT_SLOW_RATE_DEG_PER_SEC = 0.3
const val DEFAULT_FEEDBACK_DEBOUNCE_MILLIS = 3_000L
}
@@ -42,14 +55,19 @@ class LockDetector(
private var withinEnterSinceMillis: Long? = null
private var lastFeedbackAtMillis: Long? = null
fun update(stableTiltDegrees: Double, nowMillis: Long): Result {
fun update(stableTiltDegrees: Double, rateDegPerSec: Double?, nowMillis: Long): Result {
val magnitude = abs(stableTiltDegrees)
var fire = false
if (!locked) {
if (magnitude <= enterThresholdDegrees) {
val inZone = magnitude <= enterThresholdDegrees
// With a velocity signal, require slow motion to accumulate; a fast center
// crossing (rate above the threshold) never builds lock time.
val slowEnough = rateDegPerSec == null || rateDegPerSec <= slowRateThresholdDegPerSec
val requiredDwell = if (rateDegPerSec == null) settledDwellMillis else dwellMillis
if (inZone && slowEnough) {
val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
if (nowMillis - since >= dwellMillis) {
if (nowMillis - since >= requiredDwell) {
locked = true
val last = lastFeedbackAtMillis
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
@@ -58,6 +76,8 @@ class LockDetector(
}
}
} else {
// Out of zone or moving too fast: reset, so a fresh slow-and-centered spell
// starts the confirmation dwell over.
withinEnterSinceMillis = null
}
} else if (magnitude >= exitThresholdDegrees) {
@@ -38,7 +38,6 @@ import java.util.Locale
fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
val isForeground = rememberIsResumed()
val tickPolicy = remember { SurfaceTickPolicy() }
val voicePolicy = remember { VoiceGuidancePolicy() }
val context = LocalContext.current.applicationContext
val player = remember(mode, isForeground, context) {
@@ -47,6 +46,10 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
val speaker = remember(mode, isForeground, context) {
if (mode == AudioAssistMode.VOICE && isForeground) VoiceSpeaker(context) else null
}
// Fresh per speaker so each VOICE session re-evaluates from the current reading — and
// the policy is only consumed once the speaker is ready, so a phrase can't be queued
// and then go stale during TTS init (Codex).
val voicePolicy = remember(speaker) { VoiceGuidancePolicy() }
DisposableEffect(player) { onDispose { player?.release() } }
DisposableEffect(speaker) { onDispose { speaker?.shutdown() } }
@@ -74,20 +77,25 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
null -> Unit
}
val phrase = voicePolicy.update(
VoiceGuidancePolicy.Input(
isEnabled = mode == AudioAssistMode.VOICE,
isAppForeground = isForeground,
placementOk = current.placementOk,
presentation = current.surfacePresentation,
isSettling = current.isSettling,
pitchDegrees = current.stableSurfacePitchDegrees ?: 0.0,
rollDegrees = current.stableSurfaceRollDegrees ?: 0.0,
isLocked = current.isLocked,
nowMillis = System.currentTimeMillis(),
),
)
if (phrase != null) speaker?.speak(phrase)
// Only run the voice policy once the speaker is ready — never consume its state
// for a phrase we can't speak yet. When ready, it evaluates the current reading,
// so nothing stale gets spoken after init finishes.
if (speaker?.isReady == true) {
val phrase = voicePolicy.update(
VoiceGuidancePolicy.Input(
isEnabled = true,
isAppForeground = isForeground,
placementOk = current.placementOk,
presentation = current.surfacePresentation,
isSettling = current.isSettling,
pitchDegrees = current.stableSurfacePitchDegrees ?: 0.0,
rollDegrees = current.stableSurfaceRollDegrees ?: 0.0,
isLocked = current.isLocked,
nowMillis = System.currentTimeMillis(),
),
)
if (phrase != null) speaker.speak(phrase)
}
}
}
@@ -176,8 +184,9 @@ private class SonarSoundPool(context: Context) {
* TODO(voice): prefer bundled clips (res/raw) when present, fall back to TTS.
*/
private class VoiceSpeaker(context: Context) {
private var ready = false
private var pendingText: String? = null // latest phrase asked for before TTS finished init
/** The caller waits on this before consuming the voice policy, so nothing goes stale. */
var isReady = false
private set
private lateinit var tts: TextToSpeech
init {
@@ -190,18 +199,13 @@ private class VoiceSpeaker(context: Context) {
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.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
isReady = true
}
}
}
fun speak(phrase: VoiceGuidancePolicy.Phrase) {
val text = textFor(phrase)
if (ready) tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "level-voice") else pendingText = text
if (isReady) tts.speak(textFor(phrase), TextToSpeech.QUEUE_FLUSH, null, "level-voice")
}
private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) {
@@ -89,7 +89,8 @@ class LevelPipeline(
val displayMagnitude = primaryDeadband.update(magnitude)
val presentation = surfacePresentationDetector.update(displayMagnitude)
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
val lock = lockDetector.update(magnitude, nowMillis)
// Velocity-aware lock: ease into center → quick confirm; fly across → nothing.
val lock = lockDetector.update(magnitude, settling.movementRateDegreesPerSecond, nowMillis)
LevelReading(
mode = mode,
displayPrimaryDegrees = displayMagnitude,
@@ -123,8 +124,10 @@ class LevelPipeline(
OrientationMath.edgePlumbLeanDegrees(corrected),
dtSeconds,
)
// Edge has no movement-rate signal yet: null → classic fixed-dwell lock.
val lock = lockDetector.update(
if (placementOk) level else Double.MAX_VALUE,
null,
nowMillis,
)
val displayLevel = primaryDeadband.update(level)
@@ -9,65 +9,105 @@ class LockDetectorTest {
private fun detector() = LockDetector(
enterThresholdDegrees = 0.2,
exitThresholdDegrees = 0.35,
dwellMillis = 400,
dwellMillis = 175,
settledDwellMillis = 400,
slowRateThresholdDegPerSec = 0.3,
feedbackDebounceMillis = 3_000,
)
private val slow = 0.1 // °/s, below the slow-rate threshold
private val fast = 2.0 // °/s, a real center crossing
@Test
fun `lock requires dwell time inside the enter threshold`() {
fun `easing slowly into center locks after the short dwell`() {
val d = detector()
assertFalse(d.update(0.1, 0).isLocked)
assertFalse(d.update(0.1, 200).isLocked)
val result = d.update(0.1, 450)
assertTrue(result.isLocked)
assertTrue(result.fireFeedback)
assertFalse(d.update(0.1, slow, 0).isLocked)
assertFalse(d.update(0.1, slow, 174).isLocked)
val r = d.update(0.1, slow, 175)
assertTrue(r.isLocked)
assertTrue(r.fireFeedback)
}
@Test
fun `leaving the threshold before dwell completes resets the timer`() {
fun `flying across center never locks, however long it stays in the zone`() {
val d = detector()
d.update(0.1, 0)
d.update(0.5, 200) // bounced out
d.update(0.1, 300) // back in — dwell restarts
assertFalse(d.update(0.1, 600).isLocked) // only 300ms since re-entry
assertTrue(d.update(0.1, 750).isLocked)
// In the zone the whole time, but moving fast: the dwell never accumulates.
for (t in 0..2_000 step 20) assertFalse(d.update(0.05, fast, t.toLong()).isLocked)
}
@Test
fun `intermediate speed does not accumulate lock time`() {
val d = detector()
val intermediate = 0.5 // between slow (0.3) and a crossing
for (t in 0..2_000 step 20) assertFalse(d.update(0.1, intermediate, t.toLong()).isLocked)
}
@Test
fun `slowing to a stop inside the zone starts a fresh confirmation dwell`() {
val d = detector()
// In the zone but moving fast until t=300: no accumulation yet.
d.update(0.1, fast, 0)
d.update(0.1, fast, 300)
// Now slow down: dwell starts fresh here, so it must NOT be locked at +100ms...
assertFalse(d.update(0.1, slow, 320).isLocked)
assertFalse(d.update(0.1, slow, 420).isLocked)
// ...but locks 175ms after slowing.
assertTrue(d.update(0.1, slow, 495).isLocked)
}
@Test
fun `leaving the zone before dwell completes resets the timer`() {
val d = detector()
d.update(0.1, slow, 0)
d.update(0.5, slow, 100) // bounced out of the zone
d.update(0.1, slow, 150) // back in — dwell restarts
assertFalse(d.update(0.1, slow, 300).isLocked) // only 150ms since re-entry
assertTrue(d.update(0.1, slow, 325).isLocked)
}
@Test
fun `hysteresis holds the lock between enter and exit thresholds`() {
val d = detector()
d.update(0.1, 0)
assertTrue(d.update(0.1, 500).isLocked)
// 0.3° is above enter (0.2) but below exit (0.35): still locked.
assertTrue(d.update(0.3, 600).isLocked)
d.update(0.1, slow, 0)
assertTrue(d.update(0.1, slow, 200).isLocked)
// 0.3° is above enter (0.2) but below exit (0.35): still locked (velocity irrelevant once locked).
assertTrue(d.update(0.3, fast, 300).isLocked)
// 0.4° exceeds the exit threshold: unlocked.
assertFalse(d.update(0.4, 700).isLocked)
assertFalse(d.update(0.4, slow, 400).isLocked)
}
@Test
fun `feedback fires once per lock and respects the debounce window`() {
val d = detector()
d.update(0.1, 0)
assertTrue(d.update(0.1, 500).fireFeedback)
assertFalse(d.update(0.1, 600).fireFeedback) // still locked, no re-fire
d.update(0.1, slow, 0)
assertTrue(d.update(0.1, slow, 200).fireFeedback)
assertFalse(d.update(0.1, slow, 300).fireFeedback) // still locked, no re-fire
// Rock out and back in quickly: re-lock at ~1500ms is inside the 3s debounce.
d.update(0.5, 900)
d.update(0.1, 1000)
val relock = d.update(0.1, 1500)
// Rock out and back quickly: re-lock at ~1500ms is inside the 3s debounce.
d.update(0.5, fast, 900)
d.update(0.1, slow, 1000)
val relock = d.update(0.1, slow, 1500)
assertTrue(relock.isLocked)
assertFalse(relock.fireFeedback)
// A re-lock after the debounce window fires again.
d.update(0.5, 2000)
d.update(0.1, 4000)
assertTrue(d.update(0.1, 4500).fireFeedback)
d.update(0.5, fast, 4000)
d.update(0.1, slow, 4100)
assertTrue(d.update(0.1, slow, 4400).fireFeedback)
}
@Test
fun `without a velocity signal it uses the fixed settled dwell (Edge)`() {
val d = detector()
assertFalse(d.update(0.1, null, 0).isLocked)
assertFalse(d.update(0.1, null, 399).isLocked) // 175 has passed, but the no-rate path needs 400
assertTrue(d.update(0.1, null, 400).isLocked)
}
@Test
fun `negative readings lock on magnitude`() {
val d = detector()
d.update(-0.1, 0)
assertTrue(d.update(-0.1, 500).isLocked)
d.update(-0.1, slow, 0)
assertTrue(d.update(-0.1, slow, 200).isLocked)
}
}