Three-way consensus (Jay/Codex/Claude): one sound, split timing not identity.
The bullseye sound now means 'centered right now' and fires the instant you're
aligned - even a fast pass over center - so the zone that needs the most help
locating always announces itself. It no longer waits on the velocity+dwell
lock, which was the delay near center.
- SurfaceTickPolicy: outside the center zone, proximity ticks (unchanged
anchor scheduler). Inside, emit ALIGNED every frame -> caller loops level.wav.
Entry is immediate (no dwell, no velocity gate). Alignment uses SPATIAL
hysteresis (enter <=0.2, rearm only after leaving >=0.35) - not a time
debounce, so a legitimate quick re-crossing still announces. Ticks resume
immediately on exit.
- Alignment error = hypot(stable pitch, stable roll) - the SAME axes that drive
the bubble, so the sound can't lag the visual (Codex's implementation note).
- Player: level.wav loops while aligned (ensureLevelLooping, idempotent),
stopped before every restart and on exit; pending-load queue retained.
- The held-level confirmation (persistent lime, label, haptic) stays on the
velocity-aware LockDetector; the locating sound is fully decoupled from it.
Tests: immediate cue on fast crossing, one start / ticks-suppressed while
inside, stop+resume-ticks on exit, spatial-hysteresis rearm, tick reactivity.
81 tests passing; assembleDebug clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Voice (final P1 from audit): the policy is no longer consumed until TTS is
ready, so a phrase can't be queued and then spoken stale after init. When
ready, the policy evaluates the CURRENT reading (silent if moving, correct
instruction if settled). VoiceSpeaker drops its pending-text queue and just
exposes isReady; the policy is remember(speaker) so each VOICE session starts
fresh. Covers all of Codex's cancellation cases (movement, unlock, invalid
placement, foreground loss, mode change, shutdown).
Velocity-aware Surface lock (Jay's observation + Codex): the fixed 400 ms
dwell made a slowly-arriving bubble wait, delaying the level sound, while a
fast crossing could still lock. LockDetector now takes a movement rate:
- error <=0.2 deg AND rate <=0.3 deg/s -> lock after a short 175 ms confirm;
- faster than that -> dwell never accumulates (a center fly-through never
locks); slowing to a stop inside the zone starts a fresh confirmation;
- 0.35 deg exit hysteresis and the single shared lock (sound/lime/haptic)
unchanged.
LevelPipeline feeds SettlingDetector.movementRateDegreesPerSecond (was
discarded); Edge passes null -> classic fixed 400 ms dwell. Simple gated
machine, no adaptive-dwell formula.
Tests: slow-entry-locks-promptly, fast-traversal-never-locks, intermediate-
speed-no-accumulate, slowing-inside-zone-fresh-dwell, exit hysteresis,
feedback debounce, Edge fixed-dwell fallback. 82 tests passing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P1 - Tick cadence now reacts immediately to changing tilt. The due time is
recomputed every update from the last-tick anchor plus the CURRENT interval,
so dropping from 5deg to 0.4deg accelerates to the fast cadence at once
instead of waiting out the stale slow interval. Anchor advances by whole
intervals (no drift) and resyncs to now if a full interval behind (no burst).
Added far->near and near->far transition tests.
P1 - One-shot cues no longer lost to async loading. SonarSoundPool queues a
Level asked for before its sample loads and plays it on load-complete (and
cancels the queued Level on unlock/gate). VoiceSpeaker queues the latest
phrase requested before TTS finishes init and speaks it on ready. The policy
fires these once, so they can't rely on retries.
P2 - Unlock stops the level.wav tail BEFORE playing the resumed tick (order
was reversed, allowing a brief overlap).
P2 - Audio-mode preference write moved from the screen's rememberCoroutineScope
to container.applicationScope, so a quick navigation can't cancel it (matches
the earlier persistence hardening).
Cleanup: AudioAssistMode and LevelPipeline comments now describe ticking, not
the retired radar pings/homing bands; AUDIO_DESIGN_LOG cadence principle
reconciled with the 125 ms continuous-tick near rate.
78 tests passing; assembleDebug clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Non-blocking follow-up from the audit: applicationScope ran on
Dispatchers.Default, so two very fast conflicting persistence writes
(recalibrate then immediately Reset) had no ordering guarantee. Main.immediate
launches the coroutine body synchronously in call order, so the DataStore
edits enqueue deterministically; DataStore still does the IO on its own
dispatcher. Only lightweight persistence uses this scope.
64 tests passing; assembleDebug clean; runs on device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P1 - Tools screen no longer exposes dead 'coming with the feature build'
cards. Replaced the stale Calibration and Units/Feedback cards with a
single working Settings card that opens the Settings screen.
P1 - Detailed (4-point) calibration now validates rotation quality, not
just the final mean. A 4-point set holds two independent 180-flip bias
estimates - the (0,180) and (90,270) pairs; a malformed turn makes them
diverge even when the mean stays small (which the 3-degree magnitude
guard can't see). isRotationConsistent rejects divergence beyond 0.4
degrees. The disagreement scales with the surface's true tilt, so it's
lenient near level and strict on a steeper permitted surface. Test
proves a deliberately 10-degree-over-rotated set is rejected and a clean
set passes.
P2 - Calibration persistence moved from the screen's rememberCoroutineScope
to a container-owned applicationScope, so the write survives an immediate
Done/back after 'saved'. Preference writes moved there too.
Cleanup: haptic copy is mode-neutral ('when the level locks'); Settings
no longer keeps the screen awake (calibration still does); calibration
method renamed Thoroughness -> Method with Standard/Detailed labels and
accurate 'Four positions, a quarter-turn apart' / 'Averages more sensor
noise' copy; lime now marks the active-calibration status (confirmed
state), consistent with lime = level/confirmed.
64 tests passing; assembleDebug clean; verified on device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes a real defect and reworks the calibration/settings flow per Jay's
on-device review.
Bug: the second capture hung on 'capturing'. The completion code wrote
capture=null (a LaunchedEffect key) then awaited the suspend
setSurfaceCalibration BEFORE setting phase=COMPLETE; at the suspension
Compose cancelled the effect mid-write, stranding the transition. The
effect body is now fully synchronous and persistence runs on an
independent scope.launch. Verified end-to-end on device.
Settings screen (new): reached from the Level header gear (was: gear
jumped straight into calibration). Calibration is a sleek button in its
own section; Units, Haptics, and Reduce-motion prefs get a home; Done is
pinned to the bottom while the list scrolls independently.
Calibration screen: two stages. OVERVIEW shows the current correction
(Pitch/Roll offsets), the Thoroughness selector, and Calibrate / Reset
to phone defaults / Done. CAPTURING shows ONLY capture + Cancel; Cancel
restores the prior calibration (saved value is never touched mid-flow).
Instructions centered; outlined buttons given visible borders.
Thoroughness: Simple (2-point, 0/180) or Thorough (4-point,
0/90/180/270). Generalized deriveSurfaceFromSamples averages a symmetric
rotation set - the true tilt sums to zero, so the mean is the device
bias; 4-point also cancels each axis twice and averages more noise. Two
new unit tests (2-point equivalence, 4-point bias recovery).
61 tests passing; assembleDebug clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Driven by on-device iteration on a Galaxy S24+ (live ADB install/
screenshot loop), gated by a real-device motion pass at animator
scale 1x and frame stats (p95 8ms, 0.85% legacy jank).
Motion (Codex): X/Y axes animate in independent concurrent effects.
The prior sequential animateTo calls starved Y under ~50Hz
retargeting - diagonal movement traced an L and the second axis
caught up seconds late. Reduced-motion/system-disabled paths snap
both axes.
Instrument (Claude, on Codex's base):
- Fluorescent yellow-green fluid (matches real spirit-level dye);
amber retained for guidance text.
- Bubble as a true void: borderless symmetric gradient orb,
translucent interior (marks read through it), soft bright
refraction band, contact shadow, fluorescent halo, window-style
reflection + glint. Velocity-squash experiment tried and removed -
shape fidelity beats the flourish.
- Bubble (0.12R) nests inside the 1-degree target ring; ring turns
lime on VISUAL containment of the drawn bubble (lock label/pulse/
haptic remain on LockDetector truth).
- Machined bezel, etched cardinal/45 ticks, per-ring degree labels
via the same SurfaceGuidance mapping. dp-scaled strokes replace
raw-pixel hairlines; contrast lifted for real screens.
- Red center target dot beneath the translucent bubble (Codex).
Text hierarchy: guidance suppressed while locked (no correction
advice under a 'Flat within' verdict), demoted to titleLarge, and
raised into the empty status line's slot. Primary readout optically
dead-centered via an invisible leading degree twin; degree symbol
.55em at TextDim with cap-height baseline shift (.38 - metrics pair,
resize together).
59 tests passing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AudioAssistStateMachine (core/audio, pure, tested): proximity bands
with enter/exit hysteresis (close 1.0/1.2, near 3.0/3.4 degrees),
cadence preserved across band changes (no threshold double-ping),
lock cue driven solely by LockDetector's fireFeedback transition -
no second lock threshold, and the haptic debounce paces the ping.
- Silence gates: disabled, backgrounded, settling, invalid placement,
or outside FACE_UP all reset and mute; all five unit-tested.
- SonarSoundPool: locally synthesized WAV pings via SoundPool with
USAGE_ASSISTANCE_SONIFICATION; zero audio-focus APIs (verified by
grep); player exists only while enabled and resumed, released on
composition disposal.
- Persisted SONAR ON/OFF header control using the existing
audioCueEnabled preference; off by default; state shown as text,
never color alone.
- Guidance slot fixed at 56dp so settling/correction/level copy no
longer shifts the instrument (Slice 4 polish note).
59 tests passing.
Audited-by: Claude (no findings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SurfaceAdjustmentInfo: high-side instruction sourced exclusively from
SurfaceGuidance.from(displayed pitch, displayed roll).highLabel - the
deadbanded axes supply the label hysteresis, so wording cannot flap
at direction boundaries.
- Shared SettlingDetector exposed through the pipeline as the sole
motion gate; strong guidance defers behind a quiet Settling state.
- RiseRun (core/sensors, pure): tan-based mm/m and in/ft, derived from
the same deadbanded magnitude as the numeric readout; pinned at
45 degrees = 1000 mm/m = 12 in/ft.
- Persisted MeasurementUnits preference in core/settings (metric
default); Tools UI control remains deferred per plan.
- Guidance renders only in face-up Surface mode, hidden at level
(no direction below the 0.1-degree deadband).
56 tests passing.
Audited-by: Claude (no findings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SurfaceGuidance (core/sensors, pure, tested): the sole source of
bubble position, high-side direction, and ring scale. Bubble and
1/2/5-degree etched rings share one 5-degree visual range; the
clamped target puts a saturated bubble exactly at the rim ring.
- SurfaceBullseye: Compose Canvas glass vial with crosshairs and
amber bubble. Critically damped (no-overshoot) spring toward the
pre-clamped target; velocity carries across retargets. Reduced
motion (in-app pref or ANIMATOR_DURATION_SCALE == 0) snaps directly.
- Lock treatment: brief lime ring pulse on acquisition, persistent
lime tolerance label while locked, neutral numeric readout. Never
color-only; no full-screen lime state.
- Pipeline passes stable calibrated pitch/roll to the instrument;
deadbanded readout remains authoritative. Bullseye renders only in
the FACE_UP presentation.
53 tests passing.
Audited-by: Claude (1 finding raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SurfaceCalibrationScreen: two-sample guided flow from the Level header.
Captures read SensorSource.gravity directly - stored calibration is
never applied to either sample. Exact 180-degree same-plane guidance,
clear-saved-calibration action, Surface persistence only.
- SettlingDetector (core/sensors, shared): 1.0 deg/s enter, 0.3 deg/s
exit held 500 ms; mid-band movement resets the quiet dwell without
leaving Settling (audit finding 1).
- StableSurfaceCapture (core/sensors): continuous 1.5 s settled window
with the 5-degree calibration-surface guard; restarts on motion.
- SurfaceCalibrationValidation (core/sensors): 3-degree bound on the
combined two-axis bias magnitude, unit-tested both sides of the
boundary (audit finding 2).
- Lock feedback suppression is structural: the calibration route removes
LevelScreen from composition, cancelling its sensor collection.
50 tests passing.
Audited-by: Claude (2 findings raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Surface tilt magnitude stays visible 0-180 degrees; never blanked by
placement. Lock is self-gating (only near-zero magnitude can enter),
so the placement gate now drives hints only. Edge gating unchanged.
- Pitch/Roll suppressed as ambiguous at >=80 degrees; explicit
screen-down state past 90 degrees with the true magnitude retained.
- SurfacePresentationDetector classifies from the same deadbanded
stable magnitude the UI displays, with 1-degree hysteresis at both
boundaries and a direct face-up -> screen-down transition.
- Tests: angle sweep (30/80/near-vertical/screen-down), hysteretic
boundary tests, and a sustained 30-degree run proving a steep phone
never locks or fires feedback. 43 tests passing.
Audited-by: Claude (2 findings raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Calibration: still derived in angle space via the 180-degree flip, but now
APPLIED in vector space as a reference-orientation rotation (Rodrigues
alignment for Surface, Z-rotation for Edge), exact away from zero; tests
at 30 degrees, cross-axis, and edge-polarity cases.
- Angle relative zero: stores the gravity direction vector; relative reading
is the angle between directions, so cross-axis movement is honest.
- Edge mode: precise geometry documented (either long edge down, gravity
along +/-X), placement-validity guard so e.g. Edge mode never locks on a
phone lying flat; UI shows repositioning hints.
- Lock/readout coherence: locked label states the tolerance (exit threshold)
so the rounded readout can never contradict it; pipeline acceptance tests
pin the invariant.
- Sensor-vector contract: documented and pinned by SensorContractTest. The
suggested negation of gravity/accelerometer fallbacks is NOT applied: per
Android SensorEvent docs, a stationary flat device reads +9.81 on Z (the
gravity reaction), matching the rotation-vector path as-is. The contract
tests prove all paths agree.
38 tests passing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>