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
@@ -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(
SurfaceTickPolicy.Input(
isEnabled = mode == AudioAssistMode.TONES,
isAppForeground = isForeground,
placementOk = current.placementOk,
surfacePresentation = current.surfacePresentation,
errorDegrees = current.stableTiltDegrees ?: current.displayPrimaryDegrees,
isLocked = current.isLocked,
nowMillis = SystemClock.elapsedRealtime(),
),
// 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,
)
// 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
when (
tickPolicy.update(
SurfaceTickPolicy.Input(
isEnabled = mode == AudioAssistMode.TONES,
isAppForeground = isForeground,
placementOk = current.placementOk,
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
@@ -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)))
}
}