From 869c98a47c27cd7f314cbbb881350f048861d460 Mon Sep 17 00:00:00 2001 From: Tretzi Date: Sat, 21 Mar 2026 23:01:02 -0500 Subject: [PATCH] Auto commit Sat Mar 21 11:01:02 PM CDT 2026 --- .../java/com/mindmachine/mvp/MainActivity.kt | 61 +++--- .../mvp/data/SettingsRepository.kt | 21 ++- .../java/com/mindmachine/mvp/domain/Models.kt | 43 ++++- .../mvp/session/ActiveSessionUi.kt | 6 + .../mindmachine/mvp/session/ImmersiveMode.kt | 2 +- .../mindmachine/mvp/session/MainViewModel.kt | 49 +++-- .../mvp/session/TimelineEditorScreen.kt | 26 ++- .../mvp/session/TimelineProgramFactory.kt | 176 +++++++++++++++--- .../mvp/session/TimelineRuntimeEvaluator.kt | 4 +- .../mindmachine/mvp/ActiveSessionUiTest.kt | 17 +- .../com/mindmachine/mvp/PresetDefaultsTest.kt | 53 ++++-- 11 files changed, 354 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 8bf8ca0..06560b4 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -32,12 +32,10 @@ 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 import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -79,7 +77,6 @@ import com.mindmachine.mvp.audio.BinauralAudioEngine import com.mindmachine.mvp.audio.HeadsetMonitor import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.data.UserProgramRepository -import com.mindmachine.mvp.domain.CountdownPreference import com.mindmachine.mvp.domain.RuntimeState import com.mindmachine.mvp.domain.SessionMode import com.mindmachine.mvp.session.FlashColor @@ -88,7 +85,7 @@ import com.mindmachine.mvp.session.SplitFlashFrame import com.mindmachine.mvp.session.computeSplitFlashFrame import com.mindmachine.mvp.session.flashIntervalMsToSeconds import com.mindmachine.mvp.session.flashIntervalSecondsToMs -import com.mindmachine.mvp.session.shouldRevealSystemBarsOnTap +import com.mindmachine.mvp.session.sessionProgressFraction import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen import kotlinx.coroutines.delay @@ -196,9 +193,7 @@ fun App(vm: MainViewModel = viewModel()) { Text(p.description) Text("${p.defaultDurationSec / 60} min • ${p.visualPatternType} • binaural ${p.binauralDifferenceHz}") } - if (p.isUserProgram) { - TextButton(onClick = { vm.deleteProgram(p.id) }) { Text("Delete") } - } + TextButton(onClick = { vm.deleteProgram(p.id) }) { Text("Delete") } } } } @@ -380,14 +375,18 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup: flashOffMs = ui.runtimeParams.flashOffMs, ) + val progress = sessionProgressFraction( + durationSec = ui.timeline.durationSec, + remainingSec = ui.remainingSec, + ) + Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .clickable { - showOverlay = !showOverlay - if (shouldRevealSystemBarsOnTap(isVisualMode, ui.runtimeState)) { - revealSystemBarsSignal += 1 + if (!immersiveFullscreen) { + showOverlay = !showOverlay } } ) { @@ -416,6 +415,19 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup: color = Color.White ) } + + if (immersiveFullscreen && ui.settings.showImmersiveProgressBar) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(5.dp), + trackColor = Color.White.copy(alpha = 0.2f), + color = Color.White, + ) + } + if (showOverlay) { Column( Modifier @@ -503,35 +515,40 @@ private fun rememberSplitFlashFrame( } } -@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().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 }) { - CountdownPreference.values().forEach { - DropdownMenuItem(text = { Text(it.name) }, onClick = { vm.updateCountdown(it); expanded = false }) - } - } - } + + Text("Countdown: ${ui.settings.countdownSeconds}s", color = MaterialTheme.colorScheme.onBackground) + Slider( + value = ui.settings.countdownSeconds.toFloat(), + onValueChange = { vm.updateCountdownSeconds(it.roundToInt()) }, + valueRange = 0f..10f, + steps = 9, + ) + 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) } } } + Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance) Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground) } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = ui.settings.showImmersiveProgressBar, onCheckedChange = vm::updateShowImmersiveProgressBar) + Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground) + } + HorizontalDivider() Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", color = MaterialTheme.colorScheme.onBackground) OutlinedButton(onClick = onBack) { Text("Back") } diff --git a/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt b/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt index bdd1f64..1155512 100644 --- a/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt +++ b/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt @@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.mindmachine.mvp.domain.AppSettings -import com.mindmachine.mvp.domain.CountdownPreference import com.mindmachine.mvp.domain.SessionMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -19,8 +18,10 @@ class SettingsRepository(private val context: Context) { val safetyAcknowledged = booleanPreferencesKey("safety_ack") val safetyVersion = intPreferencesKey("safety_version") val countdownPref = stringPreferencesKey("countdown_pref") + val countdownSeconds = intPreferencesKey("countdown_seconds") val defaultMode = stringPreferencesKey("default_mode") val showGuidance = booleanPreferencesKey("show_guidance") + val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar") val lastPresetId = stringPreferencesKey("last_preset") } @@ -28,9 +29,12 @@ class SettingsRepository(private val context: Context) { AppSettings( safetyAcknowledged = p[Keys.safetyAcknowledged] ?: false, safetyAcknowledgedVersion = p[Keys.safetyVersion] ?: 0, - countdownPreference = runCatching { CountdownPreference.valueOf(p[Keys.countdownPref] ?: "FIVE") }.getOrDefault(CountdownPreference.FIVE), + countdownSeconds = p[Keys.countdownSeconds] + ?: legacyCountdownStringToSeconds(p[Keys.countdownPref]) + ?: 5, defaultModePreference = runCatching { SessionMode.valueOf(p[Keys.defaultMode] ?: "AUDIO_VISUAL") }.getOrDefault(SessionMode.AUDIO_VISUAL), showHolderGuidanceBeforeSession = p[Keys.showGuidance] ?: false, + showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true, lastPresetId = p[Keys.lastPresetId], ) } @@ -42,8 +46,19 @@ class SettingsRepository(private val context: Context) { } } - suspend fun updateCountdown(value: CountdownPreference) = context.dataStore.edit { it[Keys.countdownPref] = value.name } + suspend fun updateCountdownSeconds(value: Int) = context.dataStore.edit { + it[Keys.countdownSeconds] = value.coerceIn(0, 10) + } + suspend fun updateDefaultMode(value: SessionMode) = context.dataStore.edit { it[Keys.defaultMode] = value.name } suspend fun updateGuidance(value: Boolean) = context.dataStore.edit { it[Keys.showGuidance] = value } + suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit { it[Keys.showImmersiveProgressBar] = value } suspend fun updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id } + + private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) { + "OFF" -> 0 + "FIVE" -> 5 + "TEN" -> 10 + else -> null + } } 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 5ea9610..9babd52 100644 --- a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt +++ b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt @@ -3,7 +3,6 @@ package com.mindmachine.mvp.domain enum class VisualPattern { FLASH, PULSE } enum class SessionMode { AUDIO_VISUAL, AUDIO_ONLY, VISUAL_ONLY } enum class RuntimeState { IDLE, COUNTDOWN, RUNNING, PAUSED, INTERRUPTED, COMPLETED, STOPPED, ERROR } -enum class CountdownPreference(val seconds: Int) { OFF(0), FIVE(5), TEN(10) } data class SessionPreset( val id: String, @@ -34,28 +33,54 @@ data class SessionConfig( data class AppSettings( val safetyAcknowledged: Boolean = false, val safetyAcknowledgedVersion: Int = 0, - val countdownPreference: CountdownPreference = CountdownPreference.FIVE, + val countdownSeconds: Int = 5, val defaultModePreference: SessionMode = SessionMode.AUDIO_VISUAL, val showHolderGuidanceBeforeSession: Boolean = false, + val showImmersiveProgressBar: Boolean = true, val lastPresetId: String? = null, ) object Presets { val builtIn = listOf( SessionPreset( - "relax", "Relax", "Gentle pulse, slower beat", 10 * 60, - VisualPattern.PULSE, 167, 60, 200f, 6f, + "deep_release", "Deep Release", "Slow descent into calm with a soft ending", 18 * 60, + VisualPattern.PULSE, 260, 52, 180f, 4.5f, "Stop if discomfort occurs.", 1 ), SessionPreset( - "focus", "Focus", "Steady flash, conservative alert beat", 15 * 60, - VisualPattern.FLASH, 100, 65, 220f, 10f, + "laser_focus", "Laser Focus", "Progressive ramp to sharp attention and steady drive", 16 * 60, + VisualPattern.FLASH, 120, 66, 220f, 12f, "Use in a safe seated place only.", 2 ), SessionPreset( - "sleep", "Sleep Prep", "Slow pulse, low intensity", 20 * 60, - VisualPattern.PULSE, 333, 45, 180f, 3f, - "Do not use while doing other activities.", 3 + "creative_drift", "Creative Drift", "Alternating intensity waves for idea exploration", 22 * 60, + VisualPattern.PULSE, 190, 58, 205f, 8f, + "Use only in a safe resting environment.", 3 + ), + SessionPreset( + "reset_breath", "Reset Breath", "Gentle rhythmic arcs designed for breathing cadence", 12 * 60, + VisualPattern.PULSE, 300, 46, 170f, 5.5f, + "Do not use while driving or operating machinery.", 4 + ), + SessionPreset( + "night_rampdown", "Night Rampdown", "Long taper from busy mind to pre-sleep stillness", 28 * 60, + VisualPattern.PULSE, 360, 40, 160f, 2.8f, + "Do not use while doing other activities.", 5 + ), + SessionPreset( + "power_warmup", "Power Warmup", "Short energizing ladder for pre-work momentum", 9 * 60, + VisualPattern.FLASH, 95, 68, 245f, 14f, + "Use in a safe seated place only.", 6 + ), + SessionPreset( + "body_scan", "Body Scan", "Balanced mid-tempo sweep with a centered finish", 20 * 60, + VisualPattern.PULSE, 210, 50, 190f, 6.8f, + "Stop immediately for dizziness or discomfort.", 7 + ), + SessionPreset( + "storm_to_still", "Storm to Still", "Starts active, then settles into deep quiet", 24 * 60, + VisualPattern.FLASH, 150, 61, 215f, 9f, + "Use only in a safe resting environment.", 8 ) ) } diff --git a/app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt b/app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt index aef381b..a39e9a1 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/ActiveSessionUi.kt @@ -5,3 +5,9 @@ import com.mindmachine.mvp.domain.RuntimeState fun shouldShowActiveControlsByDefault(runtimeState: RuntimeState): Boolean { return runtimeState != RuntimeState.RUNNING && runtimeState != RuntimeState.COUNTDOWN } + +fun sessionProgressFraction(durationSec: Int, remainingSec: Int): Float { + val total = durationSec.coerceAtLeast(1) + val elapsed = (total - remainingSec).coerceIn(0, total) + return elapsed.toFloat() / total.toFloat() +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/ImmersiveMode.kt b/app/src/main/java/com/mindmachine/mvp/session/ImmersiveMode.kt index 1f00469..855cff1 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/ImmersiveMode.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/ImmersiveMode.kt @@ -7,5 +7,5 @@ fun shouldUseImmersiveFullscreen(isVisualMode: Boolean, runtimeState: RuntimeSta } fun shouldRevealSystemBarsOnTap(isVisualMode: Boolean, runtimeState: RuntimeState): Boolean { - return shouldUseImmersiveFullscreen(isVisualMode, runtimeState) + return false } 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 7d03cff..206142f 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -9,7 +9,6 @@ import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.data.UserProgramEntity import com.mindmachine.mvp.data.UserProgramRepository import com.mindmachine.mvp.domain.AppSettings -import com.mindmachine.mvp.domain.CountdownPreference import com.mindmachine.mvp.domain.Presets import com.mindmachine.mvp.domain.RuntimeState import com.mindmachine.mvp.domain.SessionConfig @@ -59,15 +58,27 @@ class MainViewModel( private var runJob: Job? = null private var elapsedBeforePauseSec: Float = 0f - private var cachedUserPrograms: List = emptyList() + private var cachedPrograms: List = emptyList() init { viewModelScope.launch { combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms -> settings to userPrograms }.collect { (settings, userPrograms) -> - cachedUserPrograms = userPrograms - val userPresets = userPrograms.map { + val builtInPrograms = Presets.builtIn.map { preset -> + userPrograms.firstOrNull { it.id == preset.id } ?: UserProgramEntity( + id = preset.id, + name = preset.name, + description = preset.description, + timeline = TimelineProgramFactory.fromPreset(preset), + sortOrder = preset.sortOrder, + ) + } + val mergedPrograms = (builtInPrograms + userPrograms.filterNot { program -> Presets.builtIn.any { it.id == program.id } }) + .sortedBy { it.sortOrder } + cachedPrograms = mergedPrograms + + val allPresets = mergedPrograms.map { TimelineProgramFactory.toPreset( id = it.id, name = it.name, @@ -77,7 +88,6 @@ class MainViewModel( isUserProgram = true, ) } - val allPresets = (Presets.builtIn + userPresets).sortedBy { it.sortOrder } _ui.update { current -> val selectedId = settings.lastPresetId ?: current.selectedPreset.id @@ -88,7 +98,7 @@ class MainViewModel( presets = allPresets, selectedPreset = selectedPreset, config = current.config.copy(mode = settings.defaultModePreference), - timeline = if (sameSelection) current.timeline else timelineForPreset(selectedPreset, userPrograms), + timeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms), ) } } @@ -106,7 +116,7 @@ class MainViewModel( it.copy( selectedPreset = preset, config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)), - timeline = timelineForPreset(preset, cachedUserPrograms), + timeline = timelineForProgramId(preset.id, cachedPrograms), error = null, ) } @@ -143,8 +153,8 @@ class MainViewModel( } val state = _ui.value - val id = if (state.selectedPreset.isUserProgram) state.selectedPreset.id else "user-${UUID.randomUUID()}" - val sortOrder = cachedUserPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder() + val id = state.selectedPreset.id + val sortOrder = cachedPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder() userProgramRepository.upsert( UserProgramEntity( @@ -176,13 +186,13 @@ class MainViewModel( fun deleteProgram(id: String) = viewModelScope.launch { userProgramRepository.delete(id) if (_ui.value.selectedPreset.id == id) { - val fallback = Presets.builtIn.first() + val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first() settingsRepository.updateLastPreset(fallback.id) _ui.update { it.copy( selectedPreset = fallback, config = fallback.toConfig(it.settings.defaultModePreference), - timeline = TimelineProgramFactory.fromPreset(fallback), + timeline = timelineForProgramId(fallback.id, cachedPrograms), error = null, ) } @@ -212,9 +222,10 @@ class MainViewModel( 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 updateCountdown(pref: CountdownPreference) = viewModelScope.launch { settingsRepository.updateCountdown(pref) } + fun updateCountdownSeconds(seconds: Int) = viewModelScope.launch { settingsRepository.updateCountdownSeconds(seconds) } fun updateDefaultMode(mode: SessionMode) = viewModelScope.launch { settingsRepository.updateDefaultMode(mode) } fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) } + fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) } fun startSession() { val state = _ui.value @@ -281,7 +292,7 @@ class MainViewModel( val clampedStartSec = startElapsedSec.coerceIn(0f, durationSec.toFloat()) if (includeCountdown) { - val count = _ui.value.settings.countdownPreference.seconds + val count = _ui.value.settings.countdownSeconds.coerceIn(0, 10) if (count > 0) { for (i in count downTo 1) { _ui.update { @@ -358,14 +369,18 @@ class MainViewModel( return (total - state.remainingSec).coerceIn(0f, total) } - private fun timelineForPreset(preset: SessionPreset, userPrograms: List): TimelineEditorState { - return userPrograms.firstOrNull { it.id == preset.id }?.timeline ?: TimelineProgramFactory.fromPreset(preset) + private fun timelineForProgramId(programId: String, programs: List): TimelineEditorState { + val fromSaved = programs.firstOrNull { it.id == programId }?.timeline + if (fromSaved != null) return fromSaved + + val builtInPreset = Presets.builtIn.firstOrNull { it.id == programId } + return if (builtInPreset != null) TimelineProgramFactory.fromPreset(builtInPreset) else TimelineEditorState.default() } - private fun nextSortOrder(): Int = (cachedUserPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1 + private fun nextSortOrder(): Int = (cachedPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1 private fun nextUntitledName(): String { - val names = cachedUserPrograms.map { it.name.lowercase() }.toSet() + val names = cachedPrograms.map { it.name.lowercase() }.toSet() var i = 1 while (true) { val candidate = "Custom $i" diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt index 7f942cd..d83944b 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -409,7 +409,12 @@ internal fun TimelineGraph( val curveId = draggingCurveId if (pointIndex != null && curveId != null) { val targetCurve = if (curveId == leftId) curveLeft else curveRight - val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, primary.position.y, h) + val constrainedY = constrainYForCurve( + y = primary.position.y, + height = h, + curveId = curveId, + ) + val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, constrainedY, h) if (curveId == leftId) curveLeft = updated else curveRight = updated currentOnUpdateCurve(curveId, updated) onCurveEditedHaptic() @@ -576,3 +581,22 @@ private fun valueToY(value01: Float, height: Float): Float { val v = value01.coerceIn(0f, 1f) return (1f - v) * height } + +private fun constrainYForCurve(y: Float, height: Float, curveId: ActiveCurve): Float { + val clampedY = y.coerceIn(0f, height) + val value01 = (1f - (clampedY / height)).coerceIn(0f, 1f) + + return when (curveId) { + ActiveCurve.AUDIO_LEFT -> { + val hz = ParameterMapping.logMap01(value01, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX) + val bounded01 = ParameterMapping.invLog01(hz, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX) + valueToY(bounded01, height) + } + ActiveCurve.AUDIO_RIGHT -> { + val hz = ParameterMapping.logMap01(value01, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX) + val bounded01 = ParameterMapping.invLog01(hz, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX) + valueToY(bounded01, height) + } + else -> clampedY + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt index d7cfa0c..55c89fc 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt @@ -5,39 +5,11 @@ import com.mindmachine.mvp.domain.VisualPattern object TimelineProgramFactory { fun fromPreset(preset: SessionPreset): TimelineEditorState { - val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60) - - val flash01 = ParameterMapping.invLog01( - value = preset.flashIntervalMs.toFloat(), - min = ParameterRanges.FLASH_INTERVAL_MS_MIN, - max = ParameterRanges.FLASH_INTERVAL_MS_MAX, - ) - val blank01 = flash01 - val carrier01 = ParameterMapping.invLog01( - value = preset.carrierFrequencyHz, - min = ParameterRanges.CARRIER_HZ_MIN, - max = ParameterRanges.CARRIER_HZ_MAX, - ) - val binaural01 = ParameterMapping.invLog01( - value = preset.binauralDifferenceHz, - min = ParameterRanges.BINAURAL_HZ_MIN, - max = ParameterRanges.BINAURAL_HZ_MAX, - ) - - return TimelineEditorState( - durationSec = dur, - selection = TimelineSelection(0, dur), - viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())), - activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, - visualLeft = TimelineCurve.constant(flash01, dur), - visualRight = TimelineCurve.constant(blank01, dur), - audioLeft = TimelineCurve.constant(carrier01, dur), - audioRight = TimelineCurve.constant(binaural01, dur), - isPlaying = false, - playheadSec = 0f, - ) + return builtInTimelineForPresetId(preset.id) ?: constantTimelineFromPreset(preset) } + fun builtInTimelineForPresetId(id: String): TimelineEditorState? = builtInPrograms[id] + fun toPreset( id: String, name: String, @@ -78,4 +50,146 @@ object TimelineProgramFactory { isUserProgram = isUserProgram, ) } + + private fun constantTimelineFromPreset(preset: SessionPreset): TimelineEditorState { + val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60) + + val flash01 = ParameterMapping.invLog01( + value = preset.flashIntervalMs.toFloat(), + min = ParameterRanges.FLASH_INTERVAL_MS_MIN, + max = ParameterRanges.FLASH_INTERVAL_MS_MAX, + ) + val blank01 = flash01 + val carrier01 = ParameterMapping.invLog01( + value = preset.carrierFrequencyHz, + min = ParameterRanges.CARRIER_HZ_MIN, + max = ParameterRanges.CARRIER_HZ_MAX, + ) + val binaural01 = ParameterMapping.invLog01( + value = preset.binauralDifferenceHz, + min = ParameterRanges.BINAURAL_HZ_MIN, + max = ParameterRanges.BINAURAL_HZ_MAX, + ) + + return TimelineEditorState( + durationSec = dur, + selection = TimelineSelection(0, dur), + viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())), + activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, + visualLeft = TimelineCurve.constant(flash01, dur), + visualRight = TimelineCurve.constant(blank01, dur), + audioLeft = TimelineCurve.constant(carrier01, dur), + audioRight = TimelineCurve.constant(binaural01, dur), + isPlaying = false, + playheadSec = 0f, + ) + } + + private fun timeline( + durationSec: Int, + visualLeftMs: List, + visualRightMs: List, + audioLeftHz: List, + audioRightHz: List, + ): TimelineEditorState { + val minutes = durationSec / 60 + require(visualLeftMs.size == minutes + 1) + require(visualRightMs.size == minutes + 1) + require(audioLeftHz.size == minutes + 1) + require(audioRightHz.size == minutes + 1) + + fun curveFromMs(values: List): TimelineCurve = TimelineCurve( + values.mapIndexed { index, value -> + TimelinePoint( + tSec = index * 60, + value01 = ParameterMapping.invLog01( + value = value.toFloat(), + min = ParameterRanges.FLASH_INTERVAL_MS_MIN, + max = ParameterRanges.FLASH_INTERVAL_MS_MAX, + ) + ) + } + ) + + fun curveFromHz(values: List, min: Float, max: Float): TimelineCurve = TimelineCurve( + values.mapIndexed { index, value -> + TimelinePoint( + tSec = index * 60, + value01 = ParameterMapping.invLog01(value, min, max) + ) + } + ) + + return TimelineEditorState( + durationSec = durationSec, + selection = TimelineSelection(0, durationSec), + viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())), + activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, + visualLeft = curveFromMs(visualLeftMs), + visualRight = curveFromMs(visualRightMs), + audioLeft = curveFromHz(audioLeftHz, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX), + audioRight = curveFromHz(audioRightHz, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX), + isPlaying = false, + playheadSec = 0f, + ) + } + + private val builtInPrograms: Map = mapOf( + "deep_release" to timeline( + durationSec = 18 * 60, + visualLeftMs = listOf(320, 300, 280, 260, 250, 240, 230, 220, 210, 200, 210, 220, 240, 260, 280, 300, 320, 340, 360), + visualRightMs = listOf(360, 340, 320, 300, 280, 270, 260, 250, 240, 230, 240, 250, 270, 290, 310, 330, 350, 370, 390), + audioLeftHz = listOf(190f, 188f, 186f, 184f, 182f, 180f, 178f, 176f, 174f, 172f, 170f, 168f, 166f, 164f, 162f, 160f, 158f, 156f, 154f), + audioRightHz = listOf(7f, 6.6f, 6.2f, 5.8f, 5.4f, 5f, 4.8f, 4.6f, 4.4f, 4.2f, 4f, 3.8f, 3.6f, 3.4f, 3.2f, 3f, 2.8f, 2.6f, 2.4f), + ), + "laser_focus" to timeline( + durationSec = 16 * 60, + visualLeftMs = listOf(180, 170, 160, 150, 140, 130, 120, 110, 100, 95, 95, 95, 100, 105, 110, 115, 120), + visualRightMs = listOf(200, 190, 180, 170, 160, 150, 140, 130, 120, 115, 115, 115, 120, 125, 130, 135, 140), + audioLeftHz = listOf(205f, 208f, 212f, 216f, 220f, 224f, 228f, 232f, 236f, 240f, 242f, 244f, 246f, 248f, 250f, 248f, 245f), + audioRightHz = listOf(8f, 8.5f, 9f, 9.5f, 10f, 10.5f, 11f, 11.5f, 12f, 12f, 11.5f, 11f, 10.5f, 10f, 9.5f, 9f, 8.5f), + ), + "creative_drift" to timeline( + durationSec = 22 * 60, + visualLeftMs = listOf(220, 200, 180, 160, 180, 210, 240, 270, 250, 220, 190, 170, 190, 220, 250, 280, 260, 230, 200, 180, 200, 230, 260), + visualRightMs = listOf(260, 240, 220, 200, 220, 250, 280, 310, 290, 260, 230, 210, 230, 260, 290, 320, 300, 270, 240, 220, 240, 270, 300), + audioLeftHz = listOf(200f, 204f, 208f, 212f, 216f, 220f, 224f, 228f, 224f, 220f, 216f, 212f, 208f, 204f, 200f, 196f, 192f, 196f, 200f, 204f, 208f, 212f, 216f), + audioRightHz = listOf(6f, 6.8f, 7.6f, 8.4f, 9.2f, 10f, 9.4f, 8.8f, 8.2f, 7.6f, 7f, 6.4f, 5.8f, 5.2f, 4.6f, 4f, 4.8f, 5.6f, 6.4f, 7.2f, 8f, 8.8f, 9.6f), + ), + "reset_breath" to timeline( + durationSec = 12 * 60, + visualLeftMs = listOf(360, 340, 320, 300, 280, 260, 240, 260, 280, 300, 320, 340, 360), + visualRightMs = listOf(390, 370, 350, 330, 310, 290, 270, 290, 310, 330, 350, 370, 390), + audioLeftHz = listOf(175f, 173f, 171f, 169f, 167f, 165f, 163f, 165f, 167f, 169f, 171f, 173f, 175f), + audioRightHz = listOf(6.5f, 6.2f, 5.9f, 5.6f, 5.3f, 5f, 4.7f, 5f, 5.3f, 5.6f, 5.9f, 6.2f, 6.5f), + ), + "night_rampdown" to timeline( + durationSec = 28 * 60, + visualLeftMs = listOf(300, 290, 280, 270, 260, 250, 240, 230, 220, 210, 205, 200, 205, 210, 220, 230, 240, 250, 260, 270, 280, 290, 300, 320, 340, 360, 380, 400, 420), + visualRightMs = listOf(330, 320, 310, 300, 290, 280, 270, 260, 250, 240, 235, 230, 235, 240, 250, 260, 270, 280, 290, 300, 310, 320, 330, 350, 370, 390, 410, 430, 450), + audioLeftHz = listOf(182f, 181f, 180f, 179f, 178f, 177f, 176f, 175f, 174f, 173f, 172f, 171f, 170f, 169f, 168f, 167f, 166f, 165f, 164f, 163f, 162f, 161f, 160f, 159f, 158f, 157f, 156f, 155f, 154f), + audioRightHz = listOf(5f, 4.9f, 4.8f, 4.7f, 4.6f, 4.5f, 4.4f, 4.3f, 4.2f, 4.1f, 4f, 3.9f, 3.8f, 3.7f, 3.6f, 3.5f, 3.4f, 3.3f, 3.2f, 3.1f, 3f, 2.9f, 2.8f, 2.7f, 2.6f, 2.5f, 2.4f, 2.3f, 2.2f), + ), + "power_warmup" to timeline( + durationSec = 9 * 60, + visualLeftMs = listOf(140, 130, 120, 110, 100, 90, 85, 90, 100, 115), + visualRightMs = listOf(160, 150, 140, 130, 120, 110, 105, 110, 120, 135), + audioLeftHz = listOf(220f, 225f, 230f, 235f, 240f, 245f, 250f, 248f, 244f, 238f), + audioRightHz = listOf(10f, 11f, 12f, 13f, 14f, 15f, 14f, 13f, 12f, 11f), + ), + "body_scan" to timeline( + durationSec = 20 * 60, + visualLeftMs = listOf(260, 250, 240, 230, 220, 210, 200, 190, 200, 210, 220, 230, 240, 250, 260, 250, 240, 230, 220, 210, 200), + visualRightMs = listOf(290, 280, 270, 260, 250, 240, 230, 220, 230, 240, 250, 260, 270, 280, 290, 280, 270, 260, 250, 240, 230), + audioLeftHz = listOf(195f, 194f, 193f, 192f, 191f, 190f, 189f, 188f, 187f, 186f, 185f, 186f, 187f, 188f, 189f, 190f, 191f, 192f, 193f, 194f, 195f), + audioRightHz = listOf(7.2f, 7f, 6.8f, 6.6f, 6.4f, 6.2f, 6f, 5.8f, 5.6f, 5.4f, 5.2f, 5.4f, 5.6f, 5.8f, 6f, 6.2f, 6.4f, 6.6f, 6.8f, 7f, 7.2f), + ), + "storm_to_still" to timeline( + durationSec = 24 * 60, + visualLeftMs = listOf(140, 130, 120, 110, 100, 95, 90, 100, 120, 150, 180, 210, 240, 270, 300, 330, 340, 350, 360, 370, 380, 390, 400, 410, 420), + visualRightMs = listOf(170, 160, 150, 140, 130, 125, 120, 130, 150, 180, 210, 240, 270, 300, 330, 360, 370, 380, 390, 400, 410, 420, 430, 440, 450), + audioLeftHz = listOf(230f, 232f, 234f, 236f, 238f, 240f, 242f, 238f, 232f, 226f, 220f, 214f, 208f, 202f, 196f, 190f, 186f, 182f, 178f, 174f, 170f, 166f, 162f, 158f, 154f), + audioRightHz = listOf(12f, 12.5f, 13f, 13.5f, 14f, 14.5f, 15f, 14f, 13f, 12f, 11f, 10f, 9f, 8f, 7f, 6f, 5.5f, 5f, 4.5f, 4f, 3.6f, 3.2f, 2.9f, 2.6f, 2.4f), + ), + ) } diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt index 99f8ffd..84ba738 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt @@ -15,8 +15,8 @@ object TimelineRuntimeEvaluator { return RuntimeParams( flashOnMs = flashOnMs.coerceIn(50, 2000), flashOffMs = flashOffMs.coerceIn(50, 2000), - carrierHz = carrierHz, - binauralHz = binauralHz, + carrierHz = carrierHz.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX), + binauralHz = binauralHz.coerceIn(ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX), ) } } diff --git a/app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt b/app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt index 86a4763..2ed908a 100644 --- a/app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/ActiveSessionUiTest.kt @@ -1,6 +1,7 @@ package com.mindmachine.mvp import com.mindmachine.mvp.domain.RuntimeState +import com.mindmachine.mvp.session.sessionProgressFraction import com.mindmachine.mvp.session.shouldRevealSystemBarsOnTap import com.mindmachine.mvp.session.shouldShowActiveControlsByDefault import com.mindmachine.mvp.session.shouldUseImmersiveFullscreen @@ -36,12 +37,20 @@ class ActiveSessionUiTest { } @Test - fun tap_to_reveal_system_bars_is_only_enabled_during_active_visual_stimulation() { - assertTrue(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.COUNTDOWN)) - assertTrue(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.RUNNING)) - + fun tap_to_reveal_system_bars_is_disabled() { + assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.COUNTDOWN)) + assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.RUNNING)) assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = false, runtimeState = RuntimeState.RUNNING)) assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.PAUSED)) assertFalse(shouldRevealSystemBarsOnTap(isVisualMode = true, runtimeState = RuntimeState.COMPLETED)) } + + @Test + fun session_progress_fraction_tracks_elapsed_time_and_clamps() { + assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 600) == 0f) + assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 300) == 0.5f) + assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 0) == 1f) + assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = 800) == 0f) + assertTrue(sessionProgressFraction(durationSec = 600, remainingSec = -10) == 1f) + } } diff --git a/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt b/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt index 8911c3f..cdcba53 100644 --- a/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/PresetDefaultsTest.kt @@ -7,22 +7,47 @@ import org.junit.Test class PresetDefaultsTest { @Test - fun mvp_has_exactly_three_presets_with_spec_defaults() { - assertEquals(3, Presets.builtIn.size) - val relax = Presets.builtIn.first { it.id == "relax" } - val focus = Presets.builtIn.first { it.id == "focus" } - val sleep = Presets.builtIn.first { it.id == "sleep" } + fun mvp_has_eight_preset_programs_with_expected_defaults() { + assertEquals(8, Presets.builtIn.size) + val deepRelease = Presets.builtIn.first { it.id == "deep_release" } + val laserFocus = Presets.builtIn.first { it.id == "laser_focus" } + val creativeDrift = Presets.builtIn.first { it.id == "creative_drift" } + val resetBreath = Presets.builtIn.first { it.id == "reset_breath" } + val nightRampdown = Presets.builtIn.first { it.id == "night_rampdown" } + val powerWarmup = Presets.builtIn.first { it.id == "power_warmup" } + val bodyScan = Presets.builtIn.first { it.id == "body_scan" } + val stormToStill = Presets.builtIn.first { it.id == "storm_to_still" } - assertEquals(10 * 60, relax.defaultDurationSec) - assertEquals(VisualPattern.PULSE, relax.visualPatternType) - assertEquals(167, relax.flashIntervalMs) + assertEquals(18 * 60, deepRelease.defaultDurationSec) + assertEquals(VisualPattern.PULSE, deepRelease.visualPatternType) + assertEquals(260, deepRelease.flashIntervalMs) - assertEquals(15 * 60, focus.defaultDurationSec) - assertEquals(VisualPattern.FLASH, focus.visualPatternType) - assertEquals(100, focus.flashIntervalMs) + assertEquals(16 * 60, laserFocus.defaultDurationSec) + assertEquals(VisualPattern.FLASH, laserFocus.visualPatternType) + assertEquals(120, laserFocus.flashIntervalMs) - assertEquals(20 * 60, sleep.defaultDurationSec) - assertEquals(VisualPattern.PULSE, sleep.visualPatternType) - assertEquals(333, sleep.flashIntervalMs) + assertEquals(22 * 60, creativeDrift.defaultDurationSec) + assertEquals(VisualPattern.PULSE, creativeDrift.visualPatternType) + assertEquals(190, creativeDrift.flashIntervalMs) + + assertEquals(12 * 60, resetBreath.defaultDurationSec) + assertEquals(VisualPattern.PULSE, resetBreath.visualPatternType) + assertEquals(300, resetBreath.flashIntervalMs) + + assertEquals(28 * 60, nightRampdown.defaultDurationSec) + assertEquals(VisualPattern.PULSE, nightRampdown.visualPatternType) + assertEquals(360, nightRampdown.flashIntervalMs) + + assertEquals(9 * 60, powerWarmup.defaultDurationSec) + assertEquals(VisualPattern.FLASH, powerWarmup.visualPatternType) + assertEquals(95, powerWarmup.flashIntervalMs) + + assertEquals(20 * 60, bodyScan.defaultDurationSec) + assertEquals(VisualPattern.PULSE, bodyScan.visualPatternType) + assertEquals(210, bodyScan.flashIntervalMs) + + assertEquals(24 * 60, stormToStill.defaultDurationSec) + assertEquals(VisualPattern.FLASH, stormToStill.visualPatternType) + assertEquals(150, stormToStill.flashIntervalMs) } }