From 88723bf214211daeb9f046adfec22d9a1d1b4aa4 Mon Sep 17 00:00:00 2001 From: Tretzi Date: Sun, 22 Mar 2026 00:01:01 -0500 Subject: [PATCH] Auto commit Sun Mar 22 12:01:01 AM CDT 2026 --- .../java/com/mindmachine/mvp/MainActivity.kt | 45 +++++++++---- .../mvp/data/UserProgramRepository.kt | 5 ++ .../mindmachine/mvp/session/MainViewModel.kt | 14 ++++- .../mvp/session/SetupTimelineEditor.kt | 30 ++++++++- .../mvp/session/TimelineEditorScreen.kt | 10 +-- .../mvp/session/TimelineEditorState.kt | 61 ++++++++++++------ .../mvp/session/TimelineProgramFactory.kt | 2 + .../mindmachine/mvp/session/VisualSignal.kt | 63 ++++++++++++++++++- .../mvp/TimelineCurveGestureTest.kt | 18 +++--- .../com/mindmachine/mvp/VisualSignalTest.kt | 34 ++++++++++ 10 files changed, 226 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 06560b4..d7ed181 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -53,6 +53,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment @@ -82,7 +83,7 @@ import com.mindmachine.mvp.domain.SessionMode import com.mindmachine.mvp.session.FlashColor import com.mindmachine.mvp.session.MainViewModel import com.mindmachine.mvp.session.SplitFlashFrame -import com.mindmachine.mvp.session.computeSplitFlashFrame +import com.mindmachine.mvp.session.SplitFlashSequencer import com.mindmachine.mvp.session.flashIntervalMsToSeconds import com.mindmachine.mvp.session.flashIntervalSecondsToMs import com.mindmachine.mvp.session.sessionProgressFraction @@ -204,8 +205,10 @@ fun App(vm: MainViewModel = viewModel()) { SetupScreen( vm = vm, onStart = { - vm.startSession() - nav.navigate("active") + val started = vm.startSession() + if (started) { + nav.navigate("active") + } }, onHolder = { nav.navigate("holder") }, ) @@ -223,8 +226,10 @@ fun App(vm: MainViewModel = viewModel()) { ui.selectedPreset.name ) { Button(onClick = { - vm.startSession() - nav.navigate("active") + val started = vm.startSession() + if (started) { + nav.navigate("active") + } }) { Text("Repeat Session") } OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") } } @@ -313,6 +318,7 @@ fun SetupScreen( SetupTimelineEditor( state = ui.timeline, onStateChanged = vm::updateTimeline, + onCurveGranularityChanged = vm::setCurveGranularitySec, ) TextButton(onClick = onHolder) { Text("Holder Guidance") } @@ -503,14 +509,27 @@ private fun rememberSplitFlashFrame( mode: SessionMode, flashOnMs: Int, flashOffMs: Int, -) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashOnMs, flashOffMs) { - if (!isRunning || mode == SessionMode.AUDIO_ONLY) { - value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) - return@produceState - } - while (true) { - withFrameNanos { frameTimeNanos -> - value = computeSplitFlashFrame(frameTimeNanos, flashOnMs, flashOffMs) +): androidx.compose.runtime.State { + val latestFlashOnMs = rememberUpdatedState(flashOnMs) + val latestFlashOffMs = rememberUpdatedState(flashOffMs) + + return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode) { + if (!isRunning || mode == SessionMode.AUDIO_ONLY) { + value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) + return@produceState + } + + val sequencer = SplitFlashSequencer( + startTimeNanos = System.nanoTime(), + flashOnMs = latestFlashOnMs.value, + flashOffMs = latestFlashOffMs.value, + ) + + while (true) { + withFrameNanos { frameTimeNanos -> + sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value) + value = sequencer.frameAt(frameTimeNanos) + } } } } diff --git a/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt b/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt index 0b1dd50..3e7affd 100644 --- a/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt +++ b/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.preferencesDataStore import com.mindmachine.mvp.session.TimelineCurve import com.mindmachine.mvp.session.TimelineEditorState import com.mindmachine.mvp.session.TimelinePoint +import com.mindmachine.mvp.session.TIMELINE_DEFAULT_GRANULARITY_SEC import com.mindmachine.mvp.session.TimelineSelection import com.mindmachine.mvp.session.TimelineViewport import kotlinx.coroutines.flow.Flow @@ -81,6 +82,9 @@ class UserProgramRepository(private val context: Context) { return TimelineEditorState( durationSec = o.getInt("durationSec"), + curveGranularitySec = TimelineEditorState.normalizeGranularitySec( + o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC) + ), selection = TimelineSelection( startSec = o.optInt("selectionStartSec", 0), endSec = o.optInt("selectionEndSec", o.getInt("durationSec")), @@ -110,6 +114,7 @@ class UserProgramRepository(private val context: Context) { val timeline = JSONObject() timeline.put("durationSec", program.timeline.durationSec) + timeline.put("curveGranularitySec", program.timeline.curveGranularitySec) timeline.put("selectionStartSec", program.timeline.selection.startSec) timeline.put("selectionEndSec", program.timeline.selection.endSec) timeline.put("viewportStartSec", program.timeline.viewport.startSec) 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 206142f..730d49c 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -210,6 +210,13 @@ class MainViewModel( ) } + fun setCurveGranularitySec(seconds: Int) = _ui.update { + it.copy( + timeline = it.timeline.setCurveGranularitySec(seconds), + error = null, + ) + } + fun updateTimeline(newState: TimelineEditorState) = _ui.update { it.copy( timeline = newState, @@ -227,21 +234,22 @@ class MainViewModel( fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) } fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) } - fun startSession() { + fun startSession(): Boolean { val state = _ui.value if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) { _ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") } - return + return false } val headset = headsetMonitor.isStereoHeadsetAvailable() val validation = SessionValidator.validate(state.config, headset) if (validation != null) { _ui.update { it.copy(error = validation) } - return + return false } elapsedBeforePauseSec = 0f runProgram(startElapsedSec = 0f, includeCountdown = true) + return true } fun pause(reason: String? = null) { diff --git a/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt index 7c95cba..c292787 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt @@ -6,20 +6,24 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlin.math.max +import kotlin.math.roundToInt @Composable fun SetupTimelineEditor( state: TimelineEditorState, onStateChanged: (TimelineEditorState) -> Unit, + onCurveGranularityChanged: (Int) -> Unit, modifier: Modifier = Modifier, ) { var local by remember(state) { mutableStateOf(state) } @@ -34,11 +38,35 @@ fun SetupTimelineEditor( color = MaterialTheme.colorScheme.onBackground, ) Text( - "Each dot is one minute. Drag dots up/down to shape the session. Pinch to zoom for precision.", + "Adjust curve granularity, then drag dots up/down to shape the session. Pinch to zoom for precision.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), ) + val granularitySec = local.curveGranularitySec + Text( + "Curve granularity: ${granularitySec}s per dot", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + var granularitySliderValue by remember(state.curveGranularitySec) { + mutableFloatStateOf(state.curveGranularitySec.toFloat()) + } + Slider( + value = granularitySliderValue, + onValueChange = { granularitySliderValue = it }, + onValueChangeFinished = { + val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt()) + if (snapped != local.curveGranularitySec) { + local = local.setCurveGranularitySec(snapped).also(onStateChanged) + onCurveGranularityChanged(snapped) + } + granularitySliderValue = local.curveGranularitySec.toFloat() + }, + valueRange = TIMELINE_MIN_GRANULARITY_SEC.toFloat()..TIMELINE_MAX_GRANULARITY_SEC.toFloat(), + steps = ((TIMELINE_MAX_GRANULARITY_SEC - TIMELINE_MIN_GRANULARITY_SEC) / 5) - 1, + ) + TimelineGraph( title = "Visual", leftLabel = "Flash Interval", 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 d83944b..2aa9b1f 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -506,7 +506,7 @@ internal fun TimelineGraph( } Text( - "One dot per minute • Drag selected curve dots up/down • Pinch zoom • Drag background pan • Drag handles to set range", + "Dots follow selected granularity • Drag selected curve dots up/down • Pinch zoom • Drag background pan • Drag handles to set range", color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, @@ -519,13 +519,7 @@ internal fun TimelineGraph( private fun formatTimeLabel(totalSec: Float): String { val totalMinutes = (totalSec / 60f).toInt().coerceAtLeast(0) - val hours = totalMinutes / 60 - val minutes = totalMinutes % 60 - return if (hours > 0) { - if (minutes == 0) "${hours}h" else "${hours}h ${minutes}m" - } else { - "${minutes}m" - } + return "${totalMinutes} min" } private fun formatMsLabel(value: Float): String = "${value.toInt()} ms" diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt index 432d9eb..11889b1 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -1,15 +1,17 @@ package com.mindmachine.mvp.session -import kotlin.math.abs import kotlin.math.max import kotlin.math.min private const val MIN_DURATION_SEC = 60 private const val MAX_DURATION_SEC = 8 * 60 * 60 -private const val TIMELINE_STEP_SEC = 60 +const val TIMELINE_MIN_GRANULARITY_SEC = 5 +const val TIMELINE_MAX_GRANULARITY_SEC = 300 +const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60 data class TimelineEditorState( val durationSec: Int, + val curveGranularitySec: Int, val selection: TimelineSelection, val viewport: TimelineViewport, val activeCurve: ActiveCurve, @@ -30,9 +32,11 @@ data class TimelineEditorState( companion object { fun default(durationSec: Int = 20 * 60): TimelineEditorState { val dur = snapDuration(durationSec) - val initial = TimelineCurve.constant(0.5f, dur) + val granularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC + val initial = TimelineCurve.constant(0.5f, dur, granularitySec) return TimelineEditorState( durationSec = dur, + curveGranularitySec = granularitySec, selection = TimelineSelection(0, dur), viewport = TimelineViewport( startSec = 0f, @@ -50,6 +54,9 @@ data class TimelineEditorState( private fun snapDuration(durationSec: Int): Int = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60 + + fun normalizeGranularitySec(granularitySec: Int): Int = + ((granularitySec.coerceIn(TIMELINE_MIN_GRANULARITY_SEC, TIMELINE_MAX_GRANULARITY_SEC) + 2) / 5) * 5 } fun curveFor(id: ActiveCurve): TimelineCurve = when (id) { @@ -60,7 +67,7 @@ data class TimelineEditorState( } fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState { - val normalized = curve.ensureMinutePoints(durationSec) + val normalized = curve.ensureTimelinePoints(durationSec, curveGranularitySec) return when (id) { ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized) ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized) @@ -76,10 +83,10 @@ data class TimelineEditorState( return copy( durationSec = newDur, selection = selection.copy(endSec = max(selection.endSec, newDur)), - visualLeft = visualLeft.extendTo(newDur), - visualRight = visualRight.extendTo(newDur), - audioLeft = audioLeft.extendTo(newDur), - audioRight = audioRight.extendTo(newDur), + visualLeft = visualLeft.extendTo(newDur, curveGranularitySec), + visualRight = visualRight.extendTo(newDur, curveGranularitySec), + audioLeft = audioLeft.extendTo(newDur, curveGranularitySec), + audioRight = audioRight.extendTo(newDur, curveGranularitySec), ) } @@ -94,16 +101,29 @@ data class TimelineEditorState( copy( durationSec = clamped, selection = TimelineSelection(0, clamped), - visualLeft = visualLeft.trimTo(clamped), - visualRight = visualRight.trimTo(clamped), - audioLeft = audioLeft.trimTo(clamped), - audioRight = audioRight.trimTo(clamped), + visualLeft = visualLeft.trimTo(clamped, curveGranularitySec), + visualRight = visualRight.trimTo(clamped, curveGranularitySec), + audioLeft = audioLeft.trimTo(clamped, curveGranularitySec), + audioRight = audioRight.trimTo(clamped, curveGranularitySec), viewport = newViewport, playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()), ) } } + fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState { + val normalized = normalizeGranularitySec(newGranularitySec) + if (normalized == curveGranularitySec) return this + + return copy( + curveGranularitySec = normalized, + visualLeft = visualLeft.ensureTimelinePoints(durationSec, normalized), + visualRight = visualRight.ensureTimelinePoints(durationSec, normalized), + audioLeft = audioLeft.ensureTimelinePoints(durationSec, normalized), + audioRight = audioRight.ensureTimelinePoints(durationSec, normalized), + ) + } + fun clampSelection(): TimelineEditorState { val start = selection.startSec.coerceIn(0, durationSec) var end = selection.endSec.coerceIn(0, durationSec) @@ -169,10 +189,11 @@ data class TimelineCurve( val points: List ) { companion object { - fun constant(value01: Float, durationSec: Int): TimelineCurve { + fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): TimelineCurve { val v = value01.coerceIn(0f, 1f) + val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec) return TimelineCurve( - points = (0..durationSec step TIMELINE_STEP_SEC).map { TimelinePoint(it, v) } + points = (0..durationSec step stepSec).map { TimelinePoint(it, v) } ) } } @@ -192,15 +213,16 @@ data class TimelineCurve( return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f) } - fun ensureMinutePoints(durationSec: Int): TimelineCurve { + fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve { val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60 - val normalized = (0..snappedDuration step TIMELINE_STEP_SEC).map { minuteSec -> - TimelinePoint(minuteSec, valueAt(minuteSec.toFloat())) + val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec) + val normalized = (0..snappedDuration step stepSec).map { tSec -> + TimelinePoint(tSec, valueAt(tSec.toFloat())) } return copy(points = normalized) } - fun trimTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) + fun trimTo(newDurationSec: Int, granularitySec: Int): TimelineCurve = ensureTimelinePoints(newDurationSec, granularitySec) fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve { if (index !in points.indices) return this @@ -214,7 +236,6 @@ data class TimelineCurve( if (index !in points.indices) return this val v = (1f - (newY / heightPx)).coerceIn(0f, 1f) val updated = points.toMutableList() - val draggedTime = updated[index].tSec // Update the dragged point and all points after it (same or later time) for (i in index until updated.size) { updated[i] = updated[i].copy(value01 = v) @@ -235,7 +256,7 @@ data class TimelineCurve( }.minByOrNull { it.second }?.first } - fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) + fun extendTo(newDurationSec: Int, granularitySec: Int): TimelineCurve = ensureTimelinePoints(newDurationSec, granularitySec) } data class TimelinePoint( 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 55c89fc..cd331d8 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt @@ -73,6 +73,7 @@ object TimelineProgramFactory { return TimelineEditorState( durationSec = dur, + curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC, selection = TimelineSelection(0, dur), viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())), activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, @@ -122,6 +123,7 @@ object TimelineProgramFactory { return TimelineEditorState( durationSec = durationSec, + curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC, selection = TimelineSelection(0, durationSec), viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())), activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, diff --git a/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt index 3c7de55..f76a357 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt @@ -11,13 +11,72 @@ data class SplitFlashFrame( val right: FlashColor, ) +private const val MIN_INTERVAL_MS = 50 +private const val MAX_INTERVAL_MS = 2000 + +private fun clampIntervalMs(value: Int): Long = value.coerceIn(MIN_INTERVAL_MS, MAX_INTERVAL_MS).toLong() + +class SplitFlashSequencer( + startTimeNanos: Long, + flashOnMs: Int, + flashOffMs: Int, +) { + private var onMs: Long = clampIntervalMs(flashOnMs) + private var offMs: Long = clampIntervalMs(flashOffMs) + private var pendingOnMs: Long = onMs + private var pendingOffMs: Long = offMs + + private var phase: Int = 0 + private var phaseStartNanos: Long = startTimeNanos + + fun updateIntervals(flashOnMs: Int, flashOffMs: Int) { + pendingOnMs = clampIntervalMs(flashOnMs) + pendingOffMs = clampIntervalMs(flashOffMs) + } + + fun frameAt(frameTimeNanos: Long): SplitFlashFrame { + if (frameTimeNanos < phaseStartNanos) { + phaseStartNanos = frameTimeNanos + phase = 0 + } + + while (frameTimeNanos - phaseStartNanos >= currentPhaseDurationNanos()) { + phaseStartNanos += currentPhaseDurationNanos() + phase = (phase + 1) % 4 + applyPendingIfBoundary() + } + + return frameForPhase(phase) + } + + private fun applyPendingIfBoundary() { + if (phase == 0 || phase == 2) { + onMs = pendingOnMs + } else { + offMs = pendingOffMs + } + } + + private fun currentPhaseDurationNanos(): Long { + val durationMs = if (phase == 0 || phase == 2) onMs else offMs + return durationMs * 1_000_000L + } + + private fun frameForPhase(phase: Int): SplitFlashFrame = when (phase) { + 0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN) + 1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK) + 2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED) + else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK) + } +} + fun computeSplitFlashFrame( frameTimeNanos: Long, flashOnMs: Int, flashOffMs: Int, ): SplitFlashFrame { - val onMs = flashOnMs.coerceIn(50, 2000).toLong() - val offMs = flashOffMs.coerceIn(50, 2000).toLong() + val onMs = clampIntervalMs(flashOnMs) + val offMs = clampIntervalMs(flashOffMs) val elapsedMs = frameTimeNanos / 1_000_000L // Four phases: on, off, on(swapped), off diff --git a/app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt b/app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt index cf13ec2..b786a8e 100644 --- a/app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt @@ -171,7 +171,7 @@ class TimelineCurveGestureTest { } @Test - fun `ensureMinutePoints snaps points to minute boundaries`() { + fun `ensureTimelinePoints snaps points to selected boundaries`() { val points = listOf( TimelinePoint(0, 0.0f), TimelinePoint(30, 0.5f), // 30 seconds - not a minute boundary @@ -179,26 +179,26 @@ class TimelineCurveGestureTest { ) val curve = TimelineCurve(points) - val result = curve.ensureMinutePoints(180) + val result = curve.ensureTimelinePoints(180, granularitySec = 30) - // Check that all points are on minute boundaries (multiples of 60) + // Check that all points are on selected boundaries (multiples of 30) result.points.forEach { point -> - assertTrue("Point at ${point.tSec} should be on minute boundary", point.tSec % 60 == 0) + assertTrue("Point at ${point.tSec} should be on 30s boundary", point.tSec % 30 == 0) } - // Should have points at 0, 60, 120, 180 seconds - assertEquals(4, result.points.size) + // Should have points at 0, 30, 60, 90, 120, 150, 180 seconds + assertEquals(7, result.points.size) } @Test - fun `ensureMinutePoints preserves interpolated values`() { + fun `ensureTimelinePoints preserves interpolated values`() { val points = listOf( TimelinePoint(0, 0.0f), TimelinePoint(120, 1.0f), ) val curve = TimelineCurve(points) - val result = curve.ensureMinutePoints(180) + val result = curve.ensureTimelinePoints(180, granularitySec = 60) // Value at 60 seconds should interpolate to 0.5 val valueAt60 = result.valueAt(60f) @@ -209,7 +209,7 @@ class TimelineCurveGestureTest { fun `extendTo adds points for extended duration`() { val curve = TimelineCurve.constant(0.5f, 60) - val extended = curve.extendTo(120) + val extended = curve.extendTo(120, granularitySec = 60) // Should have points covering up to 120 seconds assertTrue(extended.points.any { it.tSec == 120 }) diff --git a/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt index c4b3cb8..9df08c7 100644 --- a/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt @@ -1,6 +1,7 @@ package com.mindmachine.mvp import com.mindmachine.mvp.session.FlashColor +import com.mindmachine.mvp.session.SplitFlashSequencer import com.mindmachine.mvp.session.computeSplitFlashFrame import org.junit.Assert.assertEquals import org.junit.Test @@ -39,4 +40,37 @@ class VisualSignalTest { val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 2000, flashOffMs = 2000) assertEquals(validAt2000ms, tooSlow) } + + @Test + fun sequencer_keeps_blank_duration_constant_when_flash_duration_changes_mid_blank() { + val sequencer = SplitFlashSequencer(startTimeNanos = 0L, flashOnMs = 100, flashOffMs = 1000) + + assertEquals(FlashColor.BLACK, sequencer.frameAt(100_000_000L).left) + + sequencer.updateIntervals(flashOnMs = 200, flashOffMs = 1000) + + // Still blank before the configured 1000ms blank phase is complete. + assertEquals(FlashColor.BLACK, sequencer.frameAt(600_000_000L).left) + assertEquals(FlashColor.BLACK, sequencer.frameAt(1_099_000_000L).left) + + // At 1100ms total, blank ends and next on-phase starts. + assertEquals(FlashColor.GREEN, sequencer.frameAt(1_100_000_000L).left) + } + + @Test + fun sequencer_applies_new_blank_interval_on_next_blank_boundary() { + val sequencer = SplitFlashSequencer(startTimeNanos = 0L, flashOnMs = 100, flashOffMs = 100) + + // Enter second on phase. + assertEquals(FlashColor.GREEN, sequencer.frameAt(200_000_000L).left) + + // Change blank interval during on phase; current on should finish uninterrupted. + sequencer.updateIntervals(flashOnMs = 100, flashOffMs = 1000) + assertEquals(FlashColor.GREEN, sequencer.frameAt(250_000_000L).left) + + // Next phase is blank and should use new 1000ms off interval. + assertEquals(FlashColor.BLACK, sequencer.frameAt(300_000_000L).left) + assertEquals(FlashColor.BLACK, sequencer.frameAt(1_299_000_000L).left) + assertEquals(FlashColor.RED, sequencer.frameAt(1_300_000_000L).left) + } }