Update mode toggle, split flash visuals, and interval-ms controls

This commit is contained in:
Tretzi
2026-03-10 23:24:49 -05:00
parent fdf3bf01d3
commit bb5d77fea3
9 changed files with 137 additions and 125 deletions

View File

@@ -12,8 +12,7 @@
android:theme="@style/Theme.MindMachine"> android:theme="@style/Theme.MindMachine">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true">
android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -1,5 +1,7 @@
package com.mindmachine.mvp package com.mindmachine.mvp
import android.app.Activity
import android.content.pm.ActivityInfo
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -29,14 +32,16 @@ import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider 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.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -45,10 +50,10 @@ 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
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver 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.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.FlashColor
import com.mindmachine.mvp.session.MainViewModel 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 import kotlin.math.roundToInt
private val MindMachineColors = darkColorScheme( private val MindMachineColors = darkColorScheme(
@@ -105,7 +111,7 @@ fun App(vm: MainViewModel = viewModel()) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycle = LocalLifecycleOwner.current.lifecycle
androidx.compose.runtime.DisposableEffect(lifecycle) { DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && ui.runtimeState == RuntimeState.RUNNING) { if (event == Lifecycle.Event.ON_STOP && ui.runtimeState == RuntimeState.RUNNING) {
vm.pause("Session paused because app moved to background.") 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) 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", color = MaterialTheme.colorScheme.onBackground) Text("Mode", color = MaterialTheme.colorScheme.onBackground)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SessionMode.values().forEach { mode -> SessionMode.values().forEachIndexed { index, mode ->
OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) } 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) Text("Flash interval: ${cfg.flashIntervalMs} ms", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f) 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) 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", color = MaterialTheme.colorScheme.onBackground) 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() val ui by vm.ui.collectAsStateWithLifecycle()
if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish() 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) } var showOverlay by remember { mutableStateOf(true) }
val visualAlpha by rememberVisualAlpha( val flashFrame by rememberSplitFlashFrame(
isRunning = ui.runtimeState == RuntimeState.RUNNING, isRunning = ui.runtimeState == RuntimeState.RUNNING,
mode = ui.config.mode, mode = ui.config.mode,
visualPattern = ui.config.visualPatternType, flashIntervalMs = ui.config.flashIntervalMs,
blinkFrequencyHz = ui.config.blinkFrequencyHz,
intensityPercent = ui.config.intensityPercent
) )
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.background(Color.White.copy(alpha = visualAlpha))
.clickable { showOverlay = !showOverlay } .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) { if (ui.runtimeState == RuntimeState.COUNTDOWN) {
Text( Text(
"${ui.countdownSec}", "${ui.countdownSec}",
@@ -286,7 +331,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
Modifier Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = 0.68f)) .background(Color.Black.copy(alpha = 0.72f))
.padding(16.dp) .padding(16.dp)
) { ) {
Text("${ui.selectedPreset.name}${ui.remainingSec}s", color = Color.White) 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 @Composable
private fun rememberVisualAlpha( private fun rememberSplitFlashFrame(
isRunning: Boolean, isRunning: Boolean,
mode: SessionMode, mode: SessionMode,
visualPattern: VisualPattern, flashIntervalMs: Int,
blinkFrequencyHz: Float, ) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashIntervalMs) {
intensityPercent: Int,
): androidx.compose.runtime.State<Float> {
return produceState(initialValue = 0f, isRunning, mode, visualPattern, blinkFrequencyHz, intensityPercent) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) { if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
value = 0f value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState return@produceState
} }
while (true) { while (true) {
withFrameNanos { frameTimeNanos -> withFrameNanos { frameTimeNanos ->
value = computeVisualAlpha( value = computeSplitFlashFrame(frameTimeNanos, flashIntervalMs)
visualPattern = visualPattern,
frameTimeNanos = frameTimeNanos,
blinkFrequencyHz = blinkFrequencyHz,
intensityPercent = intensityPercent
)
}
} }
} }
} }

View File

@@ -11,7 +11,7 @@ data class SessionPreset(
val description: String, val description: String,
val defaultDurationSec: Int, val defaultDurationSec: Int,
val visualPatternType: VisualPattern, val visualPatternType: VisualPattern,
val blinkFrequencyHz: Float, val flashIntervalMs: Int,
val intensityPercent: Int, val intensityPercent: Int,
val carrierFrequencyHz: Float, val carrierFrequencyHz: Float,
val binauralDifferenceHz: Float, val binauralDifferenceHz: Float,
@@ -24,7 +24,7 @@ data class SessionConfig(
val durationSec: Int, val durationSec: Int,
val mode: SessionMode, val mode: SessionMode,
val visualPatternType: VisualPattern, val visualPatternType: VisualPattern,
val blinkFrequencyHz: Float, val flashIntervalMs: Int,
val intensityPercent: Int, val intensityPercent: Int,
val carrierFrequencyHz: Float, val carrierFrequencyHz: Float,
val binauralDifferenceHz: Float, val binauralDifferenceHz: Float,
@@ -43,17 +43,17 @@ object Presets {
val builtIn = listOf( val builtIn = listOf(
SessionPreset( SessionPreset(
"relax", "Relax", "Gentle pulse, slower beat", 10 * 60, "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 "Stop if discomfort occurs.", 1
), ),
SessionPreset( SessionPreset(
"focus", "Focus", "Steady flash, conservative alert beat", 15 * 60, "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 "Use in a safe seated place only.", 2
), ),
SessionPreset( SessionPreset(
"sleep", "Sleep Prep", "Slow pulse, low intensity", 20 * 60, "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 "Do not use while doing other activities.", 3
) )
) )
@@ -64,7 +64,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL)
durationSec = defaultDurationSec, durationSec = defaultDurationSec,
mode = defaultMode, mode = defaultMode,
visualPatternType = visualPatternType, visualPatternType = visualPatternType,
blinkFrequencyHz = blinkFrequencyHz, flashIntervalMs = flashIntervalMs,
intensityPercent = intensityPercent, intensityPercent = intensityPercent,
carrierFrequencyHz = carrierFrequencyHz, carrierFrequencyHz = carrierFrequencyHz,
binauralDifferenceHz = binauralDifferenceHz, binauralDifferenceHz = binauralDifferenceHz,

View File

@@ -67,7 +67,7 @@ class MainViewModel(
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) } 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 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 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) } fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }

View File

@@ -6,7 +6,7 @@ import com.mindmachine.mvp.domain.SessionMode
object SessionValidator { object SessionValidator {
fun validate(config: SessionConfig, headsetAvailable: Boolean): String? { fun validate(config: SessionConfig, headsetAvailable: Boolean): String? {
if (config.durationSec !in 60..(30 * 60)) return "Duration must be 1 to 30 minutes." 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.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.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be 0.5 to 20 Hz."
if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) { if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) {

View File

@@ -1,30 +1,26 @@
package com.mindmachine.mvp.session package com.mindmachine.mvp.session
import com.mindmachine.mvp.domain.VisualPattern enum class FlashColor {
import kotlin.math.PI RED,
import kotlin.math.sin GREEN,
BLACK,
}
fun computeVisualAlpha( data class SplitFlashFrame(
visualPattern: VisualPattern, val left: FlashColor,
val right: FlashColor,
)
fun computeSplitFlashFrame(
frameTimeNanos: Long, frameTimeNanos: Long,
blinkFrequencyHz: Float, flashIntervalMs: Int,
intensityPercent: Int, ): SplitFlashFrame {
): Float { val intervalMs = flashIntervalMs.coerceIn(50, 2000).toLong()
val intensity = (intensityPercent.coerceIn(0, 100) / 100f) val elapsedMs = frameTimeNanos / 1_000_000L
if (intensity <= 0f) return 0f return when (((elapsedMs / intervalMs) % 4).toInt()) {
0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN)
val frequency = blinkFrequencyHz.coerceIn(1f, 20f).toDouble() 1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
val timeSeconds = frameTimeNanos / 1_000_000_000.0 2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED)
else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
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

@@ -15,14 +15,14 @@ class PresetDefaultsTest {
assertEquals(10 * 60, relax.defaultDurationSec) assertEquals(10 * 60, relax.defaultDurationSec)
assertEquals(VisualPattern.PULSE, relax.visualPatternType) assertEquals(VisualPattern.PULSE, relax.visualPatternType)
assertEquals(6f, relax.blinkFrequencyHz) assertEquals(167, relax.flashIntervalMs)
assertEquals(15 * 60, focus.defaultDurationSec) assertEquals(15 * 60, focus.defaultDurationSec)
assertEquals(VisualPattern.FLASH, focus.visualPatternType) assertEquals(VisualPattern.FLASH, focus.visualPatternType)
assertEquals(10f, focus.blinkFrequencyHz) assertEquals(100, focus.flashIntervalMs)
assertEquals(20 * 60, sleep.defaultDurationSec) assertEquals(20 * 60, sleep.defaultDurationSec)
assertEquals(VisualPattern.PULSE, sleep.visualPatternType) assertEquals(VisualPattern.PULSE, sleep.visualPatternType)
assertEquals(3f, sleep.blinkFrequencyHz) assertEquals(333, sleep.flashIntervalMs)
} }
} }

View File

@@ -1,8 +1,8 @@
package com.mindmachine.mvp package com.mindmachine.mvp
import com.mindmachine.mvp.domain.SessionConfig
import com.mindmachine.mvp.domain.SessionMode import com.mindmachine.mvp.domain.SessionMode
import com.mindmachine.mvp.domain.VisualPattern import com.mindmachine.mvp.domain.VisualPattern
import com.mindmachine.mvp.domain.SessionConfig
import com.mindmachine.mvp.session.SessionValidator import com.mindmachine.mvp.session.SessionValidator
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -14,7 +14,7 @@ class SessionValidatorTest {
durationSec = 600, durationSec = 600,
mode = SessionMode.AUDIO_VISUAL, mode = SessionMode.AUDIO_VISUAL,
visualPatternType = VisualPattern.PULSE, visualPatternType = VisualPattern.PULSE,
blinkFrequencyHz = 6f, flashIntervalMs = 167,
intensityPercent = 50, intensityPercent = 50,
carrierFrequencyHz = 200f, carrierFrequencyHz = 200f,
binauralDifferenceHz = 6f, binauralDifferenceHz = 6f,
@@ -39,7 +39,7 @@ class SessionValidatorTest {
@Test @Test
fun out_of_range_values_are_blocked() { fun out_of_range_values_are_blocked() {
assertEquals("Duration must be 1 to 30 minutes.", SessionValidator.validate(valid.copy(durationSec = 59), true)) 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("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)) assertEquals("Binaural difference must be 0.5 to 20 Hz.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f), true))
} }

View File

@@ -1,67 +1,42 @@
package com.mindmachine.mvp package com.mindmachine.mvp
import com.mindmachine.mvp.domain.VisualPattern import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.computeVisualAlpha import com.mindmachine.mvp.session.computeSplitFlashFrame
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class VisualSignalTest { class VisualSignalTest {
@Test @Test
fun flashPatternAlternatesBetweenIntensityAndOff() { fun splitFlashPattern_cycles_redGreen_black_greenRed_black() {
val intensity = 70 val intervalMs = 100
val hz = 10f
val on = computeVisualAlpha( val phase0 = computeSplitFlashFrame(frameTimeNanos = 0L, flashIntervalMs = intervalMs)
visualPattern = VisualPattern.FLASH, val phase1 = computeSplitFlashFrame(frameTimeNanos = 100_000_000L, flashIntervalMs = intervalMs)
frameTimeNanos = 0L, val phase2 = computeSplitFlashFrame(frameTimeNanos = 200_000_000L, flashIntervalMs = intervalMs)
blinkFrequencyHz = hz, val phase3 = computeSplitFlashFrame(frameTimeNanos = 300_000_000L, flashIntervalMs = intervalMs)
intensityPercent = intensity
)
val off = computeVisualAlpha( assertEquals(FlashColor.RED, phase0.left)
visualPattern = VisualPattern.FLASH, assertEquals(FlashColor.GREEN, phase0.right)
frameTimeNanos = 75_000_000L,
blinkFrequencyHz = hz,
intensityPercent = intensity
)
assertEquals(0.7f, on, 0.0001f) assertEquals(FlashColor.BLACK, phase1.left)
assertEquals(0f, off, 0.0001f) 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 @Test
fun pulsePatternStaysWithinZeroAndIntensity() { fun interval_is_clamped_to_valid_range() {
val intensity = 60 val tooFast = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 1)
val validAt50ms = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 50)
assertEquals(validAt50ms, tooFast)
val atStart = computeVisualAlpha( val tooSlow = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 5000)
visualPattern = VisualPattern.PULSE, val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 2000)
frameTimeNanos = 0L, assertEquals(validAt2000ms, tooSlow)
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)
} }
} }