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:
+20
-11
@@ -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):
|
||||
|
||||
**SURFACE (2-D) — TONES = continuous proximity ticking.** A tick (`tick.wav`, 60 ms) repeats
|
||||
faster as you near level — axis-agnostic "warmer/colder", the RATE is the whole message (a
|
||||
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
|
||||
(≤~0.4°)**, hard-capped at 8/s. **Constant loudness** (acceleration is the signal, not volume).
|
||||
On lock (reusing `LockDetector.isLocked` — dwell + hysteresis, no second audio threshold):
|
||||
ticking stops, `level.wav` plays once, silence while it holds; unlock resumes ticking.
|
||||
Scheduling is deadline-based off a **monotonic clock** — advances a next-tick deadline (no
|
||||
drift, no catch-up bursts, ≤1 tick per sensor update); re-entry (enable TONES / return
|
||||
FACE_UP / unlock) ticks immediately. `level.wav`'s 1.39 s stream is retained and stopped on
|
||||
unlock/disable so a fresh tick can't overlap its tail.
|
||||
**SURFACE (2-D) — TONES.** One vocabulary: **faster ticks = approaching, the bullseye sound =
|
||||
centered right now.** Timing is split, not sound identity:
|
||||
|
||||
- *Outside the center zone:* `tick.wav` (60 ms) repeats faster as you near level — axis-agnostic
|
||||
"warmer/colder", rate is the message. No settle gate. Rate is exponential in the error,
|
||||
clamped **1.5/s (≥~5°) → 8/s (≤~0.4°)**, hard-capped. **Constant loudness** (acceleration is
|
||||
the signal). Scheduling is anchored to the last tick off a **monotonic clock**, due-time
|
||||
recomputed each frame from the current interval → immediate reaction to changing tilt, no
|
||||
drift, no catch-up burst, ≤1 tick per update.
|
||||
- *Inside the center zone:* the ticks give way to the looping `level.wav` **immediately — no
|
||||
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
|
||||
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
|
||||
|
||||
/**
|
||||
* Pure scheduler for Surface proximity ticking (TONES mode). Continuous "warmer/colder":
|
||||
* a tick repeats faster as you near level — the RATE is the whole message, axis-agnostic,
|
||||
* exactly like a bubble level asks nothing of you (SOUND_DESIGN_HANDOFF.md). No settle
|
||||
* gate — it ticks at every point in the adjustment.
|
||||
* Pure scheduler for Surface TONES. One audible vocabulary: faster ticks mean "approaching",
|
||||
* and the bullseye sound means "you are centered right now" (SOUND_DESIGN_HANDOFF.md + the
|
||||
* three-way audio consensus).
|
||||
*
|
||||
* Rate is exponential in the stable tilt error, clamped [MIN_RATE, MAX_RATE]; loudness is
|
||||
* constant (the caller plays every tick at the same volume — acceleration is the signal,
|
||||
* 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.
|
||||
* Two states, split by TIMING not by sound identity:
|
||||
* - Outside the center zone: proximity ticks whose rate rises as you near level (exponential,
|
||||
* clamped). Continuous "warmer/colder"; the rate is the message.
|
||||
* - 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).
|
||||
* 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.
|
||||
* Alignment uses SPATIAL hysteresis (enter ≤ [alignEnterDegrees], rearm only after leaving
|
||||
* ≥ [alignExitDegrees]) — not a time debounce, which would hide a legitimate quick re-crossing.
|
||||
* The error is the same hypot(pitch, roll) that drives the bubble, so the sound can't lag the
|
||||
* visual. The stronger "held level" confirmation (lime/label/haptic) is decided separately by
|
||||
* the velocity-aware LockDetector; this policy does not gate that.
|
||||
*/
|
||||
class SurfaceTickPolicy(
|
||||
private val nearDegrees: Double = NEAR_DEGREES,
|
||||
private val farDegrees: Double = FAR_DEGREES,
|
||||
private val maxRatePerSecond: Double = MAX_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 wasLocked = false
|
||||
private var lastTickAtMillis: Long? = null
|
||||
private var aligned = false
|
||||
|
||||
fun update(input: Input): Cue? {
|
||||
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
|
||||
@@ -42,26 +42,31 @@ class SurfaceTickPolicy(
|
||||
return null
|
||||
}
|
||||
|
||||
if (input.isLocked) {
|
||||
lastTickAtMillis = null // cancel any pending tick
|
||||
val justLocked = !wasLocked
|
||||
wasLocked = true
|
||||
return if (justLocked) Cue.LEVEL else null
|
||||
// Spatial hysteresis: enter the zone at the tight threshold, rearm only after
|
||||
// swinging back out past the loose one. No time debounce.
|
||||
if (aligned) {
|
||||
if (input.errorDegrees >= alignExitDegrees) aligned = false
|
||||
} 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 anchor = lastTickAtMillis
|
||||
if (anchor == null) {
|
||||
// Re-entry: tick immediately.
|
||||
lastTickAtMillis = now
|
||||
lastTickAtMillis = now // re-entry / just exited the zone: tick immediately
|
||||
return Cue.TICK
|
||||
}
|
||||
val interval = intervalMillis(input.errorDegrees) // current interval, recomputed each frame
|
||||
val interval = intervalMillis(input.errorDegrees)
|
||||
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
|
||||
}
|
||||
@@ -78,7 +83,7 @@ class SurfaceTickPolicy(
|
||||
|
||||
fun reset() {
|
||||
lastTickAtMillis = null
|
||||
wasLocked = false
|
||||
aligned = false
|
||||
}
|
||||
|
||||
data class Input(
|
||||
@@ -87,7 +92,6 @@ class SurfaceTickPolicy(
|
||||
val placementOk: Boolean,
|
||||
val surfacePresentation: SurfacePresentation?,
|
||||
val errorDegrees: Double,
|
||||
val isLocked: Boolean,
|
||||
val nowMillis: Long,
|
||||
)
|
||||
|
||||
@@ -96,5 +100,7 @@ class SurfaceTickPolicy(
|
||||
const val FAR_DEGREES = 5.0
|
||||
const val MAX_RATE = 8.0
|
||||
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.settings.AudioAssistMode
|
||||
import java.util.Locale
|
||||
import kotlin.math.hypot
|
||||
|
||||
/**
|
||||
* Hands-free Surface audio assist (SOUND_DESIGN_HANDOFF.md).
|
||||
*
|
||||
* TONES — continuous proximity ticking: a tick repeats faster as you near level (axis-
|
||||
* agnostic "warmer/colder"), the rate is the message. On lock, `level.wav` plays once and
|
||||
* it goes silent while it holds. Constant tick loudness (acceleration is the signal).
|
||||
* TONES — proximity ticks that speed up as you near level; the moment you're on the
|
||||
* bullseye (aligned zone, spatial hysteresis) the ticks give way to the looping `level.wav`
|
||||
* 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.
|
||||
*
|
||||
@@ -56,25 +59,35 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
||||
LaunchedEffect(reading, mode, isForeground, player, speaker) {
|
||||
val current = reading ?: return@LaunchedEffect
|
||||
|
||||
// TONES: proximity ticks off a monotonic anchor; `level.wav` at lock.
|
||||
val tickCue = tickPolicy.update(
|
||||
// TONES: proximity ticks outside the center zone; the bullseye sound the instant
|
||||
// you're aligned. Alignment uses the SAME axes as the bubble (hypot of stable
|
||||
// pitch/roll), so the sound can't lag the visual.
|
||||
val alignError = hypot(
|
||||
current.stableSurfacePitchDegrees ?: 0.0,
|
||||
current.stableSurfaceRollDegrees ?: 0.0,
|
||||
)
|
||||
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,
|
||||
errorDegrees = alignError,
|
||||
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
|
||||
) {
|
||||
// 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
|
||||
@@ -139,7 +152,7 @@ private class SonarSoundPool(context: Context) {
|
||||
// play a queued Level the moment its sample lands.
|
||||
if (sampleId == levelSound && levelPending) {
|
||||
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)
|
||||
}
|
||||
|
||||
/** The "you're level" arrival; retained so it can be stopped on unlock. Queues if not loaded. */
|
||||
fun playLevel() {
|
||||
stopLevel()
|
||||
if (levelSound in loaded) startLevel() else levelPending = true
|
||||
/**
|
||||
* The bullseye sound, looped while you're aligned. Idempotent — safe to call every frame;
|
||||
* starts once and keeps going. Queues if the sample hasn't loaded yet.
|
||||
*/
|
||||
fun ensureLevelLooping() {
|
||||
if (levelStreamId != 0) return // already looping
|
||||
if (levelSound in loaded) startLevelLoop() else levelPending = true
|
||||
}
|
||||
|
||||
private fun startLevel() {
|
||||
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f)
|
||||
private fun startLevelLoop() {
|
||||
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, -1, 1f)
|
||||
}
|
||||
|
||||
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) {
|
||||
soundPool.stop(levelStreamId)
|
||||
levelStreamId = 0
|
||||
|
||||
@@ -15,31 +15,31 @@ class SurfaceTickPolicyTest {
|
||||
foreground: Boolean = true,
|
||||
placementOk: Boolean = true,
|
||||
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
|
||||
locked: Boolean = false,
|
||||
) = SurfaceTickPolicy.Input(
|
||||
isEnabled = enabled,
|
||||
isAppForeground = foreground,
|
||||
placementOk = placementOk,
|
||||
surfacePresentation = presentation,
|
||||
errorDegrees = error,
|
||||
isLocked = locked,
|
||||
nowMillis = now,
|
||||
)
|
||||
|
||||
// --- Proximity ticking (outside the center zone) ---
|
||||
|
||||
@Test
|
||||
fun `rate hits both anchors and clamps beyond them`() {
|
||||
val p = SurfaceTickPolicy()
|
||||
assertEquals(8.0, p.tickRatePerSecond(0.4), 1e-9)
|
||||
assertEquals(1.5, p.tickRatePerSecond(5.0), 1e-9)
|
||||
assertEquals(8.0, p.tickRatePerSecond(0.1), 1e-9) // clamp near
|
||||
assertEquals(1.5, p.tickRatePerSecond(12.0), 1e-9) // clamp far
|
||||
assertEquals(8.0, p.tickRatePerSecond(0.1), 1e-9)
|
||||
assertEquals(1.5, p.tickRatePerSecond(12.0), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rate is monotonic - closer is always faster`() {
|
||||
val p = SurfaceTickPolicy()
|
||||
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
|
||||
@@ -52,79 +52,59 @@ class SurfaceTickPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-entry ticks immediately`() {
|
||||
fun `ticks fire on the current interval and react immediately to changing tilt`() {
|
||||
val p = SurfaceTickPolicy()
|
||||
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 0)))
|
||||
// Gate off, then back on: immediate tick again.
|
||||
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.
|
||||
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 5.0, now = 0))) // first tick, anchor 0
|
||||
// Drop toward center (still outside the 0.2 zone): next tick uses the FAST 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`() {
|
||||
fun `a long gap yields one tick, not a catch-up burst`() {
|
||||
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)))
|
||||
p.update(input(error = 2.0, now = 0))
|
||||
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 10_000)))
|
||||
assertNull(p.update(input(error = 2.0, now = 10_001)))
|
||||
}
|
||||
|
||||
// --- 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
|
||||
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()
|
||||
p.update(input(error = 2.0, now = 0)) // ticking
|
||||
assertEquals(SurfaceTickPolicy.Cue.LEVEL, p.update(input(locked = true, now = 100)))
|
||||
assertNull(p.update(input(locked = true, now = 200)))
|
||||
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)))
|
||||
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = 0)))
|
||||
for (t in 20..2_000 step 20) {
|
||||
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = t.toLong())))
|
||||
}
|
||||
}
|
||||
|
||||
@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)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user