diff --git a/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt b/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt index 6b52d77..aff0cbc 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt @@ -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) { diff --git a/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt index 898d31f..6b9f7da 100644 --- a/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt +++ b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt @@ -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) { diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt index 8900bcd..4f4208f 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt @@ -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) diff --git a/app/src/test/java/com/onthelevel/core/sensors/LockDetectorTest.kt b/app/src/test/java/com/onthelevel/core/sensors/LockDetectorTest.kt index c639dc6..530d3c6 100644 --- a/app/src/test/java/com/onthelevel/core/sensors/LockDetectorTest.kt +++ b/app/src/test/java/com/onthelevel/core/sensors/LockDetectorTest.kt @@ -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) } }