Slice 5: Audio Level Assist + fixed guidance slot (Codex)

- 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>
This commit is contained in:
Jay
2026-07-13 16:43:07 -04:00
parent 027b9b96b3
commit 30417a5f98
6 changed files with 348 additions and 27 deletions
@@ -0,0 +1,102 @@
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,124 @@
package com.onthelevel.feature.level
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.onthelevel.R
import com.onthelevel.core.audio.AudioAssistStateMachine
/**
* Lifecycle-scoped SoundPool player for the optional hands-free Surface assist.
* It mixes as sonification and intentionally never asks Android for audio focus.
*/
@Composable
fun AudioLevelAssist(reading: LevelReading?, isEnabled: Boolean) {
val isForeground = rememberIsResumed()
val stateMachine = remember { AudioAssistStateMachine() }
val context = LocalContext.current.applicationContext
val player = remember(isEnabled, isForeground, context) {
if (isEnabled && isForeground) SonarSoundPool(context) else null
}
DisposableEffect(player) {
onDispose { player?.release() }
}
LaunchedEffect(reading, isEnabled, isForeground, stateMachine, player) {
val current = reading ?: return@LaunchedEffect
when (
stateMachine.update(
AudioAssistStateMachine.Input(
isEnabled = isEnabled,
isAppForeground = isForeground,
placementOk = current.placementOk,
surfacePresentation = current.surfacePresentation,
isSettling = current.isSettling,
displayPrimaryDegrees = current.displayPrimaryDegrees,
isLocked = current.isLocked,
fireLockFeedback = current.fireFeedback,
nowMillis = System.currentTimeMillis(),
),
)
) {
AudioAssistStateMachine.Cue.PROXIMITY -> player?.playProximity()
AudioAssistStateMachine.Cue.LOCK -> player?.playLock()
null -> Unit
}
}
}
@Composable
private fun rememberIsResumed(): Boolean {
val lifecycleOwner = LocalLifecycleOwner.current
var isResumed by remember(lifecycleOwner) {
mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, _ ->
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
return isResumed
}
private class SonarSoundPool(context: Context) {
private val loadedSoundIds = mutableSetOf<Int>()
private var pendingSoundId: Int? = null
private var initialized = false
private val soundPool = SoundPool.Builder()
.setMaxStreams(1)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
.build()
private val proximitySound: Int
private val lockSound: Int
init {
soundPool.setOnLoadCompleteListener { _, sampleId, status ->
if (status == 0) {
loadedSoundIds += sampleId
if (initialized && pendingSoundId == sampleId) {
pendingSoundId = null
play(sampleId, volumeFor(sampleId))
}
}
}
proximitySound = soundPool.load(context, R.raw.sonar_proximity, 1)
lockSound = soundPool.load(context, R.raw.sonar_lock, 1)
initialized = true
}
fun playProximity() = play(proximitySound, volume = 0.28f)
fun playLock() = play(lockSound, volume = 0.36f)
private fun play(soundId: Int, volume: Float) {
if (soundId in loadedSoundIds) {
soundPool.play(soundId, volume, volume, 1, 0, 1f)
} else {
pendingSoundId = soundId
}
}
private fun volumeFor(soundId: Int): Float =
if (soundId == lockSound) 0.36f else 0.28f
fun release() = soundPool.release()
}
@@ -22,11 +22,13 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -49,6 +51,7 @@ import com.onthelevel.core.sensors.SurfacePresentation
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
import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
/** /**
@@ -75,12 +78,15 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> 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
.collectAsStateWithLifecycle(initialValue = false)
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
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC) .collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val view = LocalView.current val view = LocalView.current
val scope = rememberCoroutineScope()
val lockDetector = remember { LockDetector() } val lockDetector = remember { LockDetector() }
LaunchedEffect(mode) { lockDetector.reset() } LaunchedEffect(mode) { lockDetector.reset() }
@@ -91,6 +97,7 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> 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)
val isLocked = reading?.isLocked == true val isLocked = reading?.isLocked == true
val isCalibrated = when (mode) { val isCalibrated = when (mode) {
@@ -116,6 +123,16 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint, color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = {
scope.launch { container.settings.setAudioCueEnabled(!audioCueEnabled) }
}) {
Text(
text = if (audioCueEnabled) "SONAR ON" else "SONAR OFF",
style = MaterialTheme.typography.labelSmall,
color = if (audioCueEnabled) LevelColors.LimeLockText else LevelColors.TextDim,
)
}
IconButton(onClick = onOpenSurfaceCalibration) { IconButton(onClick = onOpenSurfaceCalibration) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,
@@ -124,6 +141,7 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
) )
} }
} }
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
LevelMode.entries.forEachIndexed { index, entry -> LevelMode.entries.forEachIndexed { index, entry ->
@@ -183,10 +201,20 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
) )
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) { if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// This slot stays the same height for Settling, guidance, and
// level states so a stable phone never makes the instrument
// jump as its correction copy changes.
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
contentAlignment = Alignment.TopCenter,
) {
SurfaceAdjustmentInfo(reading, measurementUnits) SurfaceAdjustmentInfo(reading, measurementUnits)
} }
} }
} }
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -215,7 +243,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
*/ */
@Composable @Composable
private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) { private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) {
if (reading == null) return Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (reading == null) return@Column
if (reading.isSettling) { if (reading.isSettling) {
Text( Text(
@@ -223,12 +252,12 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim, color = LevelColors.TextDim,
) )
return return@Column
} }
val pitch = reading.secondaryADegrees ?: return val pitch = reading.secondaryADegrees ?: return@Column
val roll = reading.secondaryBDegrees ?: return val roll = reading.secondaryBDegrees ?: return@Column
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column
Text( Text(
text = highLabel, text = highLabel,
@@ -242,6 +271,7 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
color = LevelColors.TextDim, color = LevelColors.TextDim,
) )
} }
}
@Composable @Composable
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) { private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,65 @@
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)))
}
}