Audio: instant bullseye cue on alignment, decoupled from the held lock

Three-way consensus (Jay/Codex/Claude): one sound, split timing not identity.
The bullseye sound now means 'centered right now' and fires the instant you're
aligned - even a fast pass over center - so the zone that needs the most help
locating always announces itself. It no longer waits on the velocity+dwell
lock, which was the delay near center.

- SurfaceTickPolicy: outside the center zone, proximity ticks (unchanged
  anchor scheduler). Inside, emit ALIGNED every frame -> caller loops level.wav.
  Entry is immediate (no dwell, no velocity gate). Alignment uses SPATIAL
  hysteresis (enter <=0.2, rearm only after leaving >=0.35) - not a time
  debounce, so a legitimate quick re-crossing still announces. Ticks resume
  immediately on exit.
- Alignment error = hypot(stable pitch, stable roll) - the SAME axes that drive
  the bubble, so the sound can't lag the visual (Codex's implementation note).
- Player: level.wav loops while aligned (ensureLevelLooping, idempotent),
  stopped before every restart and on exit; pending-load queue retained.
- The held-level confirmation (persistent lime, label, haptic) stays on the
  velocity-aware LockDetector; the locating sound is fully decoupled from it.

Tests: immediate cue on fast crossing, one start / ticks-suppressed while
inside, stop+resume-ticks on exit, spatial-hysteresis rearm, tick reactivity.
81 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-17 11:18:04 -04:00
parent 604d61ebb2
commit 0cb315fdcc
4 changed files with 151 additions and 140 deletions
+20 -11
View File
@@ -12,17 +12,26 @@ controls it) and never takes audio focus.
Two modes / two mechanisms, one sound identity (all derived from a 500 Hz blip): Two modes / two mechanisms, one sound identity (all derived from a 500 Hz blip):
**SURFACE (2-D) — TONES = continuous proximity ticking.** A tick (`tick.wav`, 60 ms) repeats **SURFACE (2-D) — TONES.** One vocabulary: **faster ticks = approaching, the bullseye sound =
faster as you near level — axis-agnostic "warmer/colder", the RATE is the whole message (a centered right now.** Timing is split, not sound identity:
bubble level asks nothing of you). **No settle gate** — it ticks at every point in the
adjustment. Rate is exponential in the stable tilt error, clamped **1.5/s (≥~5°) → 8/s - *Outside the center zone:* `tick.wav` (60 ms) repeats faster as you near level — axis-agnostic
(≤~0.4°)**, hard-capped at 8/s. **Constant loudness** (acceleration is the signal, not volume). "warmer/colder", rate is the message. No settle gate. Rate is exponential in the error,
On lock (reusing `LockDetector.isLocked` — dwell + hysteresis, no second audio threshold): clamped **1.5/s (≥~5°) → 8/s (≤~0.4°)**, hard-capped. **Constant loudness** (acceleration is
ticking stops, `level.wav` plays once, silence while it holds; unlock resumes ticking. the signal). Scheduling is anchored to the last tick off a **monotonic clock**, due-time
Scheduling is deadline-based off a **monotonic clock** — advances a next-tick deadline (no recomputed each frame from the current interval → immediate reaction to changing tilt, no
drift, no catch-up bursts, ≤1 tick per sensor update); re-entry (enable TONES / return drift, no catch-up burst, ≤1 tick per update.
FACE_UP / unlock) ticks immediately. `level.wav`'s 1.39 s stream is retained and stopped on - *Inside the center zone:* the ticks give way to the looping `level.wav` **immediately — no
unlock/disable so a fresh tick can't overlap its tail. dwell, no velocity gate — even on a fast pass**, because a momentary alignment is real,
locating information (that little zone needs the most help). It stops the instant you leave.
Alignment uses **spatial hysteresis** (enter ≤0.2°, rearm only after leaving ≥0.35°) — not a
time debounce, which would hide a legitimate quick re-crossing — and is measured from the
**same hypot(stable pitch, stable roll)** that drives the bubble, so the sound can't lag the
visual. `level.wav`'s stream id is retained and stopped before every restart / on exit.
- The stronger **"held level" confirmation — persistent lime, the "Flat within 0.35°" label,
and the haptic — is decided separately** by the velocity-aware `LockDetector` (≤0.2° + ≤0.3°/s
→ 175 ms confirm). The locating *sound* never waits on it; a fast fly-through gets the bullseye
cue but not the "you nailed it" confirmation.
**EDGE (1-D, future) — the "sonar staircase."** Direction + magnitude as a 4-blip pitch **EDGE (1-D, future) — the "sonar staircase."** Direction + magnitude as a 4-blip pitch
contour (rising = raise it, falling = lower it, steepness = how far; level sounds flat), one contour (rising = raise it, falling = lower it, steepness = how far; level sounds flat), one
@@ -4,35 +4,35 @@ import com.onthelevel.core.sensors.SurfacePresentation
import kotlin.math.pow import kotlin.math.pow
/** /**
* Pure scheduler for Surface proximity ticking (TONES mode). Continuous "warmer/colder": * Pure scheduler for Surface TONES. One audible vocabulary: faster ticks mean "approaching",
* a tick repeats faster as you near level — the RATE is the whole message, axis-agnostic, * and the bullseye sound means "you are centered right now" (SOUND_DESIGN_HANDOFF.md + the
* exactly like a bubble level asks nothing of you (SOUND_DESIGN_HANDOFF.md). No settle * three-way audio consensus).
* gate — it ticks at every point in the adjustment.
* *
* Rate is exponential in the stable tilt error, clamped [MIN_RATE, MAX_RATE]; loudness is * Two states, split by TIMING not by sound identity:
* constant (the caller plays every tick at the same volume — acceleration is the signal, * - Outside the center zone: proximity ticks whose rate rises as you near level (exponential,
* not volume). Centered reuses the real lock (LockDetector's dwell + hysteresis): on the * clamped). Continuous "warmer/colder"; the rate is the message.
* lock transition it emits [Cue.LEVEL] once, then stays silent until unlock. * - Inside the center zone: emit [Cue.ALIGNED] every frame so the caller holds the bullseye
* sound. Entry is IMMEDIATE — no dwell, no velocity gate — so even a fast pass over center
* is announced (that's the locating cue). Ticks are suppressed while aligned.
* *
* Scheduling is anchored to the last tick off a MONOTONIC clock (caller passes nowMillis). * Alignment uses SPATIAL hysteresis (enter ≤ [alignEnterDegrees], rearm only after leaving
* The due time is recomputed EVERY update from that anchor plus the CURRENT interval, so a * ≥ [alignExitDegrees]) — not a time debounce, which would hide a legitimate quick re-crossing.
* change in tilt takes effect immediately — if you drop from 5° to 0.4°, the next tick * The error is the same hypot(pitch, roll) that drives the bubble, so the sound can't lag the
* accelerates to the fast cadence instead of waiting out the stale slow interval. The anchor * visual. The stronger "held level" confirmation (lime/label/haptic) is decided separately by
* advances by whole intervals (no drift, just a constant poll latency); if it ever falls a * the velocity-aware LockDetector; this policy does not gate that.
* 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( class SurfaceTickPolicy(
private val nearDegrees: Double = NEAR_DEGREES, private val nearDegrees: Double = NEAR_DEGREES,
private val farDegrees: Double = FAR_DEGREES, private val farDegrees: Double = FAR_DEGREES,
private val maxRatePerSecond: Double = MAX_RATE, private val maxRatePerSecond: Double = MAX_RATE,
private val minRatePerSecond: Double = MIN_RATE, private val minRatePerSecond: Double = MIN_RATE,
private val alignEnterDegrees: Double = ALIGN_ENTER_DEGREES,
private val alignExitDegrees: Double = ALIGN_EXIT_DEGREES,
) { ) {
enum class Cue { TICK, LEVEL } enum class Cue { TICK, ALIGNED }
private var lastTickAtMillis: Long? = null // anchor: when the last tick actually fired private var lastTickAtMillis: Long? = null
private var wasLocked = false private var aligned = false
fun update(input: Input): Cue? { fun update(input: Input): Cue? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk || if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
@@ -42,26 +42,31 @@ class SurfaceTickPolicy(
return null return null
} }
if (input.isLocked) { // Spatial hysteresis: enter the zone at the tight threshold, rearm only after
lastTickAtMillis = null // cancel any pending tick // swinging back out past the loose one. No time debounce.
val justLocked = !wasLocked if (aligned) {
wasLocked = true if (input.errorDegrees >= alignExitDegrees) aligned = false
return if (justLocked) Cue.LEVEL else null } else {
if (input.errorDegrees <= alignEnterDegrees) aligned = true
} }
wasLocked = false
if (aligned) {
lastTickAtMillis = null // so ticks resume immediately the moment we exit
return Cue.ALIGNED
}
// Outside the zone: proximity ticks, anchored to the last tick, due-time recomputed
// every frame from the current interval (immediate reaction to changing tilt; no drift,
// no catch-up burst). At most one tick per update.
val now = input.nowMillis val now = input.nowMillis
val anchor = lastTickAtMillis val anchor = lastTickAtMillis
if (anchor == null) { if (anchor == null) {
// Re-entry: tick immediately. lastTickAtMillis = now // re-entry / just exited the zone: tick immediately
lastTickAtMillis = now
return Cue.TICK return Cue.TICK
} }
val interval = intervalMillis(input.errorDegrees) // current interval, recomputed each frame val interval = intervalMillis(input.errorDegrees)
val due = anchor + interval val due = anchor + interval
if (now >= due) { 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 lastTickAtMillis = if (now - due >= interval) now else due
return Cue.TICK return Cue.TICK
} }
@@ -78,7 +83,7 @@ class SurfaceTickPolicy(
fun reset() { fun reset() {
lastTickAtMillis = null lastTickAtMillis = null
wasLocked = false aligned = false
} }
data class Input( data class Input(
@@ -87,7 +92,6 @@ class SurfaceTickPolicy(
val placementOk: Boolean, val placementOk: Boolean,
val surfacePresentation: SurfacePresentation?, val surfacePresentation: SurfacePresentation?,
val errorDegrees: Double, val errorDegrees: Double,
val isLocked: Boolean,
val nowMillis: Long, val nowMillis: Long,
) )
@@ -96,5 +100,7 @@ class SurfaceTickPolicy(
const val FAR_DEGREES = 5.0 const val FAR_DEGREES = 5.0
const val MAX_RATE = 8.0 const val MAX_RATE = 8.0
const val MIN_RATE = 1.5 const val MIN_RATE = 1.5
const val ALIGN_ENTER_DEGREES = 0.2
const val ALIGN_EXIT_DEGREES = 0.35
} }
} }
@@ -21,13 +21,16 @@ import com.onthelevel.core.audio.SurfaceTickPolicy
import com.onthelevel.core.audio.VoiceGuidancePolicy import com.onthelevel.core.audio.VoiceGuidancePolicy
import com.onthelevel.core.settings.AudioAssistMode import com.onthelevel.core.settings.AudioAssistMode
import java.util.Locale import java.util.Locale
import kotlin.math.hypot
/** /**
* Hands-free Surface audio assist (SOUND_DESIGN_HANDOFF.md). * Hands-free Surface audio assist (SOUND_DESIGN_HANDOFF.md).
* *
* TONES — continuous proximity ticking: a tick repeats faster as you near level (axis- * TONES — proximity ticks that speed up as you near level; the moment you're on the
* agnostic "warmer/colder"), the rate is the message. On lock, `level.wav` plays once and * bullseye (aligned zone, spatial hysteresis) the ticks give way to the looping `level.wav`
* it goes silent while it holds. Constant tick loudness (acceleration is the signal). * bullseye sound — immediately, even on a fast pass. One vocabulary: faster ticks = closer,
* bullseye sound = centered now. The persistent lime/label/haptic "held level" is separate
* (velocity-aware LockDetector); the sound does not wait on it.
* *
* VOICE — deliberately separate: spoken corrections + "that's level", settled-change only. * VOICE — deliberately separate: spoken corrections + "that's level", settled-change only.
* *
@@ -56,25 +59,35 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
LaunchedEffect(reading, mode, isForeground, player, speaker) { LaunchedEffect(reading, mode, isForeground, player, speaker) {
val current = reading ?: return@LaunchedEffect val current = reading ?: return@LaunchedEffect
// TONES: proximity ticks off a monotonic anchor; `level.wav` at lock. // TONES: proximity ticks outside the center zone; the bullseye sound the instant
val tickCue = tickPolicy.update( // you're aligned. Alignment uses the SAME axes as the bubble (hypot of stable
SurfaceTickPolicy.Input( // pitch/roll), so the sound can't lag the visual.
isEnabled = mode == AudioAssistMode.TONES, val alignError = hypot(
isAppForeground = isForeground, current.stableSurfacePitchDegrees ?: 0.0,
placementOk = current.placementOk, current.stableSurfaceRollDegrees ?: 0.0,
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 when (
// briefly overlap the two (order matters — Codex P2). tickPolicy.update(
if (!(mode == AudioAssistMode.TONES && current.isLocked)) player?.stopLevel() SurfaceTickPolicy.Input(
when (tickCue) { isEnabled = mode == AudioAssistMode.TONES,
SurfaceTickPolicy.Cue.TICK -> player?.playTick() isAppForeground = isForeground,
SurfaceTickPolicy.Cue.LEVEL -> player?.playLevel() placementOk = current.placementOk,
null -> Unit surfacePresentation = current.surfacePresentation,
errorDegrees = alignError,
nowMillis = SystemClock.elapsedRealtime(),
),
)
) {
// Aligned: hold the looping bullseye sound (idempotent — starts once).
SurfaceTickPolicy.Cue.ALIGNED -> player?.ensureLevelLooping()
// A tick means we're outside the zone: stop the bullseye first (order matters),
// then tick.
SurfaceTickPolicy.Cue.TICK -> {
player?.stopLevel()
player?.playTick()
}
// Outside the zone, no tick due: make sure the bullseye is stopped (covers exit).
null -> player?.stopLevel()
} }
// Only run the voice policy once the speaker is ready — never consume its state // Only run the voice policy once the speaker is ready — never consume its state
@@ -139,7 +152,7 @@ private class SonarSoundPool(context: Context) {
// play a queued Level the moment its sample lands. // play a queued Level the moment its sample lands.
if (sampleId == levelSound && levelPending) { if (sampleId == levelSound && levelPending) {
levelPending = false levelPending = false
startLevel() startLevelLoop()
} }
} }
} }
@@ -152,18 +165,21 @@ private class SonarSoundPool(context: Context) {
if (tickSound in loaded) soundPool.play(tickSound, TICK_VOLUME, TICK_VOLUME, 1, 0, 1f) 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. Queues if not loaded. */ /**
fun playLevel() { * The bullseye sound, looped while you're aligned. Idempotent — safe to call every frame;
stopLevel() * starts once and keeps going. Queues if the sample hasn't loaded yet.
if (levelSound in loaded) startLevel() else levelPending = true */
fun ensureLevelLooping() {
if (levelStreamId != 0) return // already looping
if (levelSound in loaded) startLevelLoop() else levelPending = true
} }
private fun startLevel() { private fun startLevelLoop() {
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f) levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, -1, 1f)
} }
fun stopLevel() { fun stopLevel() {
levelPending = false // cancel a queued Level (unlock/gate invalidated it) levelPending = false // cancel a queued start (we left the zone before it loaded)
if (levelStreamId != 0) { if (levelStreamId != 0) {
soundPool.stop(levelStreamId) soundPool.stop(levelStreamId)
levelStreamId = 0 levelStreamId = 0
@@ -15,31 +15,31 @@ class SurfaceTickPolicyTest {
foreground: Boolean = true, foreground: Boolean = true,
placementOk: Boolean = true, placementOk: Boolean = true,
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP, presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
locked: Boolean = false,
) = SurfaceTickPolicy.Input( ) = SurfaceTickPolicy.Input(
isEnabled = enabled, isEnabled = enabled,
isAppForeground = foreground, isAppForeground = foreground,
placementOk = placementOk, placementOk = placementOk,
surfacePresentation = presentation, surfacePresentation = presentation,
errorDegrees = error, errorDegrees = error,
isLocked = locked,
nowMillis = now, nowMillis = now,
) )
// --- Proximity ticking (outside the center zone) ---
@Test @Test
fun `rate hits both anchors and clamps beyond them`() { fun `rate hits both anchors and clamps beyond them`() {
val p = SurfaceTickPolicy() val p = SurfaceTickPolicy()
assertEquals(8.0, p.tickRatePerSecond(0.4), 1e-9) assertEquals(8.0, p.tickRatePerSecond(0.4), 1e-9)
assertEquals(1.5, p.tickRatePerSecond(5.0), 1e-9) assertEquals(1.5, p.tickRatePerSecond(5.0), 1e-9)
assertEquals(8.0, p.tickRatePerSecond(0.1), 1e-9) // clamp near assertEquals(8.0, p.tickRatePerSecond(0.1), 1e-9)
assertEquals(1.5, p.tickRatePerSecond(12.0), 1e-9) // clamp far assertEquals(1.5, p.tickRatePerSecond(12.0), 1e-9)
} }
@Test @Test
fun `rate is monotonic - closer is always faster`() { fun `rate is monotonic - closer is always faster`() {
val p = SurfaceTickPolicy() val p = SurfaceTickPolicy()
val rates = listOf(0.4, 1.0, 2.0, 3.0, 4.0, 5.0).map { p.tickRatePerSecond(it) } val rates = listOf(0.4, 1.0, 2.0, 3.0, 4.0, 5.0).map { p.tickRatePerSecond(it) }
for (i in 1 until rates.size) assertTrue("rates must decrease with error", rates[i] < rates[i - 1]) for (i in 1 until rates.size) assertTrue(rates[i] < rates[i - 1])
} }
@Test @Test
@@ -52,79 +52,59 @@ class SurfaceTickPolicyTest {
} }
@Test @Test
fun `re-entry ticks immediately`() { fun `ticks fire on the current interval and react immediately to changing tilt`() {
val p = SurfaceTickPolicy() val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 0))) assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 5.0, now = 0))) // first tick, anchor 0
// Gate off, then back on: immediate tick again. // Drop toward center (still outside the 0.2 zone): next tick uses the FAST interval.
assertNull(p.update(input(enabled = false, now = 10)))
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 20)))
}
@Test
fun `ticks on the interval and does not double-fire before it`() {
val p = SurfaceTickPolicy()
val interval = p.intervalMillis(2.0)
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 0)))
assertNull(p.update(input(error = 2.0, now = interval - 1)))
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = interval)))
}
@Test
fun `a long gap yields one tick, not a catch-up burst`() {
val p = SurfaceTickPolicy()
val interval = p.intervalMillis(2.0)
p.update(input(error = 2.0, now = 0)) // first tick, deadline = interval
// Jump far past many intervals: exactly one tick fires...
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 10_000)))
// ...and the next deadline is in the future, so the very next update is silent.
assertNull(p.update(input(error = 2.0, now = 10_001)))
}
@Test
fun `deadline advances without drifting slow`() {
val p = SurfaceTickPolicy()
val interval = p.intervalMillis(2.0)
var ticks = 0
// Poll every 20 ms (like the sensor); count ticks over 10 nominal intervals.
val end = interval * 10
var now = 0L
while (now <= end) {
if (p.update(input(error = 2.0, now = now)) == SurfaceTickPolicy.Cue.TICK) ticks++
now += 20
}
// Should be ~10 ticks (first is immediate), not fewer from accumulated lateness.
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))) assertNull(p.update(input(error = 0.4, now = 124)))
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 125))) assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 125)))
} }
@Test @Test
fun `near to far slows immediately - no leftover fast tick`() { fun `a long gap yields one tick, not a catch-up burst`() {
val p = SurfaceTickPolicy() val p = SurfaceTickPolicy()
p.update(input(error = 0.4, now = 0)) // first tick at 0.4°, anchor = 0 p.update(input(error = 2.0, now = 0))
// Board jumps to 5° at t=50. The old fast interval (125 ms) must NOT fire; the assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 10_000)))
// next tick waits for the slow interval (~667 ms). assertNull(p.update(input(error = 2.0, now = 10_001)))
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))) // --- Bullseye alignment (inside the center zone) ---
@Test
fun `entering the zone announces immediately - even on a fast pass, no dwell`() {
val p = SurfaceTickPolicy()
p.update(input(error = 3.0, now = 0)) // ticking, outside
// One frame later it's already inside (a fast sweep): aligned right away.
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 20)))
} }
@Test @Test
fun `level fires once per lock, then silence, then ticks resume on unlock`() { fun `stays aligned every frame while inside, ticks suppressed`() {
val p = SurfaceTickPolicy() val p = SurfaceTickPolicy()
p.update(input(error = 2.0, now = 0)) // ticking assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = 0)))
assertEquals(SurfaceTickPolicy.Cue.LEVEL, p.update(input(locked = true, now = 100))) for (t in 20..2_000 step 20) {
assertNull(p.update(input(locked = true, now = 200))) assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = t.toLong())))
assertNull(p.update(input(locked = true, now = 5_000))) }
// Unlock: immediate tick again. }
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, locked = false, now = 5_100)))
@Test
fun `spatial hysteresis - holds aligned to 0_35, resumes ticks past it`() {
val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 0)))
// 0.3 is past the 0.2 enter but below the 0.35 exit: still aligned.
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.3, now = 20)))
// Beyond 0.35: exit, and ticks resume immediately (anchor cleared on exit).
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 40)))
}
@Test
fun `re-crossing rearms only after leaving the exit band, then announces again`() {
val p = SurfaceTickPolicy()
p.update(input(error = 0.1, now = 0)) // aligned
p.update(input(error = 0.25, now = 20)) // 0.2 < 0.25 < 0.35: still aligned (not rearmed)
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.25, now = 40)))
// Swing out past 0.35, then back to center: announces again.
p.update(input(error = 0.5, now = 60)) // exits
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 80)))
} }
} }