diff --git a/AUDIO_DESIGN_LOG.md b/AUDIO_DESIGN_LOG.md index 5025cba..2a6a000 100644 --- a/AUDIO_DESIGN_LOG.md +++ b/AUDIO_DESIGN_LOG.md @@ -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 diff --git a/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt b/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt index e757db4..b4cf273 100644 --- a/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt +++ b/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt @@ -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 } } diff --git a/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt index 6b9f7da..d06f79a 100644 --- a/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt +++ b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt @@ -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 diff --git a/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt b/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt index 9853f47..5662ba1 100644 --- a/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt +++ b/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt @@ -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))) } }