From bb5d77fea35681c22d772c03d7a0dccf61fee406 Mon Sep 17 00:00:00 2001 From: Tretzi Date: Tue, 10 Mar 2026 23:24:49 -0500 Subject: [PATCH] Update mode toggle, split flash visuals, and interval-ms controls --- app/src/main/AndroidManifest.xml | 3 +- .../java/com/mindmachine/mvp/MainActivity.kt | 112 ++++++++++++------ .../java/com/mindmachine/mvp/domain/Models.kt | 12 +- .../mindmachine/mvp/session/MainViewModel.kt | 2 +- .../mvp/session/SessionValidator.kt | 2 +- .../mindmachine/mvp/session/VisualSignal.kt | 44 ++++--- .../com/mindmachine/mvp/PresetDefaultsTest.kt | 6 +- .../mindmachine/mvp/SessionValidatorTest.kt | 6 +- .../com/mindmachine/mvp/VisualSignalTest.kt | 75 ++++-------- 9 files changed, 137 insertions(+), 125 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 579af80..2f041a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,8 +12,7 @@ android:theme="@style/Theme.MindMachine"> + android:exported="true"> diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index d0a752a..6c59c25 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -1,5 +1,7 @@ package com.mindmachine.mvp +import android.app.Activity +import android.content.pm.ActivityInfo import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -29,14 +32,16 @@ import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -45,10 +50,10 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -63,9 +68,10 @@ import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.domain.CountdownPreference import com.mindmachine.mvp.domain.RuntimeState import com.mindmachine.mvp.domain.SessionMode -import com.mindmachine.mvp.domain.VisualPattern +import com.mindmachine.mvp.session.FlashColor import com.mindmachine.mvp.session.MainViewModel -import com.mindmachine.mvp.session.computeVisualAlpha +import com.mindmachine.mvp.session.SplitFlashFrame +import com.mindmachine.mvp.session.computeSplitFlashFrame import kotlin.math.roundToInt private val MindMachineColors = darkColorScheme( @@ -105,7 +111,7 @@ fun App(vm: MainViewModel = viewModel()) { val ui by vm.ui.collectAsStateWithLifecycle() val lifecycle = LocalLifecycleOwner.current.lifecycle - androidx.compose.runtime.DisposableEffect(lifecycle) { + DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP && ui.runtimeState == RuntimeState.RUNNING) { vm.pause("Session paused because app moved to background.") @@ -234,13 +240,23 @@ fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) { Text("Duration: ${cfg.durationSec / 60} min", color = MaterialTheme.colorScheme.onBackground) Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f) Text("Mode", color = MaterialTheme.colorScheme.onBackground) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SessionMode.values().forEach { mode -> - OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SessionMode.values().forEachIndexed { index, mode -> + SegmentedButton( + shape = androidx.compose.material3.SegmentedButtonDefaults.itemShape(index = index, count = SessionMode.values().size), + selected = cfg.mode == mode, + onClick = { vm.setMode(mode) }, + label = { Text(mode.name.replace("_", " ")) } + ) } } - Text("Blink ${cfg.blinkFrequencyHz} Hz", color = MaterialTheme.colorScheme.onBackground) - Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f) + Text("Flash interval: ${cfg.flashIntervalMs} ms", color = MaterialTheme.colorScheme.onBackground) + Slider( + value = cfg.flashIntervalMs.toFloat(), + onValueChange = { vm.setFlashIntervalMs(it.roundToInt()) }, + valueRange = 50f..2000f + ) + Text("Controls how long each frame is shown (red/green/black pattern).", color = MaterialTheme.colorScheme.onBackground) Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz", color = MaterialTheme.colorScheme.onBackground) Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f) Text("Difference ${cfg.binauralDifferenceHz} Hz", color = MaterialTheme.colorScheme.onBackground) @@ -257,22 +273,51 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { val ui by vm.ui.collectAsStateWithLifecycle() if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish() + val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY + val context = LocalContext.current + DisposableEffect(isVisualMode) { + val activity = context as? Activity + val previous = activity?.requestedOrientation + if (isVisualMode) { + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + onDispose { + if (isVisualMode) { + activity?.requestedOrientation = previous ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + } + var showOverlay by remember { mutableStateOf(true) } - val visualAlpha by rememberVisualAlpha( + val flashFrame by rememberSplitFlashFrame( isRunning = ui.runtimeState == RuntimeState.RUNNING, mode = ui.config.mode, - visualPattern = ui.config.visualPatternType, - blinkFrequencyHz = ui.config.blinkFrequencyHz, - intensityPercent = ui.config.intensityPercent + flashIntervalMs = ui.config.flashIntervalMs, ) Box( modifier = Modifier .fillMaxSize() .background(Color.Black) - .background(Color.White.copy(alpha = visualAlpha)) .clickable { showOverlay = !showOverlay } ) { + if (isVisualMode) { + Row(Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(flashFrame.left.toComposeColor()) + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(flashFrame.right.toComposeColor()) + ) + } + } + if (ui.runtimeState == RuntimeState.COUNTDOWN) { Text( "${ui.countdownSec}", @@ -286,7 +331,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.68f)) + .background(Color.Black.copy(alpha = 0.72f)) .padding(16.dp) ) { Text("${ui.selectedPreset.name} • ${ui.remainingSec}s", color = Color.White) @@ -309,28 +354,25 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) { } } +private fun FlashColor.toComposeColor(): Color = when (this) { + FlashColor.RED -> Color.Red + FlashColor.GREEN -> Color.Green + FlashColor.BLACK -> Color.Black +} + @Composable -private fun rememberVisualAlpha( +private fun rememberSplitFlashFrame( isRunning: Boolean, mode: SessionMode, - visualPattern: VisualPattern, - blinkFrequencyHz: Float, - intensityPercent: Int, -): androidx.compose.runtime.State { - return produceState(initialValue = 0f, isRunning, mode, visualPattern, blinkFrequencyHz, intensityPercent) { - if (!isRunning || mode == SessionMode.AUDIO_ONLY) { - value = 0f - return@produceState - } - while (true) { - withFrameNanos { frameTimeNanos -> - value = computeVisualAlpha( - visualPattern = visualPattern, - frameTimeNanos = frameTimeNanos, - blinkFrequencyHz = blinkFrequencyHz, - intensityPercent = intensityPercent - ) - } + flashIntervalMs: Int, +) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashIntervalMs) { + if (!isRunning || mode == SessionMode.AUDIO_ONLY) { + value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) + return@produceState + } + while (true) { + withFrameNanos { frameTimeNanos -> + value = computeSplitFlashFrame(frameTimeNanos, flashIntervalMs) } } } diff --git a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt index 31abed1..fdf12e4 100644 --- a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt +++ b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt @@ -11,7 +11,7 @@ data class SessionPreset( val description: String, val defaultDurationSec: Int, val visualPatternType: VisualPattern, - val blinkFrequencyHz: Float, + val flashIntervalMs: Int, val intensityPercent: Int, val carrierFrequencyHz: Float, val binauralDifferenceHz: Float, @@ -24,7 +24,7 @@ data class SessionConfig( val durationSec: Int, val mode: SessionMode, val visualPatternType: VisualPattern, - val blinkFrequencyHz: Float, + val flashIntervalMs: Int, val intensityPercent: Int, val carrierFrequencyHz: Float, val binauralDifferenceHz: Float, @@ -43,17 +43,17 @@ object Presets { val builtIn = listOf( SessionPreset( "relax", "Relax", "Gentle pulse, slower beat", 10 * 60, - VisualPattern.PULSE, 6f, 60, 200f, 6f, + VisualPattern.PULSE, 167, 60, 200f, 6f, "Stop if discomfort occurs.", 1 ), SessionPreset( "focus", "Focus", "Steady flash, conservative alert beat", 15 * 60, - VisualPattern.FLASH, 10f, 65, 220f, 10f, + VisualPattern.FLASH, 100, 65, 220f, 10f, "Use in a safe seated place only.", 2 ), SessionPreset( "sleep", "Sleep Prep", "Slow pulse, low intensity", 20 * 60, - VisualPattern.PULSE, 3f, 45, 180f, 3f, + VisualPattern.PULSE, 333, 45, 180f, 3f, "Do not use while doing other activities.", 3 ) ) @@ -64,7 +64,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL) durationSec = defaultDurationSec, mode = defaultMode, visualPatternType = visualPatternType, - blinkFrequencyHz = blinkFrequencyHz, + flashIntervalMs = flashIntervalMs, intensityPercent = intensityPercent, carrierFrequencyHz = carrierFrequencyHz, binauralDifferenceHz = binauralDifferenceHz, diff --git a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt index ce5c6de..40bac70 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -67,7 +67,7 @@ class MainViewModel( fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) } fun setDurationMin(min: Int) = _ui.update { it.copy(config = it.config.copy(durationSec = (min.coerceIn(1, 30) * 60)), error = null) } - fun setBlinkFrequency(value: Float) = _ui.update { it.copy(config = it.config.copy(blinkFrequencyHz = value.coerceIn(1f, 20f)), error = null) } + fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) } fun setCarrier(value: Float) = _ui.update { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), error = null) } fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) } diff --git a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt index a4bde1c..2cc0fe6 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt @@ -6,7 +6,7 @@ import com.mindmachine.mvp.domain.SessionMode object SessionValidator { fun validate(config: SessionConfig, headsetAvailable: Boolean): String? { if (config.durationSec !in 60..(30 * 60)) return "Duration must be 1 to 30 minutes." - if (config.blinkFrequencyHz !in 1f..20f) return "Blink frequency must be 1.0 to 20.0 Hz." + if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 50 to 2000 ms." if (config.carrierFrequencyHz !in 80f..400f) return "Carrier frequency must be 80 to 400 Hz." if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be 0.5 to 20 Hz." if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) { diff --git a/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt index 13941d4..13061d6 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt @@ -1,30 +1,26 @@ package com.mindmachine.mvp.session -import com.mindmachine.mvp.domain.VisualPattern -import kotlin.math.PI -import kotlin.math.sin +enum class FlashColor { + RED, + GREEN, + BLACK, +} -fun computeVisualAlpha( - visualPattern: VisualPattern, +data class SplitFlashFrame( + val left: FlashColor, + val right: FlashColor, +) + +fun computeSplitFlashFrame( frameTimeNanos: Long, - blinkFrequencyHz: Float, - intensityPercent: Int, -): Float { - val intensity = (intensityPercent.coerceIn(0, 100) / 100f) - if (intensity <= 0f) return 0f - - val frequency = blinkFrequencyHz.coerceIn(1f, 20f).toDouble() - val timeSeconds = frameTimeNanos / 1_000_000_000.0 - - return when (visualPattern) { - VisualPattern.FLASH -> { - val phase = (timeSeconds * frequency) % 1.0 - if (phase < 0.5) intensity else 0f - } - - VisualPattern.PULSE -> { - val pulse = ((sin(2.0 * PI * frequency * timeSeconds) + 1.0) / 2.0).toFloat() - (intensity * pulse).coerceIn(0f, intensity) - } + flashIntervalMs: Int, +): SplitFlashFrame { + val intervalMs = flashIntervalMs.coerceIn(50, 2000).toLong() + val elapsedMs = frameTimeNanos / 1_000_000L + return when (((elapsedMs / intervalMs) % 4).toInt()) { + 0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN) + 1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK) + 2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED) + else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK) } } diff --git a/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt b/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt index c86a438..8911c3f 100644 --- a/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt @@ -15,14 +15,14 @@ class PresetDefaultsTest { assertEquals(10 * 60, relax.defaultDurationSec) assertEquals(VisualPattern.PULSE, relax.visualPatternType) - assertEquals(6f, relax.blinkFrequencyHz) + assertEquals(167, relax.flashIntervalMs) assertEquals(15 * 60, focus.defaultDurationSec) assertEquals(VisualPattern.FLASH, focus.visualPatternType) - assertEquals(10f, focus.blinkFrequencyHz) + assertEquals(100, focus.flashIntervalMs) assertEquals(20 * 60, sleep.defaultDurationSec) assertEquals(VisualPattern.PULSE, sleep.visualPatternType) - assertEquals(3f, sleep.blinkFrequencyHz) + assertEquals(333, sleep.flashIntervalMs) } } diff --git a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt index e7cdf9a..4c9464c 100644 --- a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt @@ -1,8 +1,8 @@ package com.mindmachine.mvp +import com.mindmachine.mvp.domain.SessionConfig import com.mindmachine.mvp.domain.SessionMode import com.mindmachine.mvp.domain.VisualPattern -import com.mindmachine.mvp.domain.SessionConfig import com.mindmachine.mvp.session.SessionValidator import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -14,7 +14,7 @@ class SessionValidatorTest { durationSec = 600, mode = SessionMode.AUDIO_VISUAL, visualPatternType = VisualPattern.PULSE, - blinkFrequencyHz = 6f, + flashIntervalMs = 167, intensityPercent = 50, carrierFrequencyHz = 200f, binauralDifferenceHz = 6f, @@ -39,7 +39,7 @@ class SessionValidatorTest { @Test fun out_of_range_values_are_blocked() { assertEquals("Duration must be 1 to 30 minutes.", SessionValidator.validate(valid.copy(durationSec = 59), true)) - assertEquals("Blink frequency must be 1.0 to 20.0 Hz.", SessionValidator.validate(valid.copy(blinkFrequencyHz = 21f), true)) + assertEquals("Flash interval must be 50 to 2000 ms.", SessionValidator.validate(valid.copy(flashIntervalMs = 49), true)) assertEquals("Carrier frequency must be 80 to 400 Hz.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 79f), true)) assertEquals("Binaural difference must be 0.5 to 20 Hz.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f), true)) } diff --git a/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt index 7be7be9..755e31f 100644 --- a/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt @@ -1,67 +1,42 @@ package com.mindmachine.mvp -import com.mindmachine.mvp.domain.VisualPattern -import com.mindmachine.mvp.session.computeVisualAlpha +import com.mindmachine.mvp.session.FlashColor +import com.mindmachine.mvp.session.computeSplitFlashFrame import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class VisualSignalTest { @Test - fun flashPatternAlternatesBetweenIntensityAndOff() { - val intensity = 70 - val hz = 10f + fun splitFlashPattern_cycles_redGreen_black_greenRed_black() { + val intervalMs = 100 - val on = computeVisualAlpha( - visualPattern = VisualPattern.FLASH, - frameTimeNanos = 0L, - blinkFrequencyHz = hz, - intensityPercent = intensity - ) + val phase0 = computeSplitFlashFrame(frameTimeNanos = 0L, flashIntervalMs = intervalMs) + val phase1 = computeSplitFlashFrame(frameTimeNanos = 100_000_000L, flashIntervalMs = intervalMs) + val phase2 = computeSplitFlashFrame(frameTimeNanos = 200_000_000L, flashIntervalMs = intervalMs) + val phase3 = computeSplitFlashFrame(frameTimeNanos = 300_000_000L, flashIntervalMs = intervalMs) - val off = computeVisualAlpha( - visualPattern = VisualPattern.FLASH, - frameTimeNanos = 75_000_000L, - blinkFrequencyHz = hz, - intensityPercent = intensity - ) + assertEquals(FlashColor.RED, phase0.left) + assertEquals(FlashColor.GREEN, phase0.right) - assertEquals(0.7f, on, 0.0001f) - assertEquals(0f, off, 0.0001f) + assertEquals(FlashColor.BLACK, phase1.left) + assertEquals(FlashColor.BLACK, phase1.right) + + assertEquals(FlashColor.GREEN, phase2.left) + assertEquals(FlashColor.RED, phase2.right) + + assertEquals(FlashColor.BLACK, phase3.left) + assertEquals(FlashColor.BLACK, phase3.right) } @Test - fun pulsePatternStaysWithinZeroAndIntensity() { - val intensity = 60 + fun interval_is_clamped_to_valid_range() { + val tooFast = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 1) + val validAt50ms = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 50) + assertEquals(validAt50ms, tooFast) - val atStart = computeVisualAlpha( - visualPattern = VisualPattern.PULSE, - frameTimeNanos = 0L, - blinkFrequencyHz = 3f, - intensityPercent = intensity - ) - val quarterCycle = computeVisualAlpha( - visualPattern = VisualPattern.PULSE, - frameTimeNanos = 83_333_333L, - blinkFrequencyHz = 3f, - intensityPercent = intensity - ) - - assertTrue(atStart in 0f..0.6f) - assertTrue(quarterCycle in 0f..0.6f) - assertTrue(quarterCycle > atStart) - } - - @Test - fun intensityZeroAlwaysReturnsZero() { - val alpha = computeVisualAlpha( - visualPattern = VisualPattern.FLASH, - frameTimeNanos = 123_000_000L, - blinkFrequencyHz = 8f, - intensityPercent = 0 - ) - - assertEquals(0f, alpha, 0f) + val tooSlow = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 5000) + val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 2000) + assertEquals(validAt2000ms, tooSlow) } }