MindMachine: improve contrast and fix visual flash/pulse rendering

This commit is contained in:
Tretzi
2026-03-10 23:14:33 -05:00
parent d4990af092
commit fdf3bf01d3
3 changed files with 223 additions and 46 deletions

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -32,11 +33,15 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -49,21 +54,35 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.mindmachine.mvp.audio.BinauralAudioEngine import com.mindmachine.mvp.audio.BinauralAudioEngine
import com.mindmachine.mvp.audio.HeadsetMonitor import com.mindmachine.mvp.audio.HeadsetMonitor
import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.domain.CountdownPreference import com.mindmachine.mvp.domain.CountdownPreference
import com.mindmachine.mvp.domain.RuntimeState import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.domain.SessionMode import com.mindmachine.mvp.domain.SessionMode
import com.mindmachine.mvp.domain.VisualPattern
import com.mindmachine.mvp.session.MainViewModel import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.computeVisualAlpha
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val MindMachineColors = darkColorScheme(
primary = Color(0xFF8AB4FF),
onPrimary = Color(0xFF001B3D),
primaryContainer = Color(0xFF004689),
onPrimaryContainer = Color(0xFFD7E3FF),
secondary = Color(0xFFFFD166),
onSecondary = Color(0xFF2E2300),
background = Color(0xFF05070D),
onBackground = Color(0xFFF3F6FF),
surface = Color(0xFF121621),
onSurface = Color(0xFFF3F6FF),
error = Color(0xFFFF6B6B),
onError = Color(0xFF3A0002)
)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels { private val vm: MainViewModel by viewModels {
MainViewModel.Factory(SettingsRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine()) MainViewModel.Factory(SettingsRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine())
@@ -71,7 +90,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { App(vm) } setContent {
MaterialTheme(colorScheme = MindMachineColors) {
App(vm)
}
}
} }
} }
@@ -93,7 +116,7 @@ fun App(vm: MainViewModel = viewModel()) {
} }
val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome" val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome"
NavHost(navController = nav, startDestination = startRoute) { NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
composable("welcome") { composable("welcome") {
SimpleScreen("MindMachine", "Blinking light + binaural audio. Stereo headphones required for binaural mode. Not a medical device.") { SimpleScreen("MindMachine", "Blinking light + binaural audio. Stereo headphones required for binaural mode. Not a medical device.") {
Button(onClick = { nav.navigate("safety") }) { Text("Continue") } Button(onClick = { nav.navigate("safety") }) { Text("Continue") }
@@ -118,10 +141,16 @@ fun App(vm: MainViewModel = viewModel()) {
TextButton(onClick = { nav.navigate("holder") }) { Text("Holder Guidance") } TextButton(onClick = { nav.navigate("holder") }) { Text("Holder Guidance") }
} }
items(ui.presets) { p -> items(ui.presets) { p ->
Card(Modifier.fillMaxWidth().clickable { Card(
vm.choosePreset(p.id) colors = CardDefaults.cardColors(
nav.navigate("setup") containerColor = MaterialTheme.colorScheme.surface,
}.padding(4.dp)) { contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth().clickable {
vm.choosePreset(p.id)
nav.navigate("setup")
}.padding(4.dp)
) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(p.name, style = MaterialTheme.typography.titleMedium) Text(p.name, style = MaterialTheme.typography.titleMedium)
Text(p.description) Text(p.description)
@@ -165,11 +194,11 @@ fun App(vm: MainViewModel = viewModel()) {
@Composable @Composable
fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) { fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) {
Column( Column(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text(title, style = MaterialTheme.typography.headlineMedium) Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
Text(subtitle) Text(subtitle, color = MaterialTheme.colorScheme.onBackground)
actions() actions()
} }
} }
@@ -177,13 +206,16 @@ fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnSco
@Composable @Composable
fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) { fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
var checked by remember { mutableStateOf(false) } var checked by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp)) {
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall) Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text("Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.\n\nDo not use while driving, walking, cycling, or operating machinery.\n\nStop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.\n\nBinaural mode requires stereo headphones.\n\nThis app is not a medical device.") Text(
"Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.\n\nDo not use while driving, walking, cycling, or operating machinery.\n\nStop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.\n\nBinaural mode requires stereo headphones.\n\nThis app is not a medical device.",
color = MaterialTheme.colorScheme.onBackground
)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = { checked = it }) Checkbox(checked = checked, onCheckedChange = { checked = it })
Text("I understand the risks and will stop immediately if I feel discomfort.") Text("I understand the risks and will stop immediately if I feel discomfort.", color = MaterialTheme.colorScheme.onBackground)
} }
Button(onClick = onAck, enabled = checked, modifier = Modifier.semantics { contentDescription = "I Understand" }) { Text("I Understand") } Button(onClick = onAck, enabled = checked, modifier = Modifier.semantics { contentDescription = "I Understand" }) { Text("I Understand") }
TextButton(onClick = onHolder) { Text("Holder Guidance") } TextButton(onClick = onHolder) { Text("Holder Guidance") }
@@ -194,25 +226,28 @@ fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) { fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val cfg = ui.config val cfg = ui.config
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(
Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall) Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
Text("Duration: ${cfg.durationSec / 60} min") verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text("Duration: ${cfg.durationSec / 60} min", color = MaterialTheme.colorScheme.onBackground)
Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f) Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f)
Text("Mode") Text("Mode", color = MaterialTheme.colorScheme.onBackground)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionMode.values().forEach { mode -> SessionMode.values().forEach { mode ->
OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) } OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) }
} }
} }
Text("Blink ${cfg.blinkFrequencyHz} Hz") Text("Blink ${cfg.blinkFrequencyHz} Hz", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f) Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f)
Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz") Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f) Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f)
Text("Difference ${cfg.binauralDifferenceHz} Hz") Text("Difference ${cfg.binauralDifferenceHz} Hz", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.binauralDifferenceHz, onValueChange = vm::setDifference, valueRange = 0.5f..20f) Slider(value = cfg.binauralDifferenceHz, onValueChange = vm::setDifference, valueRange = 0.5f..20f)
Text("Brightness recommendation: keep screen comfortable and avoid eye strain.") Text("Brightness recommendation: keep screen comfortable and avoid eye strain.", color = MaterialTheme.colorScheme.onBackground)
TextButton(onClick = onHolder) { Text("Holder Guidance") } TextButton(onClick = onHolder) { Text("Holder Guidance") }
if (ui.error != null) Text(ui.error!!, color = Color.Red) if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error)
Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") } Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") }
} }
} }
@@ -223,12 +258,19 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish() if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish()
var showOverlay by remember { mutableStateOf(true) } var showOverlay by remember { mutableStateOf(true) }
val intensity = (ui.config.intensityPercent / 100f) val visualAlpha by rememberVisualAlpha(
val flashing = if (ui.config.visualPatternType.name == "FLASH") { isRunning = ui.runtimeState == RuntimeState.RUNNING,
if ((ui.remainingSec % 2) == 0) intensity else 0f mode = ui.config.mode,
} else intensity * 0.5f visualPattern = ui.config.visualPatternType,
blinkFrequencyHz = ui.config.blinkFrequencyHz,
intensityPercent = ui.config.intensityPercent
)
Box( Box(
modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = if (ui.config.mode == SessionMode.AUDIO_ONLY) 0f else flashing)) modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.background(Color.White.copy(alpha = visualAlpha))
.clickable { showOverlay = !showOverlay } .clickable { showOverlay = !showOverlay }
) { ) {
if (ui.runtimeState == RuntimeState.COUNTDOWN) { if (ui.runtimeState == RuntimeState.COUNTDOWN) {
@@ -236,11 +278,17 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
"${ui.countdownSec}", "${ui.countdownSec}",
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge,
color = Color.Black color = Color.White
) )
} }
if (showOverlay) { if (showOverlay) {
Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(Color.Black.copy(alpha = 0.6f)).padding(16.dp)) { Column(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.68f))
.padding(16.dp)
) {
Text("${ui.selectedPreset.name}${ui.remainingSec}s", color = Color.White) Text("${ui.selectedPreset.name}${ui.remainingSec}s", color = Color.White)
if (ui.runtimeState == RuntimeState.RUNNING) { if (ui.runtimeState == RuntimeState.RUNNING) {
Button(onClick = { vm.pause() }, modifier = Modifier.fillMaxWidth()) { Text("Pause") } Button(onClick = { vm.pause() }, modifier = Modifier.fillMaxWidth()) { Text("Pause") }
@@ -261,14 +309,43 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
} }
} }
@Composable
private fun rememberVisualAlpha(
isRunning: Boolean,
mode: SessionMode,
visualPattern: VisualPattern,
blinkFrequencyHz: Float,
intensityPercent: Int,
): androidx.compose.runtime.State<Float> {
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
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) { fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(
Text("Settings", style = MaterialTheme.typography.headlineSmall) Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
Text("Countdown") verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text("Countdown", color = MaterialTheme.colorScheme.onBackground)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
TextButton(onClick = { expanded = true }) { Text(ui.settings.countdownPreference.name) } TextButton(onClick = { expanded = true }) { Text(ui.settings.countdownPreference.name) }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@@ -277,7 +354,7 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
} }
} }
} }
Text("Default mode") Text("Default mode", color = MaterialTheme.colorScheme.onBackground)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionMode.values().forEach { mode -> SessionMode.values().forEach { mode ->
OutlinedButton(onClick = { vm.updateDefaultMode(mode) }) { Text(mode.name) } OutlinedButton(onClick = { vm.updateDefaultMode(mode) }) { Text(mode.name) }
@@ -285,24 +362,27 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance) Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance)
Text("Show holder guidance before session") Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground)
} }
HorizontalDivider() HorizontalDivider()
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.") Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", color = MaterialTheme.colorScheme.onBackground)
OutlinedButton(onClick = onBack) { Text("Back") } OutlinedButton(onClick = onBack) { Text("Back") }
} }
} }
@Composable @Composable
fun HolderScreen(onBack: () -> Unit) { fun HolderScreen(onBack: () -> Unit) {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(
Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall) Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
Text("• Use a simple cardboard visor/holder.") verticalArrangement = Arrangement.spacedBy(8.dp)
Text("• Keep phone stable and hands-free.") ) {
Text("• Do not press device against eyes/face.") Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text("Allow airflow and comfort.") Text("Use a simple cardboard visor/holder.", color = MaterialTheme.colorScheme.onBackground)
Text("Test fit before session.") Text("Keep phone stable and hands-free.", color = MaterialTheme.colorScheme.onBackground)
Text("Sit or lie down in a safe place.") Text("Do not press device against eyes/face.", color = MaterialTheme.colorScheme.onBackground)
Text("• Allow airflow and comfort.", color = MaterialTheme.colorScheme.onBackground)
Text("• Test fit before session.", color = MaterialTheme.colorScheme.onBackground)
Text("• Sit or lie down in a safe place.", color = MaterialTheme.colorScheme.onBackground)
OutlinedButton(onClick = onBack) { Text("Back") } OutlinedButton(onClick = onBack) { Text("Back") }
} }
} }

View File

@@ -0,0 +1,30 @@
package com.mindmachine.mvp.session
import com.mindmachine.mvp.domain.VisualPattern
import kotlin.math.PI
import kotlin.math.sin
fun computeVisualAlpha(
visualPattern: VisualPattern,
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)
}
}
}

View File

@@ -0,0 +1,67 @@
package com.mindmachine.mvp
import com.mindmachine.mvp.domain.VisualPattern
import com.mindmachine.mvp.session.computeVisualAlpha
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
val on = computeVisualAlpha(
visualPattern = VisualPattern.FLASH,
frameTimeNanos = 0L,
blinkFrequencyHz = hz,
intensityPercent = intensity
)
val off = computeVisualAlpha(
visualPattern = VisualPattern.FLASH,
frameTimeNanos = 75_000_000L,
blinkFrequencyHz = hz,
intensityPercent = intensity
)
assertEquals(0.7f, on, 0.0001f)
assertEquals(0f, off, 0.0001f)
}
@Test
fun pulsePatternStaysWithinZeroAndIntensity() {
val intensity = 60
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)
}
}