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:
@@ -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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -49,6 +51,7 @@ import com.onthelevel.core.sensors.SurfacePresentation
|
||||
import com.onthelevel.core.settings.MeasurementUnits
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -75,12 +78,15 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
||||
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
|
||||
val hapticsEnabled by container.settings.hapticsEnabled
|
||||
.collectAsStateWithLifecycle(initialValue = true)
|
||||
val audioCueEnabled by container.settings.audioCueEnabled
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val reducedMotion by container.settings.reducedMotion
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val measurementUnits by container.settings.measurementUnits
|
||||
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
|
||||
|
||||
val view = LocalView.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val lockDetector = remember { LockDetector() }
|
||||
LaunchedEffect(mode) { lockDetector.reset() }
|
||||
|
||||
@@ -91,6 +97,7 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
||||
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
|
||||
}
|
||||
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
AudioLevelAssist(reading, audioCueEnabled)
|
||||
|
||||
val isLocked = reading?.isLocked == true
|
||||
val isCalibrated = when (mode) {
|
||||
@@ -116,12 +123,23 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
||||
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSurfaceCalibration) {
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
contentDescription = "Calibration and settings",
|
||||
tint = LevelColors.TextDim,
|
||||
)
|
||||
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) {
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
contentDescription = "Calibration and settings",
|
||||
tint = LevelColors.TextDim,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +201,17 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
||||
)
|
||||
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SurfaceAdjustmentInfo(reading, measurementUnits)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,32 +243,34 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
||||
*/
|
||||
@Composable
|
||||
private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) {
|
||||
if (reading == null) return
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (reading == null) return@Column
|
||||
|
||||
if (reading.isSettling) {
|
||||
Text(
|
||||
text = "Settling",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.TextDim,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
val pitch = reading.secondaryADegrees ?: return@Column
|
||||
val roll = reading.secondaryBDegrees ?: return@Column
|
||||
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column
|
||||
|
||||
if (reading.isSettling) {
|
||||
Text(
|
||||
text = "Settling",
|
||||
text = highLabel,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = LevelColors.Amber,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = formatRiseRun(reading.displayPrimaryDegrees, units),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.TextDim,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val pitch = reading.secondaryADegrees ?: return
|
||||
val roll = reading.secondaryBDegrees ?: return
|
||||
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return
|
||||
|
||||
Text(
|
||||
text = highLabel,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = LevelColors.Amber,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = formatRiseRun(reading.displayPrimaryDegrees, units),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.TextDim,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user