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