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>
6.7 KiB
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.wavplays 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":
- 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.
- Loudness stays constant as rate rises. The acceleration is the signal; volume ramping is what turns it into a panic siren.
- On entering the lock zone: stop ticking, play
level.wavonce, then stay silent while level holds. - Hysteresis on the lock boundary (enter slightly inside, exit slightly outside) so the arrival sound can't retrigger repeatedly at the threshold.
- 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
- 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.
- 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").
- 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.
- 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.