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):
|
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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user