Audio: Surface proximity ticking + Voice settled-change; stage Edge staircase

Replaces the hands-free audio assist after extensive on-device iteration
(full journey + rejected approaches in AUDIO_DESIGN_LOG.md; current design
in SOUND_DESIGN_HANDOFF.md, from Jay's sound-design sessions).

Surface (TONES) = continuous proximity ticking: tick.wav repeats faster as
you near level (axis-agnostic warmer/colder, the rate is the message).
- Exponential rate in stable tilt error, clamped 1.5/s (>=~5 deg) to 8/s
  (<=~0.4 deg), hard-capped; constant loudness (acceleration is the signal).
- Deadline scheduler off a monotonic clock (SystemClock.elapsedRealtime):
  advances a next-tick deadline, so no drift and no catch-up bursts; <=1 tick
  per sensor update; immediate tick on re-entry (enable/return FACE_UP/unlock).
- Lock reuses LockDetector.isLocked (dwell + hysteresis, no second audio
  threshold): stop ticking, play level.wav once, silence while held. The
  1.39s level stream is retained and stopped on unlock/disable so a fresh
  tick can't overlap its tail.
- Pure SurfaceTickPolicy (unit-tested: anchors/clamps, monotonic rates,
  gating, immediate re-entry, no catch-up burst, level-once, resume on unlock).

Voice (VOICE) = settled-change announcer: one correction on the dominant
axis, silent while moving, speaks again on settle only if direction changed,
crossed coarse->fine, or reached lock. No periodic repeat. (VoiceGuidancePolicy,
unit-tested.)

Assets: tick.wav + level.wav downsampled to mono 44.1kHz in res/raw (from
Jay's SoundQ-derived 96k masters). AudioAssistMode adds OFF/TONES/VOICE.

Retired the settle-gated two-ding packet scheduler (AudioAssistStateMachine +
its ding assets) - superseded by ticking for Surface; logged as tested/rejected.

Edge staircase (1-D, future): 5 pitch-contour masters staged in
sonar-staircase-v3/ for when Edge mode is built.

76 tests passing; assembleDebug clean; blessed on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-17 09:43:18 -04:00
parent e5d15c920f
commit 36c0cfa5ab
22 changed files with 809 additions and 223 deletions
@@ -1,102 +0,0 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
/**
* Pure scheduler for optional Surface Audio Assist. It deliberately receives the
* existing lock transition rather than measuring a second lock threshold, and
* owns only proximity-band hysteresis and pulse cadence.
*/
class AudioAssistStateMachine {
enum class Cue { PROXIMITY, LOCK }
private enum class ProximityBand(val intervalMillis: Long) {
NONE(0),
NEAR(2_200),
CLOSE(1_200),
}
private var band = ProximityBand.NONE
private var lastProximityPulseMillis: Long? = null
fun update(input: Input): Cue? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
input.surfacePresentation != SurfacePresentation.FACE_UP || input.isSettling
) {
reset()
return null
}
// Lock acquisition already passed LockDetector's dwell and debounce. A
// distinct one-shot ping is therefore truthful without another threshold.
if (input.isLocked) {
band = ProximityBand.NONE
lastProximityPulseMillis = null
return if (input.fireLockFeedback) Cue.LOCK else null
}
val nextBand = resolveBand(input.displayPrimaryDegrees)
if (nextBand == ProximityBand.NONE) {
band = ProximityBand.NONE
lastProximityPulseMillis = null
return null
}
if (nextBand != band) {
band = nextBand
// Keep the prior pulse time across a band change. The new interval
// changes the next cadence without creating a double-ping at a
// threshold crossing.
}
val lastPulse = lastProximityPulseMillis
return if (lastPulse == null || input.nowMillis - lastPulse >= band.intervalMillis) {
lastProximityPulseMillis = input.nowMillis
Cue.PROXIMITY
} else {
null
}
}
fun reset() {
band = ProximityBand.NONE
lastProximityPulseMillis = null
}
/** Hysteresis prevents a noisy display from chattering between pulse cadences. */
private fun resolveBand(degrees: Double): ProximityBand = when (band) {
ProximityBand.CLOSE -> when {
degrees <= CLOSE_EXIT_DEGREES -> ProximityBand.CLOSE
degrees <= NEAR_ENTER_DEGREES -> ProximityBand.NEAR
else -> ProximityBand.NONE
}
ProximityBand.NEAR -> when {
degrees <= CLOSE_ENTER_DEGREES -> ProximityBand.CLOSE
degrees <= NEAR_EXIT_DEGREES -> ProximityBand.NEAR
else -> ProximityBand.NONE
}
ProximityBand.NONE -> when {
degrees <= CLOSE_ENTER_DEGREES -> ProximityBand.CLOSE
degrees <= NEAR_ENTER_DEGREES -> ProximityBand.NEAR
else -> ProximityBand.NONE
}
}
data class Input(
val isEnabled: Boolean,
val isAppForeground: Boolean,
val placementOk: Boolean,
val surfacePresentation: SurfacePresentation?,
val isSettling: Boolean,
val displayPrimaryDegrees: Double,
val isLocked: Boolean,
val fireLockFeedback: Boolean,
val nowMillis: Long,
)
companion object {
const val CLOSE_ENTER_DEGREES = 1.0
const val CLOSE_EXIT_DEGREES = 1.2
const val NEAR_ENTER_DEGREES = 3.0
const val NEAR_EXIT_DEGREES = 3.4
}
}
@@ -0,0 +1,96 @@
package com.onthelevel.core.audio
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.
*
* 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.
*
* 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.
*/
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,
) {
enum class Cue { TICK, LEVEL }
private var deadlineMillis: Long? = null
private var wasLocked = false
fun update(input: Input): Cue? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
input.surfacePresentation != SurfacePresentation.FACE_UP
) {
reset()
return null
}
if (input.isLocked) {
deadlineMillis = null // cancel any pending tick
val justLocked = !wasLocked
wasLocked = true
return if (justLocked) Cue.LEVEL else null
}
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)
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
return Cue.TICK
}
return null
}
/** Ticks/second: [maxRatePerSecond] at/inside [nearDegrees], [minRatePerSecond] at/beyond [farDegrees]. */
fun tickRatePerSecond(errorDegrees: Double): Double {
val t = ((errorDegrees - nearDegrees) / (farDegrees - nearDegrees)).coerceIn(0.0, 1.0)
return maxRatePerSecond * (minRatePerSecond / maxRatePerSecond).pow(t) // geometric = exponential
}
fun intervalMillis(errorDegrees: Double): Long = (1000.0 / tickRatePerSecond(errorDegrees)).toLong()
fun reset() {
deadlineMillis = null
wasLocked = false
}
data class Input(
val isEnabled: Boolean,
val isAppForeground: Boolean,
val placementOk: Boolean,
val surfacePresentation: SurfacePresentation?,
val errorDegrees: Double,
val isLocked: Boolean,
val nowMillis: Long,
)
companion object {
const val NEAR_DEGREES = 0.4
const val FAR_DEGREES = 5.0
const val MAX_RATE = 8.0
const val MIN_RATE = 1.5
}
}
@@ -0,0 +1,117 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import kotlin.math.abs
/**
* Pure policy for spoken Surface leveling guidance — a SETTLED-CHANGE announcer, not a
* timer. It talks like a person watching the bubble: ONE correction on the axis that's
* most off, naming the low side to RAISE, softened to "a little" when close, and "that's
* level" once.
*
* It speaks only when there's something new to say. While the board is MOVING it stays
* silent (but remembers movement happened); when it SETTLES again it re-assesses and
* speaks only if the dominant direction changed, the correction crossed coarse→fine, or
* it reached lock. A board that settles still needing the same coarse nudge gets silence —
* the person already has that instruction. There is no periodic repeat.
*
* Sign conventions (OrientationMath): pitch>0 = top edge high, roll>0 = right edge high.
* To level you raise the LOW side, so top-high -> raise bottom, right-high -> raise left.
*/
class VoiceGuidancePolicy(
private val fineThresholdDegrees: Double = FINE_THRESHOLD_DEGREES,
private val dominanceMarginDegrees: Double = DOMINANCE_MARGIN_DEGREES,
) {
enum class Direction { RAISE_LEFT, RAISE_RIGHT, RAISE_TOP, RAISE_BOTTOM }
sealed interface Phrase {
data class Raise(val direction: Direction, val fine: Boolean) : Phrase
data object Level : Phrase
}
private var spokenDirection: Direction? = null
private var spokenFine = false
private var levelAnnounced = false
// True right after movement, so the next settled frame re-assesses. Starts true so the
// very first settled reading gives an instruction.
private var awaitingSettledAssessment = true
fun update(input: Input): Phrase? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
input.presentation != SurfacePresentation.FACE_UP
) {
reset()
return null
}
if (input.isLocked) {
spokenDirection = null // re-announce direction after any unlock
awaitingSettledAssessment = false
if (levelAnnounced) return null
levelAnnounced = true
return Phrase.Level
}
levelAnnounced = false
// Moving: stay silent, but note that we must re-assess once it settles. Preserve
// the last spoken direction/tier (do NOT fully reset — that caused the repeats).
if (input.isSettling) {
awaitingSettledAssessment = true
return null
}
// Settled and off-level: speak only on a meaningful change since the last settle.
val direction = dominantDirection(input.pitchDegrees, input.rollDegrees)
val fine = maxOf(abs(input.pitchDegrees), abs(input.rollDegrees)) <= fineThresholdDegrees
val speak = awaitingSettledAssessment && (
spokenDirection == null || // first instruction
direction != spokenDirection || // dominant direction changed
(fine && !spokenFine) // crossed coarse -> fine
)
awaitingSettledAssessment = false
return if (speak) {
spokenDirection = direction
spokenFine = fine
Phrase.Raise(direction, fine)
} else {
null
}
}
private fun dominantDirection(pitch: Double, roll: Double): Direction {
val pitchDir = if (pitch > 0) Direction.RAISE_BOTTOM else Direction.RAISE_TOP
val rollDir = if (roll > 0) Direction.RAISE_LEFT else Direction.RAISE_RIGHT
val prev = spokenDirection
val ambiguous = abs(abs(pitch) - abs(roll)) < dominanceMarginDegrees
if (ambiguous && prev != null) {
// Stay on whichever axis we're already coaching, to avoid flapping on a diagonal.
if (prev == Direction.RAISE_TOP || prev == Direction.RAISE_BOTTOM) return pitchDir
return rollDir
}
return if (abs(pitch) >= abs(roll)) pitchDir else rollDir
}
fun reset() {
spokenDirection = null
spokenFine = false
levelAnnounced = false
awaitingSettledAssessment = true
}
data class Input(
val isEnabled: Boolean,
val isAppForeground: Boolean,
val placementOk: Boolean,
val presentation: SurfacePresentation?,
val isSettling: Boolean,
val pitchDegrees: Double,
val rollDegrees: Double,
val isLocked: Boolean,
val nowMillis: Long,
)
companion object {
const val FINE_THRESHOLD_DEGREES = 1.0
const val DOMINANCE_MARGIN_DEGREES = 0.3
}
}
@@ -0,0 +1,13 @@
package com.onthelevel.core.settings
/**
* How the hands-free Surface audio assist speaks. Mutually exclusive: tones and voice
* 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.
* VOICE — spoken corrections ("raise the right") + "that's level", announced only on a
* settled change. No bed underneath — speech stays sparse.
*/
enum class AudioAssistMode { OFF, TONES, VOICE }
@@ -38,7 +38,16 @@ class SettingsRepository(context: Context) {
}
val hapticsEnabled: Flow<Boolean> = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true }
val audioCueEnabled: Flow<Boolean> = store.data.map { it[Keys.AUDIO_CUE_ENABLED] ?: false }
/** Off / Tones / Voice, migrating from the legacy audio-cue boolean if unset. */
val audioAssistMode: Flow<AudioAssistMode> = store.data.map { prefs ->
when (prefs[Keys.AUDIO_ASSIST_MODE]) {
AudioAssistMode.TONES.name -> AudioAssistMode.TONES
AudioAssistMode.VOICE.name -> AudioAssistMode.VOICE
AudioAssistMode.OFF.name -> AudioAssistMode.OFF
else -> if (prefs[Keys.AUDIO_CUE_ENABLED] == true) AudioAssistMode.TONES else AudioAssistMode.OFF
}
}
/** In-app reduced-motion preference; the system animator-scale signal is respected separately. */
val reducedMotion: Flow<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
@@ -82,6 +91,10 @@ class SettingsRepository(context: Context) {
store.edit { it[Keys.AUDIO_CUE_ENABLED] = enabled }
}
suspend fun setAudioAssistMode(mode: AudioAssistMode) {
store.edit { it[Keys.AUDIO_ASSIST_MODE] = mode.name }
}
suspend fun setReducedMotion(enabled: Boolean) {
store.edit { it[Keys.REDUCED_MOTION] = enabled }
}
@@ -103,6 +116,7 @@ class SettingsRepository(context: Context) {
val EDGE_CAL_POSITIVE_X = booleanPreferencesKey("edge_cal_positive_x")
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
val AUDIO_ASSIST_MODE = stringPreferencesKey("audio_assist_mode")
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units")
val CALIBRATION_MODE = stringPreferencesKey("calibration_mode")
@@ -3,6 +3,8 @@ package com.onthelevel.feature.level
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import android.os.SystemClock
import android.speech.tts.TextToSpeech
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -15,46 +17,78 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.onthelevel.R
import com.onthelevel.core.audio.AudioAssistStateMachine
import com.onthelevel.core.audio.SurfaceTickPolicy
import com.onthelevel.core.audio.VoiceGuidancePolicy
import com.onthelevel.core.settings.AudioAssistMode
import java.util.Locale
/**
* Lifecycle-scoped SoundPool player for the optional hands-free Surface assist.
* It mixes as sonification and intentionally never asks Android for audio focus.
* 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).
*
* VOICE — deliberately separate: spoken corrections + "that's level", settled-change only.
*
* Everything plays on the media stream (so the volume slider works) and never takes audio
* focus. Players/speakers exist only while enabled and foregrounded; released on dispose.
*/
@Composable
fun AudioLevelAssist(reading: LevelReading?, isEnabled: Boolean) {
fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
val isForeground = rememberIsResumed()
val stateMachine = remember { AudioAssistStateMachine() }
val tickPolicy = remember { SurfaceTickPolicy() }
val voicePolicy = remember { VoiceGuidancePolicy() }
val context = LocalContext.current.applicationContext
val player = remember(isEnabled, isForeground, context) {
if (isEnabled && isForeground) SonarSoundPool(context) else null
}
DisposableEffect(player) {
onDispose { player?.release() }
val player = remember(mode, isForeground, context) {
if (mode != AudioAssistMode.OFF && isForeground) SonarSoundPool(context) else null
}
val speaker = remember(mode, isForeground, context) {
if (mode == AudioAssistMode.VOICE && isForeground) VoiceSpeaker(context) else null
}
DisposableEffect(player) { onDispose { player?.release() } }
DisposableEffect(speaker) { onDispose { speaker?.shutdown() } }
LaunchedEffect(reading, isEnabled, isForeground, stateMachine, player) {
LaunchedEffect(reading, mode, isForeground, player, speaker) {
val current = reading ?: return@LaunchedEffect
// TONES: proximity ticks off a monotonic deadline; `level.wav` at lock.
when (
stateMachine.update(
AudioAssistStateMachine.Input(
isEnabled = isEnabled,
tickPolicy.update(
SurfaceTickPolicy.Input(
isEnabled = mode == AudioAssistMode.TONES,
isAppForeground = isForeground,
placementOk = current.placementOk,
surfacePresentation = current.surfacePresentation,
isSettling = current.isSettling,
displayPrimaryDegrees = current.displayPrimaryDegrees,
errorDegrees = current.stableTiltDegrees ?: current.displayPrimaryDegrees,
isLocked = current.isLocked,
fireLockFeedback = current.fireFeedback,
nowMillis = System.currentTimeMillis(),
nowMillis = SystemClock.elapsedRealtime(),
),
)
) {
AudioAssistStateMachine.Cue.PROXIMITY -> player?.playProximity()
AudioAssistStateMachine.Cue.LOCK -> player?.playLock()
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(
isEnabled = mode == AudioAssistMode.VOICE,
isAppForeground = isForeground,
placementOk = current.placementOk,
presentation = current.surfacePresentation,
isSettling = current.isSettling,
pitchDegrees = current.stableSurfacePitchDegrees ?: 0.0,
rollDegrees = current.stableSurfaceRollDegrees ?: 0.0,
isLocked = current.isLocked,
nowMillis = System.currentTimeMillis(),
),
)
if (phrase != null) speaker?.speak(phrase)
}
}
@@ -75,50 +109,98 @@ private fun rememberIsResumed(): Boolean {
}
private class SonarSoundPool(context: Context) {
private val loadedSoundIds = mutableSetOf<Int>()
private var pendingSoundId: Int? = null
private var initialized = false
private val loaded = mutableSetOf<Int>()
private val soundPool = SoundPool.Builder()
.setMaxStreams(1)
.setMaxStreams(4)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
.build()
private val proximitySound: Int
private val lockSound: Int
private val tickSound: Int
private val levelSound: Int
private var levelStreamId: Int = 0
init {
soundPool.setOnLoadCompleteListener { _, sampleId, status ->
if (status == 0) {
loadedSoundIds += sampleId
if (initialized && pendingSoundId == sampleId) {
pendingSoundId = null
play(sampleId, volumeFor(sampleId))
}
}
}
proximitySound = soundPool.load(context, R.raw.sonar_proximity, 1)
lockSound = soundPool.load(context, R.raw.sonar_lock, 1)
initialized = true
soundPool.setOnLoadCompleteListener { _, sampleId, status -> if (status == 0) loaded += sampleId }
tickSound = soundPool.load(context, R.raw.tick, 1)
levelSound = soundPool.load(context, R.raw.level, 1)
}
fun playProximity() = play(proximitySound, volume = 0.28f)
/** One proximity tick — constant volume (the rate is the signal, not loudness). */
fun playTick() {
if (tickSound in loaded) soundPool.play(tickSound, TICK_VOLUME, TICK_VOLUME, 1, 0, 1f)
}
fun playLock() = play(lockSound, volume = 0.36f)
private fun play(soundId: Int, volume: Float) {
if (soundId in loadedSoundIds) {
soundPool.play(soundId, volume, volume, 1, 0, 1f)
} else {
pendingSoundId = soundId
/** The "you're level" arrival; retained so it can be stopped on unlock. */
fun playLevel() {
stopLevel()
if (levelSound in loaded) {
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f)
}
}
private fun volumeFor(soundId: Int): Float =
if (soundId == lockSound) 0.36f else 0.28f
fun stopLevel() {
if (levelStreamId != 0) {
soundPool.stop(levelStreamId)
levelStreamId = 0
}
}
fun release() = soundPool.release()
private companion object {
const val TICK_VOLUME = 0.55f
const val LEVEL_VOLUME = 0.85f
}
}
/**
* On-device text-to-speech, structured so premium pre-recorded clips can be swapped in
* later without touching callers. Mixes as sonification and does not take audio focus.
* TODO(voice): prefer bundled clips (res/raw) when present, fall back to TTS.
*/
private class VoiceSpeaker(context: Context) {
private var ready = false
private lateinit var tts: TextToSpeech
init {
tts = TextToSpeech(context.applicationContext) { status ->
if (status == TextToSpeech.SUCCESS) {
tts.language = Locale.US
tts.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
ready = true
}
}
}
fun speak(phrase: VoiceGuidancePolicy.Phrase) {
if (!ready) return
tts.speak(textFor(phrase), TextToSpeech.QUEUE_FLUSH, null, "level-voice")
}
private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) {
VoiceGuidancePolicy.Phrase.Level -> "that's level"
is VoiceGuidancePolicy.Phrase.Raise -> {
val side = when (phrase.direction) {
VoiceGuidancePolicy.Direction.RAISE_LEFT -> "left"
VoiceGuidancePolicy.Direction.RAISE_RIGHT -> "right"
VoiceGuidancePolicy.Direction.RAISE_TOP -> "top"
VoiceGuidancePolicy.Direction.RAISE_BOTTOM -> "bottom"
}
if (phrase.fine) "raise the $side, just a little" else "raise the $side"
}
}
fun shutdown() {
tts.stop()
tts.shutdown()
}
}
@@ -28,6 +28,8 @@ 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. */
val stableTiltDegrees: Double? = null,
/** Surface-only presentation state; null for Edge mode. */
val surfacePresentation: SurfacePresentation? = null,
/** False when the device is not physically in the selected mode's geometry. */
@@ -101,6 +103,7 @@ class LevelPipeline(
} else {
null
},
stableTiltDegrees = magnitude,
stableSurfacePitchDegrees = pitch,
stableSurfaceRollDegrees = roll,
surfacePresentation = presentation,
@@ -56,6 +56,7 @@ import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceGuidance
import com.onthelevel.core.sensors.SurfacePresentation
import com.onthelevel.core.settings.AudioAssistMode
import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@@ -86,8 +87,8 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true)
val audioCueEnabled by container.settings.audioCueEnabled
.collectAsStateWithLifecycle(initialValue = false)
val audioMode by container.settings.audioAssistMode
.collectAsStateWithLifecycle(initialValue = AudioAssistMode.OFF)
val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false)
val measurementUnits by container.settings.measurementUnits
@@ -105,7 +106,7 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
}
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
AudioLevelAssist(reading, audioCueEnabled)
AudioLevelAssist(reading, audioMode)
val isLocked = reading?.isLocked == true
val isCalibrated = when (mode) {
@@ -132,13 +133,23 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
// Tap cycles Off -> Tones -> Voice. Lime when active (a confirmed on-state).
TextButton(onClick = {
scope.launch { container.settings.setAudioCueEnabled(!audioCueEnabled) }
val next = when (audioMode) {
AudioAssistMode.OFF -> AudioAssistMode.TONES
AudioAssistMode.TONES -> AudioAssistMode.VOICE
AudioAssistMode.VOICE -> AudioAssistMode.OFF
}
scope.launch { container.settings.setAudioAssistMode(next) }
}) {
Text(
text = if (audioCueEnabled) "SONAR ON" else "SONAR OFF",
text = when (audioMode) {
AudioAssistMode.OFF -> "AUDIO OFF"
AudioAssistMode.TONES -> "TONES"
AudioAssistMode.VOICE -> "VOICE"
},
style = MaterialTheme.typography.labelSmall,
color = if (audioCueEnabled) LevelColors.LimeLockText else LevelColors.TextDim,
color = if (audioMode == AudioAssistMode.OFF) LevelColors.TextDim else LevelColors.LimeLockText,
)
}
IconButton(onClick = onOpenSettings) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,65 +0,0 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AudioAssistStateMachineTest {
private val machine = AudioAssistStateMachine()
private fun input(
degrees: Double = 2.0,
nowMillis: Long = 0,
enabled: Boolean = true,
foreground: Boolean = true,
placementOk: Boolean = true,
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
settling: Boolean = false,
locked: Boolean = false,
fireLockFeedback: Boolean = false,
) = AudioAssistStateMachine.Input(
isEnabled = enabled,
isAppForeground = foreground,
placementOk = placementOk,
surfacePresentation = presentation,
isSettling = settling,
displayPrimaryDegrees = degrees,
isLocked = locked,
fireLockFeedback = fireLockFeedback,
nowMillis = nowMillis,
)
@Test
fun `is silent while disabled settling invalid or backgrounded`() {
assertNull(machine.update(input(enabled = false)))
assertNull(machine.update(input(settling = true)))
assertNull(machine.update(input(placementOk = false)))
assertNull(machine.update(input(presentation = SurfacePresentation.NEAR_VERTICAL)))
assertNull(machine.update(input(foreground = false)))
}
@Test
fun `proximity cadence uses hysteretic bands`() {
assertEquals(AudioAssistStateMachine.Cue.PROXIMITY, machine.update(input(degrees = 1.0, nowMillis = 0)))
assertNull(machine.update(input(degrees = 1.1, nowMillis = 1_199))) // remains Close through exit at 1.2°
assertEquals(AudioAssistStateMachine.Cue.PROXIMITY, machine.update(input(degrees = 1.1, nowMillis = 1_200)))
// Cross Close's exit but remain in Near: cadence changes without a
// threshold double-ping, then holds Near through 3.4°.
assertNull(machine.update(input(degrees = 1.3, nowMillis = 1_201)))
assertEquals(AudioAssistStateMachine.Cue.PROXIMITY, machine.update(input(degrees = 3.3, nowMillis = 3_400)))
assertNull(machine.update(input(degrees = 3.5, nowMillis = 3_401)))
}
@Test
fun `lock sound reuses the existing one shot lock transition`() {
assertNull(machine.update(input(degrees = 0.1, locked = true, fireLockFeedback = false)))
assertEquals(
AudioAssistStateMachine.Cue.LOCK,
machine.update(input(degrees = 0.1, locked = true, fireLockFeedback = true, nowMillis = 20)),
)
assertNull(machine.update(input(degrees = 0.1, locked = true, fireLockFeedback = false, nowMillis = 40)))
}
}
@@ -0,0 +1,109 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SurfaceTickPolicyTest {
private fun input(
error: Double = 2.0,
now: Long = 0,
enabled: Boolean = true,
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,
)
@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
}
@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])
}
@Test
fun `silent and reset while disabled, backgrounded, or off-surface`() {
val p = SurfaceTickPolicy()
assertNull(p.update(input(enabled = false)))
assertNull(p.update(input(foreground = false)))
assertNull(p.update(input(placementOk = false)))
assertNull(p.update(input(presentation = SurfacePresentation.NEAR_VERTICAL)))
}
@Test
fun `re-entry ticks immediately`() {
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 `level fires once per lock, then silence, then ticks resume on unlock`() {
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)))
}
}
@@ -0,0 +1,96 @@
package com.onthelevel.core.audio
import com.onthelevel.core.audio.VoiceGuidancePolicy.Direction
import com.onthelevel.core.audio.VoiceGuidancePolicy.Phrase
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class VoiceGuidancePolicyTest {
private fun input(
pitch: Double = 0.0,
roll: Double = 0.0,
locked: Boolean = false,
settling: Boolean = false,
enabled: Boolean = true,
foreground: Boolean = true,
placementOk: Boolean = true,
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
) = VoiceGuidancePolicy.Input(
isEnabled = enabled,
isAppForeground = foreground,
placementOk = placementOk,
presentation = presentation,
isSettling = settling,
pitchDegrees = pitch,
rollDegrees = roll,
isLocked = locked,
nowMillis = 0,
)
/** Move (silent), then come to rest — the frame the board settles on. */
private fun VoiceGuidancePolicy.moveThenSettle(pitch: Double, roll: Double): Phrase? {
update(input(pitch = pitch, roll = roll, settling = true))
return update(input(pitch = pitch, roll = roll, settling = false))
}
@Test
fun `first settled reading gives one instruction, low side of the dominant axis`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
}
@Test
fun `same direction after another settle stays silent`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
// Adjusted, still needs the same coarse "raise the left": say nothing.
assertNull(p.moveThenSettle(pitch = 0.0, roll = 2.8))
}
@Test
fun `direction change speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0)) // raise left
// Overcorrected past level (left now high): speak the new direction, once.
assertEquals(Phrase.Raise(Direction.RAISE_RIGHT, fine = false), p.moveThenSettle(pitch = 0.0, roll = -3.0))
// Settling again with the same direction: silent.
assertNull(p.moveThenSettle(pitch = 0.0, roll = -2.9))
}
@Test
fun `crossing coarse to fine speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0)) // coarse "raise the left"
// Now close, same direction: announce the finer correction, once.
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = true), p.moveThenSettle(pitch = 0.0, roll = 0.6))
// Still fine, same direction: silent.
assertNull(p.moveThenSettle(pitch = 0.0, roll = 0.5))
}
@Test
fun `lock speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0))
assertEquals(Phrase.Level, p.update(input(locked = true)))
assertNull(p.update(input(locked = true)))
}
@Test
fun `no periodic repeat while sitting settled and off-level`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
// Many more settled frames, unchanged: silence, no timer-driven repeat.
repeat(50) { assertNull(p.update(input(roll = 3.0))) }
}
@Test
fun `silent when disabled, backgrounded, or off-surface`() {
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, enabled = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, foreground = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, placementOk = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, presentation = SurfacePresentation.NEAR_VERTICAL)))
}
}