diff --git a/AUDIO_DESIGN_LOG.md b/AUDIO_DESIGN_LOG.md new file mode 100644 index 0000000..6b5ef05 --- /dev/null +++ b/AUDIO_DESIGN_LOG.md @@ -0,0 +1,76 @@ +# Audio Assist — design log + +Hands-free audio to level something without watching the screen. Setting cycles +**OFF / TONES / VOICE** from the Level header. Plays on `USAGE_MEDIA` (so the volume slider +controls it) and never takes audio focus. + +> **Source of truth for the current design is [`SOUND_DESIGN_HANDOFF.md`]**, from Jay's +> dedicated sound-design sessions. This log records the *journey* — especially rejected +> approaches — so they don't get rediscovered. Where the two ever disagree, the handoff wins. + +## Chosen direction (current) + +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. + +**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 +report per move→settle. Five pre-rendered assets staged in `sonar-staircase-v3/`; wired when +Edge mode is built. + +**VOICE — deliberately separate, a SETTLED-CHANGE announcer (not a timer).** Speaks one +correction, then stays silent while moving; when it settles again it speaks only if the +dominant direction changed, it crossed coarse→fine, or it reached lock ("that's level"). A +settle still needing the same coarse nudge gets silence. No periodic repeat, no bed under it. + +Implementation: pure `SurfaceTickPolicy` (monotonic deadline scheduler, exponential rate, +unit-tested) + `VoiceGuidancePolicy`; the SoundPool layer stays dumb (play a tick / play & +stop level). Sounds are Jay's SoundQ-derived blips (`tick.wav`, `level.wav`) downsampled to +mono 44.1 kHz in `res/raw`. + +## Rejected approaches (and why) + +1. **Two fixed proximity bands + lock ping** (original). Worked but coarse; the near/close jump + was audible ("a lower ding when close"). +2. **Continuous cadence** — beep interval shrinks smoothly toward center (parking-sensor). + → "gets a bit much." Monotonous over a long adjustment. +3. **Rising-pitch discrete beeps.** Better, but still same-note-ish and busy. +4. **Continuous glide tone** — one warm tone, pitch low→treble by closeness, resolving into the + aura. → "the constant sound works but it's too much; not clicking." A continuous tone gives + the ear **no silence to rest on**, so it fatigues; it also bypassed the tested scheduler. +5. **Radar doublet on a per-band cadence** — two sonar pings ~110 ms apart, repeating on a + 1.3/0.9/0.6 s cadence while off-level. The literal two-ping "pew-pew" read as UI/radar beeps, + not sonar, and the continued cadence was still a clock. Kept the bands/pitch, dropped the + cadence in favor of one packet per settle. +6. **Continuous forcefield aura bed** (looping CC0 ambience while locked). Seamless on a PC + media player but **clicked at the loop point through Android SoundPool**. Pulled; a gapless + bed needs AudioTrack/ExoPlayer. +7. **Settle-gated two-ding "status packet"** (banded pitch + gap, one packet per move→settle, + fuller arrival ding at lock). Built, unit-tested, on-device tested. Coherent, but on the + bench the settle-then-report model felt sparse/laggy for the fiddly 2-D surface adjustment — + you want live "warmer/colder" while you're actually moving it, not a report after you stop. + Superseded by continuous ticking for Surface. (The settle-then-report shape lives on for + VOICE and for future EDGE, where a discrete per-settle report fits the 1-D task.) + +## 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). +- 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. +- No reverb/comb tails on phone speakers — they smear and lengthen fatigue. +- Reuse the real lock state for the centered handoff; never invent a second audio threshold. diff --git a/SOUND_DESIGN_HANDOFF.md b/SOUND_DESIGN_HANDOFF.md new file mode 100644 index 0000000..cdc6295 --- /dev/null +++ b/SOUND_DESIGN_HANDOFF.md @@ -0,0 +1,136 @@ +# On The Level — Audio Feedback Handoff + +Handoff from the sound-design session (2026-07-16) to the app implementation. +The design is final and approved by Jay; assets are ready to ship. + +Two modes, two mechanisms, **one sound identity** — every sound derives from +the same 500 Hz blip: + +- **Edge mode (1-D)**: sonar staircase, one discrete report per settle event. +- **Surface mode (2-D)**: continuous proximity ticking — tick rate rises as + you approach the bullseye; on lock, `level.wav` plays as the arrival sound. + +The staircase does NOT ship in surface mode. All earlier discussion of 2-D +dominant-axis mapping is dead — surface is axis-agnostic warmer/colder by +design, because leveling a surface shouldn't require decoding anything a +bubble level wouldn't ask of you. + +## Surface mode: proximity ticking + +Assets: `tick.wav` (in AudioGenerator's `outputs/surface-proximity/`) — the +same 500 Hz blip shortened to 60 ms with a soft release. The app plays this +single asset and schedules repetitions; rate is computed, not baked into +loops. Arrival = the shared `level.wav` (already in this repo under +`sonar-staircase-v3/`). + +Rules — these are the difference between this and the old rejected +"annoying mode": + +1. Map total error → tick rate **exponentially**, ~1.5 ticks/s (far) to + ~8 ticks/s (near). Hard cap at 8/s; beyond ~10/s it fuses into a buzz. +2. **Loudness stays constant** as rate rises. The acceleration is the signal; + volume ramping is what turns it into a panic siren. +3. On entering the lock zone: stop ticking, play `level.wav` once, then stay + silent while level holds. +4. **Hysteresis on the lock boundary** (enter slightly inside, exit slightly + outside) so the arrival sound can't retrigger repeatedly at the threshold. +5. Ticking runs continuously, no settle gate — in this mode the realtime rate + IS the feedback. The settle-then-sound rule below applies to edge mode. + +Rejected candidates kept for reference in AudioGenerator: the sustained +"energy field" loops (`bullseye-field-*.wav`) and the alternate ticks +(`outputs/tick-candidates/`). Do not ship them. + +## Edge mode: the "sonar staircase" + +Leveling feedback is a short train of four sonar blips whose **pitch contour is +the message**: + +| Reading | Sound | Pitch steps (semitones per blip) | +|---|---|---| +| Level | flat train — all four blips at the same pitch | 0, 0, 0, 0 | +| Slightly low (raise it a little) | gentle rising staircase | 0, +1, +2, +3 | +| Very low (raise it a lot) | steep rising staircase | 0, +3, +6, +9 | +| Slightly high | gentle falling staircase | 0, −1, −2, −3 | +| Very high | steep falling staircase | 0, −3, −6, −9 | + +Why this works: +- **Direction** = contour direction (rising = go higher; falling = go lower). + Never invert this mapping. +- **Magnitude** = contour steepness. Humans are poor at absolute pitch but + excellent at relative pitch, so a staircase is decodable without a reference. +- **Level literally sounds "flat."** The metaphor is self-explaining, and the + flat train doubles as the reward/confirmation state. +- **Constant rhythm at every zone.** All five sounds share the same 175 ms blip + cadence — a far-off reading is exactly as fast as a near one. Direction is + audible by blip 2 (~200 ms), full reading by ~525 ms; the rest is decay. + +## When to play it (edge mode) + +Staircase feedback fires **only after movement followed by settling** (same +trigger as the app's voice feedback). One staircase per settle event. + +## The assets + +`outputs/sonar-staircase-v3/` (copy into the app bundle): + +| File | Duration | +|---|---| +| `level.wav` | 1.39 s | +| `slightly-low.wav` | 1.36 s | +| `very-low.wav` | 1.32 s | +| `slightly-high.wav` | 1.42 s | +| `very-high.wav` | 1.50 s | + +All are 96 kHz stereo PCM-16 WAV, peak-normalized to 0.85. Downsampling to +44.1/48 kHz for the bundle is fine. The 500 Hz fundamental sits comfortably in +phone-speaker range, including the −9 st variant (~297 Hz). + +## Anatomy of the sound (for regeneration or runtime synthesis) + +- **Blip**: a single pure 500 Hz sine blip (100% tonal purity), ~165 ms + including its natural decay, extracted from Jay's licensed SoundQ file + (`~/Downloads/SoundQ Audio/UIBeep_Beep Sonar_PSE_BW-BD3HL_OmEo5.wav`, + segment 515–680 ms). 4 ms attack fade, 30 ms release fade. +- **Train**: 4 blips, onsets every 175 ms, pitched per the table above. + Pitch shifts are plain resampling (varispeed — pitch and length change + together). On Android, `SoundPool.setRate()` reproduces this exactly, and + its 0.5–2.0 range covers the full ±12 semitones used here. +- **Tail**: NOT reverb. The decay is *rhythmic*: the final blip repeats 4 more + times at the same 175 ms cadence, each repeat −8 dB, holding the last blip's + pitch (the tail "holds up the answer"). Jay specifically rejected a smooth + reverb tail — the pulse must survive into silence. This matches the source + file's own decay behavior (measured: repeats every ~180 ms, ~6 dB/repeat). + +Generator script: `build_staircase.py` in this folder (AudioGenerator). +Every parameter above is a named constant at the top. Run with +`.venv/bin/python build_staircase.py`; outputs to `outputs/sonar-staircase-v3/`. + +## Implementation notes + +1. **Hysteresis on tier boundaries (important).** With discrete tiers, a + reading sitting at the slightly/very threshold will flip-flop between + sounds on consecutive settles and feel indecisive. Require ~15% past a + boundary before switching tiers. Same for the level/slightly boundary. +2. **Three tiers is deliberate — don't add a fourth.** Absolute-judgment + limits make >3 discrete steepness levels hard to tell apart in the field. + If finer feedback is ever wanted, synthesize the blips at runtime instead + (enveloped sine oscillators via AudioTrack or Oboe) and make steepness + *continuous* — proportional to the error angle. Continuous mapping + sidesteps the categorization limit entirely; users only compare successive + readings ("shallower than last time = converging"). +3. **Interruption**: if a new settle event lands while a staircase is still + playing its tail, cut it and play the new reading — the tail is decoration, + the fresh reading is information. +4. **Haptic pairing (optional)**: a light haptic tap synced to the level.wav + blips makes the "arrived" state feel physical; the tool is usually pressed + against a surface, so it lands well. + +## Licensing + +The blip derives from Jay's SoundQ "Lifetime License" pack — perpetual +royalty-free use, which for SFX marketplaces normally covers embedding in +apps; standalone redistribution of the sounds is what's prohibited. If any +doubt ever arises: the blip is a mathematically pure 500 Hz sine with simple +envelopes, so a from-scratch synthesized replacement (zero licensing) is +trivial and indistinguishable — the design carries the value, not the sample. diff --git a/app/src/main/java/com/onthelevel/core/audio/AudioAssistStateMachine.kt b/app/src/main/java/com/onthelevel/core/audio/AudioAssistStateMachine.kt deleted file mode 100644 index 9df8c42..0000000 --- a/app/src/main/java/com/onthelevel/core/audio/AudioAssistStateMachine.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt b/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt new file mode 100644 index 0000000..c92f0db --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/audio/SurfaceTickPolicy.kt @@ -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 + } +} diff --git a/app/src/main/java/com/onthelevel/core/audio/VoiceGuidancePolicy.kt b/app/src/main/java/com/onthelevel/core/audio/VoiceGuidancePolicy.kt new file mode 100644 index 0000000..df519f1 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/audio/VoiceGuidancePolicy.kt @@ -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 + } +} diff --git a/app/src/main/java/com/onthelevel/core/settings/AudioAssistMode.kt b/app/src/main/java/com/onthelevel/core/settings/AudioAssistMode.kt new file mode 100644 index 0000000..1637d9b --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/settings/AudioAssistMode.kt @@ -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 } diff --git a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt index 68405ff..b00e7e7 100644 --- a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt +++ b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt @@ -38,7 +38,16 @@ class SettingsRepository(context: Context) { } val hapticsEnabled: Flow = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true } - val audioCueEnabled: Flow = store.data.map { it[Keys.AUDIO_CUE_ENABLED] ?: false } + + /** Off / Tones / Voice, migrating from the legacy audio-cue boolean if unset. */ + val audioAssistMode: Flow = 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 = 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") 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 32d0c3b..104911b 100644 --- a/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt +++ b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt @@ -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() - private var pendingSoundId: Int? = null - private var initialized = false + private val loaded = mutableSetOf() 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() + } } 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 9a3d9da..4bfe829 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt @@ -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, 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 ae4339e..7187607 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -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) { diff --git a/app/src/main/res/raw/level.wav b/app/src/main/res/raw/level.wav new file mode 100644 index 0000000..a620060 Binary files /dev/null and b/app/src/main/res/raw/level.wav differ diff --git a/app/src/main/res/raw/sonar_lock.wav b/app/src/main/res/raw/sonar_lock.wav deleted file mode 100644 index 2c2e307..0000000 Binary files a/app/src/main/res/raw/sonar_lock.wav and /dev/null differ diff --git a/app/src/main/res/raw/sonar_proximity.wav b/app/src/main/res/raw/sonar_proximity.wav deleted file mode 100644 index 936c3fc..0000000 Binary files a/app/src/main/res/raw/sonar_proximity.wav and /dev/null differ diff --git a/app/src/main/res/raw/tick.wav b/app/src/main/res/raw/tick.wav new file mode 100644 index 0000000..297e372 Binary files /dev/null and b/app/src/main/res/raw/tick.wav differ diff --git a/app/src/test/java/com/onthelevel/core/audio/AudioAssistStateMachineTest.kt b/app/src/test/java/com/onthelevel/core/audio/AudioAssistStateMachineTest.kt deleted file mode 100644 index ec3e015..0000000 --- a/app/src/test/java/com/onthelevel/core/audio/AudioAssistStateMachineTest.kt +++ /dev/null @@ -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))) - } -} diff --git a/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt b/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt new file mode 100644 index 0000000..4bdd47f --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/audio/SurfaceTickPolicyTest.kt @@ -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))) + } +} diff --git a/app/src/test/java/com/onthelevel/core/audio/VoiceGuidancePolicyTest.kt b/app/src/test/java/com/onthelevel/core/audio/VoiceGuidancePolicyTest.kt new file mode 100644 index 0000000..0b6a9e2 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/audio/VoiceGuidancePolicyTest.kt @@ -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))) + } +} diff --git a/sonar-staircase-v3/level.wav b/sonar-staircase-v3/level.wav new file mode 100644 index 0000000..da084a3 Binary files /dev/null and b/sonar-staircase-v3/level.wav differ diff --git a/sonar-staircase-v3/slightly-high.wav b/sonar-staircase-v3/slightly-high.wav new file mode 100644 index 0000000..38f2938 Binary files /dev/null and b/sonar-staircase-v3/slightly-high.wav differ diff --git a/sonar-staircase-v3/slightly-low.wav b/sonar-staircase-v3/slightly-low.wav new file mode 100644 index 0000000..4552f7a Binary files /dev/null and b/sonar-staircase-v3/slightly-low.wav differ diff --git a/sonar-staircase-v3/very-high.wav b/sonar-staircase-v3/very-high.wav new file mode 100644 index 0000000..3897987 Binary files /dev/null and b/sonar-staircase-v3/very-high.wav differ diff --git a/sonar-staircase-v3/very-low.wav b/sonar-staircase-v3/very-low.wav new file mode 100644 index 0000000..fd11f5a Binary files /dev/null and b/sonar-staircase-v3/very-low.wav differ