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