Auto commit Sun Mar 22 12:01:01 AM CDT 2026

This commit is contained in:
Tretzi
2026-03-22 00:01:01 -05:00
parent 869c98a47c
commit 88723bf214
10 changed files with 226 additions and 56 deletions

View File

@@ -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()
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()
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) {
): androidx.compose.runtime.State<SplitFlashFrame> {
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 ->
value = computeSplitFlashFrame(frameTimeNanos, flashOnMs, flashOffMs)
sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value)
value = sequencer.frameAt(frameTimeNanos)
}
}
}
}

View File

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

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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"

View File

@@ -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<TimelinePoint>
) {
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(

View File

@@ -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,

View File

@@ -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

View File

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

View File

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