diff --git a/AUDIO_DESIGN_LOG.md b/AUDIO_DESIGN_LOG.md index 6b5ef05..5025cba 100644 --- a/AUDIO_DESIGN_LOG.md +++ b/AUDIO_DESIGN_LOG.md @@ -65,10 +65,12 @@ mono 44.1 kHz in `res/raw`. ## Guiding principles (learned) -- Leave **air** between cues; silence is restful. (600 ms near cadence is responsive without - being alarm territory; ~180 ms was alarm territory.) -- **Pitch** carries distance more reliably than **volume** (phone volume + room noise make - loudness a poor information channel). +- Cue *character* decides tolerance more than raw rate. As **discrete report cues**, ~180 ms + spacing felt alarm-like and ~600 ms felt calm — but as a **continuous proximity tick** an + 8/s (125 ms) near rate reads as pleasant acceleration, not an alarm (it's a Geiger/parking + sensor, and the ear expects it to quicken as you close in). Judge by feel on the phone. +- **Volume** is a poor information channel (phone volume + room noise make loudness + unreliable) — let rate/pitch carry the message and keep loudness constant. - Keep the SoundPool layer dumb; put decisions in tested pure logic. - The **centered** sound needs source loudness/spectrum presence, not just a bigger volume number. 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 c92f0db..e757db4 100644 --- a/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt +++ b/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt @@ -14,11 +14,14 @@ import kotlin.math.pow * 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. * - * Scheduling is deadline-based off a MONOTONIC clock (caller passes nowMillis): it advances - * a next-tick deadline rather than restarting from "now", so it neither drifts slow nor - * fires catch-up bursts. At most one tick per update; if it ever falls a full interval - * behind (a pause, a rate change), it resyncs the deadline into the future instead of - * bursting. Re-entry — enabling TONES, returning FACE_UP, or unlocking — ticks immediately. + * 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. */ class SurfaceTickPolicy( private val nearDegrees: Double = NEAR_DEGREES, @@ -28,7 +31,7 @@ class SurfaceTickPolicy( ) { enum class Cue { TICK, LEVEL } - private var deadlineMillis: Long? = null + private var lastTickAtMillis: Long? = null // anchor: when the last tick actually fired private var wasLocked = false fun update(input: Input): Cue? { @@ -40,7 +43,7 @@ class SurfaceTickPolicy( } if (input.isLocked) { - deadlineMillis = null // cancel any pending tick + lastTickAtMillis = null // cancel any pending tick val justLocked = !wasLocked wasLocked = true return if (justLocked) Cue.LEVEL else null @@ -48,17 +51,18 @@ class SurfaceTickPolicy( wasLocked = false val now = input.nowMillis - val deadline = deadlineMillis - if (deadline == null) { - // Re-entry: tick immediately, then schedule the next. - deadlineMillis = now + intervalMillis(input.errorDegrees) + val anchor = lastTickAtMillis + if (anchor == null) { + // Re-entry: tick immediately. + lastTickAtMillis = now return Cue.TICK } - if (now >= deadline) { - val interval = intervalMillis(input.errorDegrees) - var next = deadline + interval - if (next <= now) next = now + interval // fell a full interval behind: resync, no burst - deadlineMillis = next + val interval = intervalMillis(input.errorDegrees) // current interval, recomputed each frame + 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 } return null @@ -73,7 +77,7 @@ class SurfaceTickPolicy( fun intervalMillis(errorDegrees: Double): Long = (1000.0 / tickRatePerSecond(errorDegrees)).toLong() fun reset() { - deadlineMillis = null + lastTickAtMillis = null wasLocked = false } diff --git a/app/src/main/java/com/onthelevel/core/settings/AudioAssistMode.kt b/app/src/main/java/com/onthelevel/core/settings/AudioAssistMode.kt index 1637d9b..9067d4f 100644 --- a/app/src/main/java/com/onthelevel/core/settings/AudioAssistMode.kt +++ b/app/src/main/java/com/onthelevel/core/settings/AudioAssistMode.kt @@ -5,8 +5,8 @@ package com.onthelevel.core.settings * would talk over each other, so it's one choice, not two toggles. * * OFF — silent. - * TONES — radar-style sonar homing pings (pitch steps up as you near level) + an arrival - * hit at lock. + * TONES — continuous proximity ticking that speeds up as you near level, then `level.wav` + * once at lock. * VOICE — spoken corrections ("raise the right") + "that's level", announced only on a * settled change. No bed underneath — speech stays sparse. */ 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 104911b..898d31f 100644 --- a/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt +++ b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt @@ -53,27 +53,26 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) { LaunchedEffect(reading, mode, isForeground, player, speaker) { val current = reading ?: return@LaunchedEffect - // TONES: proximity ticks off a monotonic deadline; `level.wav` at lock. - 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, - nowMillis = SystemClock.elapsedRealtime(), - ), - ) - ) { + // 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(), + ), + ) + // 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 } - // Stop the 1.39 s level.wav tail the moment we're no longer holding level, so a - // fresh tick after a quick unlock can't overlap it (Codex requirement). - if (!(mode == AudioAssistMode.TONES && current.isLocked)) player?.stopLevel() val phrase = voicePolicy.update( VoiceGuidancePolicy.Input( @@ -122,9 +121,20 @@ private class SonarSoundPool(context: Context) { private val tickSound: Int private val levelSound: Int private var levelStreamId: Int = 0 + private var levelPending = false // Level asked for before the sample finished loading init { - soundPool.setOnLoadCompleteListener { _, sampleId, status -> if (status == 0) loaded += sampleId } + soundPool.setOnLoadCompleteListener { _, sampleId, status -> + if (status == 0) { + loaded += sampleId + // The one-shot Level can't rely on a retry (the policy fires it once), so + // play a queued Level the moment its sample lands. + if (sampleId == levelSound && levelPending) { + levelPending = false + startLevel() + } + } + } tickSound = soundPool.load(context, R.raw.tick, 1) levelSound = soundPool.load(context, R.raw.level, 1) } @@ -134,15 +144,18 @@ 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. */ + /** The "you're level" arrival; retained so it can be stopped on unlock. Queues if not loaded. */ fun playLevel() { stopLevel() - if (levelSound in loaded) { - levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f) - } + if (levelSound in loaded) startLevel() else levelPending = true + } + + private fun startLevel() { + levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f) } fun stopLevel() { + levelPending = false // cancel a queued Level (unlock/gate invalidated it) if (levelStreamId != 0) { soundPool.stop(levelStreamId) levelStreamId = 0 @@ -164,6 +177,7 @@ private class SonarSoundPool(context: Context) { */ private class VoiceSpeaker(context: Context) { private var ready = false + private var pendingText: String? = null // latest phrase asked for before TTS finished init private lateinit var tts: TextToSpeech init { @@ -177,13 +191,17 @@ private class VoiceSpeaker(context: Context) { .build(), ) ready = true + // The first phrase often arrives before init finishes; the policy won't + // re-issue it, so speak the latest queued phrase now. + pendingText?.let { tts.speak(it, TextToSpeech.QUEUE_FLUSH, null, "level-voice") } + pendingText = null } } } fun speak(phrase: VoiceGuidancePolicy.Phrase) { - if (!ready) return - tts.speak(textFor(phrase), TextToSpeech.QUEUE_FLUSH, null, "level-voice") + val text = textFor(phrase) + if (ready) tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "level-voice") else pendingText = text } private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) { diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt index 4bfe829..8900bcd 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt @@ -28,7 +28,7 @@ data class LevelReading( /** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */ val stableSurfacePitchDegrees: Double? = null, val stableSurfaceRollDegrees: Double? = null, - /** Stable (pre-deadband) Surface tilt magnitude; drives audio homing bands. Null for Edge. */ + /** Stable (pre-deadband) Surface tilt magnitude; drives the audio tick rate. Null for Edge. */ val stableTiltDegrees: Double? = null, /** Surface-only presentation state; null for Edge mode. */ val surfacePresentation: SurfacePresentation? = null, diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt index 7187607..8af0c39 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -95,7 +94,6 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) { .collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC) val view = LocalView.current - val scope = rememberCoroutineScope() val lockDetector = remember { LockDetector() } LaunchedEffect(mode) { lockDetector.reset() } @@ -140,7 +138,7 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) { AudioAssistMode.TONES -> AudioAssistMode.VOICE AudioAssistMode.VOICE -> AudioAssistMode.OFF } - scope.launch { container.settings.setAudioAssistMode(next) } + container.applicationScope.launch { container.settings.setAudioAssistMode(next) } }) { Text( text = when (audioMode) { 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 4bdd47f..9853f47 100644 --- a/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt +++ b/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt @@ -96,6 +96,27 @@ class SurfaceTickPolicyTest { 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))) + assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 125))) + } + + @Test + fun `near to far slows immediately - no leftover fast tick`() { + 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))) + } + @Test fun `level fires once per lock, then silence, then ticks resume on unlock`() { val p = SurfaceTickPolicy()