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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -32,11 +33,15 @@ 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.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -49,21 +54,35 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.mindmachine.mvp.audio.BinauralAudioEngine
import com.mindmachine.mvp.audio.HeadsetMonitor
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.MainViewModel
import com.mindmachine.mvp.session.computeVisualAlpha
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() {
private val vm: MainViewModel by viewModels {
MainViewModel.Factory(SettingsRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine())
@@ -71,7 +90,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
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"
NavHost(navController = nav, startDestination = startRoute) {
NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
composable("welcome") {
SimpleScreen("MindMachine", "Blinking light + binaural audio. Stereo headphones required for binaural mode. Not a medical device.") {
Button(onClick = { nav.navigate("safety") }) { Text("Continue") }
@@ -118,10 +141,16 @@ fun App(vm: MainViewModel = viewModel()) {
TextButton(onClick = { nav.navigate("holder") }) { Text("Holder Guidance") }
}
items(ui.presets) { p ->
Card(Modifier.fillMaxWidth().clickable {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth().clickable {
vm.choosePreset(p.id)
nav.navigate("setup")
}.padding(4.dp)) {
}.padding(4.dp)
) {
Column(Modifier.padding(12.dp)) {
Text(p.name, style = MaterialTheme.typography.titleMedium)
Text(p.description)
@@ -165,11 +194,11 @@ fun App(vm: MainViewModel = viewModel()) {
@Composable
fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Text(subtitle)
Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
Text(subtitle, color = MaterialTheme.colorScheme.onBackground)
actions()
}
}
@@ -177,13 +206,16 @@ fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnSco
@Composable
fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
var checked by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall)
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp)) {
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
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) {
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") }
TextButton(onClick = onHolder) { Text("Holder Guidance") }
@@ -194,25 +226,28 @@ fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
val cfg = ui.config
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall)
Text("Duration: ${cfg.durationSec / 60} min")
Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
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)
Text("Mode")
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("_", " ")) }
}
}
Text("Blink ${cfg.blinkFrequencyHz} Hz")
Text("Blink ${cfg.blinkFrequencyHz} Hz", color = MaterialTheme.colorScheme.onBackground)
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)
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)
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") }
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") }
}
}
@@ -223,12 +258,19 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish()
var showOverlay by remember { mutableStateOf(true) }
val intensity = (ui.config.intensityPercent / 100f)
val flashing = if (ui.config.visualPatternType.name == "FLASH") {
if ((ui.remainingSec % 2) == 0) intensity else 0f
} else intensity * 0.5f
val visualAlpha by rememberVisualAlpha(
isRunning = ui.runtimeState == RuntimeState.RUNNING,
mode = ui.config.mode,
visualPattern = ui.config.visualPatternType,
blinkFrequencyHz = ui.config.blinkFrequencyHz,
intensityPercent = ui.config.intensityPercent
)
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 }
) {
if (ui.runtimeState == RuntimeState.COUNTDOWN) {
@@ -236,11 +278,17 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
"${ui.countdownSec}",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.displayLarge,
color = Color.Black
color = Color.White
)
}
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)
if (ui.runtimeState == RuntimeState.RUNNING) {
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)
@Composable
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
var expanded by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Settings", style = MaterialTheme.typography.headlineSmall)
Text("Countdown")
Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
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 }) {
TextButton(onClick = { expanded = true }) { Text(ui.settings.countdownPreference.name) }
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)) {
SessionMode.values().forEach { mode ->
OutlinedButton(onClick = { vm.updateDefaultMode(mode) }) { Text(mode.name) }
@@ -285,24 +362,27 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
}
Row(verticalAlignment = Alignment.CenterVertically) {
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()
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") }
}
}
@Composable
fun HolderScreen(onBack: () -> Unit) {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall)
Text("• Use a simple cardboard visor/holder.")
Text("• Keep phone stable and hands-free.")
Text("• Do not press device against eyes/face.")
Text("Allow airflow and comfort.")
Text("Test fit before session.")
Text("Sit or lie down in a safe place.")
Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Holder Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text("Use a simple cardboard visor/holder.", color = MaterialTheme.colorScheme.onBackground)
Text("Keep phone stable and hands-free.", color = MaterialTheme.colorScheme.onBackground)
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") }
}
}

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)
}
}