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:
@@ -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,9 +77,13 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
// 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 = mode == AudioAssistMode.VOICE,
|
||||
isEnabled = true,
|
||||
isAppForeground = isForeground,
|
||||
placementOk = current.placementOk,
|
||||
presentation = current.surfacePresentation,
|
||||
@@ -87,7 +94,8 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
||||
nowMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
if (phrase != null) speaker?.speak(phrase)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user