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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-17 09:43:18 -04:00
parent e5d15c920f
commit 36c0cfa5ab
22 changed files with 809 additions and 223 deletions
+76
View File
@@ -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.
+136
View File
@@ -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 515680 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.52.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.
@@ -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 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. */ /** 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 } 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 } 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) { suspend fun setReducedMotion(enabled: Boolean) {
store.edit { it[Keys.REDUCED_MOTION] = enabled } 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 EDGE_CAL_POSITIVE_X = booleanPreferencesKey("edge_cal_positive_x")
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled") val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
val AUDIO_ASSIST_MODE = stringPreferencesKey("audio_assist_mode")
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion") val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units") val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units")
val CALIBRATION_MODE = stringPreferencesKey("calibration_mode") val CALIBRATION_MODE = stringPreferencesKey("calibration_mode")
@@ -3,6 +3,8 @@ package com.onthelevel.feature.level
import android.content.Context import android.content.Context
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.SoundPool import android.media.SoundPool
import android.os.SystemClock
import android.speech.tts.TextToSpeech
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -15,46 +17,78 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.onthelevel.R 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. * Hands-free Surface audio assist (SOUND_DESIGN_HANDOFF.md).
* It mixes as sonification and intentionally never asks Android for audio focus. *
* 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 @Composable
fun AudioLevelAssist(reading: LevelReading?, isEnabled: Boolean) { fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
val isForeground = rememberIsResumed() val isForeground = rememberIsResumed()
val stateMachine = remember { AudioAssistStateMachine() } val tickPolicy = remember { SurfaceTickPolicy() }
val voicePolicy = remember { VoiceGuidancePolicy() }
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
val player = remember(isEnabled, isForeground, context) {
if (isEnabled && isForeground) SonarSoundPool(context) else null
}
DisposableEffect(player) { val player = remember(mode, isForeground, context) {
onDispose { player?.release() } 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 val current = reading ?: return@LaunchedEffect
// TONES: proximity ticks off a monotonic deadline; `level.wav` at lock.
when ( when (
stateMachine.update( tickPolicy.update(
AudioAssistStateMachine.Input( SurfaceTickPolicy.Input(
isEnabled = isEnabled, isEnabled = mode == AudioAssistMode.TONES,
isAppForeground = isForeground, isAppForeground = isForeground,
placementOk = current.placementOk, placementOk = current.placementOk,
surfacePresentation = current.surfacePresentation, surfacePresentation = current.surfacePresentation,
isSettling = current.isSettling, errorDegrees = current.stableTiltDegrees ?: current.displayPrimaryDegrees,
displayPrimaryDegrees = current.displayPrimaryDegrees,
isLocked = current.isLocked, isLocked = current.isLocked,
fireLockFeedback = current.fireFeedback, nowMillis = SystemClock.elapsedRealtime(),
nowMillis = System.currentTimeMillis(),
), ),
) )
) { ) {
AudioAssistStateMachine.Cue.PROXIMITY -> player?.playProximity() SurfaceTickPolicy.Cue.TICK -> player?.playTick()
AudioAssistStateMachine.Cue.LOCK -> player?.playLock() SurfaceTickPolicy.Cue.LEVEL -> player?.playLevel()
null -> Unit 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 class SonarSoundPool(context: Context) {
private val loadedSoundIds = mutableSetOf<Int>() private val loaded = mutableSetOf<Int>()
private var pendingSoundId: Int? = null
private var initialized = false
private val soundPool = SoundPool.Builder() private val soundPool = SoundPool.Builder()
.setMaxStreams(1) .setMaxStreams(4)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) .setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(), .build(),
) )
.build() .build()
private val proximitySound: Int private val tickSound: Int
private val lockSound: Int private val levelSound: Int
private var levelStreamId: Int = 0
init { init {
soundPool.setOnLoadCompleteListener { _, sampleId, status -> soundPool.setOnLoadCompleteListener { _, sampleId, status -> if (status == 0) loaded += sampleId }
if (status == 0) { tickSound = soundPool.load(context, R.raw.tick, 1)
loadedSoundIds += sampleId levelSound = soundPool.load(context, R.raw.level, 1)
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
} }
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) /** The "you're level" arrival; retained so it can be stopped on unlock. */
fun playLevel() {
private fun play(soundId: Int, volume: Float) { stopLevel()
if (soundId in loadedSoundIds) { if (levelSound in loaded) {
soundPool.play(soundId, volume, volume, 1, 0, 1f) levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, 0, 1f)
} else {
pendingSoundId = soundId
} }
} }
private fun volumeFor(soundId: Int): Float = fun stopLevel() {
if (soundId == lockSound) 0.36f else 0.28f if (levelStreamId != 0) {
soundPool.stop(levelStreamId)
levelStreamId = 0
}
}
fun release() = soundPool.release() 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. */ /** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */
val stableSurfacePitchDegrees: Double? = null, val stableSurfacePitchDegrees: Double? = null,
val stableSurfaceRollDegrees: 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. */ /** Surface-only presentation state; null for Edge mode. */
val surfacePresentation: SurfacePresentation? = null, val surfacePresentation: SurfacePresentation? = null,
/** False when the device is not physically in the selected mode's geometry. */ /** False when the device is not physically in the selected mode's geometry. */
@@ -101,6 +103,7 @@ class LevelPipeline(
} else { } else {
null null
}, },
stableTiltDegrees = magnitude,
stableSurfacePitchDegrees = pitch, stableSurfacePitchDegrees = pitch,
stableSurfaceRollDegrees = roll, stableSurfaceRollDegrees = roll,
surfacePresentation = presentation, surfacePresentation = presentation,
@@ -56,6 +56,7 @@ import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.sensors.SurfaceCalibration import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceGuidance import com.onthelevel.core.sensors.SurfaceGuidance
import com.onthelevel.core.sensors.SurfacePresentation import com.onthelevel.core.sensors.SurfacePresentation
import com.onthelevel.core.settings.AudioAssistMode
import com.onthelevel.core.settings.MeasurementUnits import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -86,8 +87,8 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE) .collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
val hapticsEnabled by container.settings.hapticsEnabled val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true) .collectAsStateWithLifecycle(initialValue = true)
val audioCueEnabled by container.settings.audioCueEnabled val audioMode by container.settings.audioAssistMode
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = AudioAssistMode.OFF)
val reducedMotion by container.settings.reducedMotion val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val measurementUnits by container.settings.measurementUnits val measurementUnits by container.settings.measurementUnits
@@ -105,7 +106,7 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() } .onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
} }
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null) val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
AudioLevelAssist(reading, audioCueEnabled) AudioLevelAssist(reading, audioMode)
val isLocked = reading?.isLocked == true val isLocked = reading?.isLocked == true
val isCalibrated = when (mode) { val isCalibrated = when (mode) {
@@ -132,13 +133,23 @@ fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// Tap cycles Off -> Tones -> Voice. Lime when active (a confirmed on-state).
TextButton(onClick = { 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(
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, 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) { 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)))
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.