diff --git a/app/src/main/java/com/onthelevel/core/audio/AudioAssistStateMachine.kt b/app/src/main/java/com/onthelevel/core/audio/AudioAssistStateMachine.kt new file mode 100644 index 0000000..9df8c42 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/audio/AudioAssistStateMachine.kt @@ -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 + } +} diff --git a/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt new file mode 100644 index 0000000..32d0c3b --- /dev/null +++ b/app/src/main/java/com/onthelevel/feature/level/AudioLevelAssist.kt @@ -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() + 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() +} diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt index 53e3297..cf496dd 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -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 diff --git a/app/src/main/res/raw/sonar_lock.wav b/app/src/main/res/raw/sonar_lock.wav new file mode 100644 index 0000000..2c2e307 Binary files /dev/null and b/app/src/main/res/raw/sonar_lock.wav differ diff --git a/app/src/main/res/raw/sonar_proximity.wav b/app/src/main/res/raw/sonar_proximity.wav new file mode 100644 index 0000000..936c3fc Binary files /dev/null and b/app/src/main/res/raw/sonar_proximity.wav differ diff --git a/app/src/test/java/com/onthelevel/core/audio/AudioAssistStateMachineTest.kt b/app/src/test/java/com/onthelevel/core/audio/AudioAssistStateMachineTest.kt new file mode 100644 index 0000000..ec3e015 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/audio/AudioAssistStateMachineTest.kt @@ -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))) + } +}