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.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment 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.FlashColor
import com.mindmachine.mvp.session.MainViewModel import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.SplitFlashFrame 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.flashIntervalMsToSeconds
import com.mindmachine.mvp.session.flashIntervalSecondsToMs import com.mindmachine.mvp.session.flashIntervalSecondsToMs
import com.mindmachine.mvp.session.sessionProgressFraction import com.mindmachine.mvp.session.sessionProgressFraction
@@ -204,8 +205,10 @@ fun App(vm: MainViewModel = viewModel()) {
SetupScreen( SetupScreen(
vm = vm, vm = vm,
onStart = { onStart = {
vm.startSession() val started = vm.startSession()
if (started) {
nav.navigate("active") nav.navigate("active")
}
}, },
onHolder = { nav.navigate("holder") }, onHolder = { nav.navigate("holder") },
) )
@@ -223,8 +226,10 @@ fun App(vm: MainViewModel = viewModel()) {
ui.selectedPreset.name ui.selectedPreset.name
) { ) {
Button(onClick = { Button(onClick = {
vm.startSession() val started = vm.startSession()
if (started) {
nav.navigate("active") nav.navigate("active")
}
}) { Text("Repeat Session") } }) { Text("Repeat Session") }
OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") } OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") }
} }
@@ -313,6 +318,7 @@ fun SetupScreen(
SetupTimelineEditor( SetupTimelineEditor(
state = ui.timeline, state = ui.timeline,
onStateChanged = vm::updateTimeline, onStateChanged = vm::updateTimeline,
onCurveGranularityChanged = vm::setCurveGranularitySec,
) )
TextButton(onClick = onHolder) { Text("Holder Guidance") } TextButton(onClick = onHolder) { Text("Holder Guidance") }
@@ -503,14 +509,27 @@ private fun rememberSplitFlashFrame(
mode: SessionMode, mode: SessionMode,
flashOnMs: Int, flashOnMs: Int,
flashOffMs: 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) { if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState return@produceState
} }
val sequencer = SplitFlashSequencer(
startTimeNanos = System.nanoTime(),
flashOnMs = latestFlashOnMs.value,
flashOffMs = latestFlashOffMs.value,
)
while (true) { while (true) {
withFrameNanos { frameTimeNanos -> 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.TimelineCurve
import com.mindmachine.mvp.session.TimelineEditorState import com.mindmachine.mvp.session.TimelineEditorState
import com.mindmachine.mvp.session.TimelinePoint 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.TimelineSelection
import com.mindmachine.mvp.session.TimelineViewport import com.mindmachine.mvp.session.TimelineViewport
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -81,6 +82,9 @@ class UserProgramRepository(private val context: Context) {
return TimelineEditorState( return TimelineEditorState(
durationSec = o.getInt("durationSec"), durationSec = o.getInt("durationSec"),
curveGranularitySec = TimelineEditorState.normalizeGranularitySec(
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC)
),
selection = TimelineSelection( selection = TimelineSelection(
startSec = o.optInt("selectionStartSec", 0), startSec = o.optInt("selectionStartSec", 0),
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")), endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
@@ -110,6 +114,7 @@ class UserProgramRepository(private val context: Context) {
val timeline = JSONObject() val timeline = JSONObject()
timeline.put("durationSec", program.timeline.durationSec) timeline.put("durationSec", program.timeline.durationSec)
timeline.put("curveGranularitySec", program.timeline.curveGranularitySec)
timeline.put("selectionStartSec", program.timeline.selection.startSec) timeline.put("selectionStartSec", program.timeline.selection.startSec)
timeline.put("selectionEndSec", program.timeline.selection.endSec) timeline.put("selectionEndSec", program.timeline.selection.endSec)
timeline.put("viewportStartSec", program.timeline.viewport.startSec) 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 { fun updateTimeline(newState: TimelineEditorState) = _ui.update {
it.copy( it.copy(
timeline = newState, timeline = newState,
@@ -227,21 +234,22 @@ class MainViewModel(
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) } fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) } fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
fun startSession() { fun startSession(): Boolean {
val state = _ui.value val state = _ui.value
if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) { if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) {
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") } _ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
return return false
} }
val headset = headsetMonitor.isStereoHeadsetAvailable() val headset = headsetMonitor.isStereoHeadsetAvailable()
val validation = SessionValidator.validate(state.config, headset) val validation = SessionValidator.validate(state.config, headset)
if (validation != null) { if (validation != null) {
_ui.update { it.copy(error = validation) } _ui.update { it.copy(error = validation) }
return return false
} }
elapsedBeforePauseSec = 0f elapsedBeforePauseSec = 0f
runProgram(startElapsedSec = 0f, includeCountdown = true) runProgram(startElapsedSec = 0f, includeCountdown = true)
return true
} }
fun pause(reason: String? = null) { 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt
@Composable @Composable
fun SetupTimelineEditor( fun SetupTimelineEditor(
state: TimelineEditorState, state: TimelineEditorState,
onStateChanged: (TimelineEditorState) -> Unit, onStateChanged: (TimelineEditorState) -> Unit,
onCurveGranularityChanged: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var local by remember(state) { mutableStateOf(state) } var local by remember(state) { mutableStateOf(state) }
@@ -34,11 +38,35 @@ fun SetupTimelineEditor(
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), 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( TimelineGraph(
title = "Visual", title = "Visual",
leftLabel = "Flash Interval", leftLabel = "Flash Interval",

View File

@@ -506,7 +506,7 @@ internal fun TimelineGraph(
} }
Text( 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), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -519,13 +519,7 @@ internal fun TimelineGraph(
private fun formatTimeLabel(totalSec: Float): String { private fun formatTimeLabel(totalSec: Float): String {
val totalMinutes = (totalSec / 60f).toInt().coerceAtLeast(0) val totalMinutes = (totalSec / 60f).toInt().coerceAtLeast(0)
val hours = totalMinutes / 60 return "${totalMinutes} min"
val minutes = totalMinutes % 60
return if (hours > 0) {
if (minutes == 0) "${hours}h" else "${hours}h ${minutes}m"
} else {
"${minutes}m"
}
} }
private fun formatMsLabel(value: Float): String = "${value.toInt()} ms" private fun formatMsLabel(value: Float): String = "${value.toInt()} ms"

View File

@@ -1,15 +1,17 @@
package com.mindmachine.mvp.session package com.mindmachine.mvp.session
import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
private const val MIN_DURATION_SEC = 60 private const val MIN_DURATION_SEC = 60
private const val MAX_DURATION_SEC = 8 * 60 * 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( data class TimelineEditorState(
val durationSec: Int, val durationSec: Int,
val curveGranularitySec: Int,
val selection: TimelineSelection, val selection: TimelineSelection,
val viewport: TimelineViewport, val viewport: TimelineViewport,
val activeCurve: ActiveCurve, val activeCurve: ActiveCurve,
@@ -30,9 +32,11 @@ data class TimelineEditorState(
companion object { companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState { fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val dur = snapDuration(durationSec) 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( return TimelineEditorState(
durationSec = dur, durationSec = dur,
curveGranularitySec = granularitySec,
selection = TimelineSelection(0, dur), selection = TimelineSelection(0, dur),
viewport = TimelineViewport( viewport = TimelineViewport(
startSec = 0f, startSec = 0f,
@@ -50,6 +54,9 @@ data class TimelineEditorState(
private fun snapDuration(durationSec: Int): Int = private fun snapDuration(durationSec: Int): Int =
((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60 ((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) { fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
@@ -60,7 +67,7 @@ data class TimelineEditorState(
} }
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState { fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState {
val normalized = curve.ensureMinutePoints(durationSec) val normalized = curve.ensureTimelinePoints(durationSec, curveGranularitySec)
return when (id) { return when (id) {
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized) ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized)
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized) ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized)
@@ -76,10 +83,10 @@ data class TimelineEditorState(
return copy( return copy(
durationSec = newDur, durationSec = newDur,
selection = selection.copy(endSec = max(selection.endSec, newDur)), selection = selection.copy(endSec = max(selection.endSec, newDur)),
visualLeft = visualLeft.extendTo(newDur), visualLeft = visualLeft.extendTo(newDur, curveGranularitySec),
visualRight = visualRight.extendTo(newDur), visualRight = visualRight.extendTo(newDur, curveGranularitySec),
audioLeft = audioLeft.extendTo(newDur), audioLeft = audioLeft.extendTo(newDur, curveGranularitySec),
audioRight = audioRight.extendTo(newDur), audioRight = audioRight.extendTo(newDur, curveGranularitySec),
) )
} }
@@ -94,16 +101,29 @@ data class TimelineEditorState(
copy( copy(
durationSec = clamped, durationSec = clamped,
selection = TimelineSelection(0, clamped), selection = TimelineSelection(0, clamped),
visualLeft = visualLeft.trimTo(clamped), visualLeft = visualLeft.trimTo(clamped, curveGranularitySec),
visualRight = visualRight.trimTo(clamped), visualRight = visualRight.trimTo(clamped, curveGranularitySec),
audioLeft = audioLeft.trimTo(clamped), audioLeft = audioLeft.trimTo(clamped, curveGranularitySec),
audioRight = audioRight.trimTo(clamped), audioRight = audioRight.trimTo(clamped, curveGranularitySec),
viewport = newViewport, viewport = newViewport,
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()), 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 { fun clampSelection(): TimelineEditorState {
val start = selection.startSec.coerceIn(0, durationSec) val start = selection.startSec.coerceIn(0, durationSec)
var end = selection.endSec.coerceIn(0, durationSec) var end = selection.endSec.coerceIn(0, durationSec)
@@ -169,10 +189,11 @@ data class TimelineCurve(
val points: List<TimelinePoint> val points: List<TimelinePoint>
) { ) {
companion object { 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 v = value01.coerceIn(0f, 1f)
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
return TimelineCurve( 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) 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 snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
val normalized = (0..snappedDuration step TIMELINE_STEP_SEC).map { minuteSec -> val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
TimelinePoint(minuteSec, valueAt(minuteSec.toFloat())) val normalized = (0..snappedDuration step stepSec).map { tSec ->
TimelinePoint(tSec, valueAt(tSec.toFloat()))
} }
return copy(points = normalized) 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 { fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve {
if (index !in points.indices) return this if (index !in points.indices) return this
@@ -214,7 +236,6 @@ data class TimelineCurve(
if (index !in points.indices) return this if (index !in points.indices) return this
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f) val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList() val updated = points.toMutableList()
val draggedTime = updated[index].tSec
// Update the dragged point and all points after it (same or later time) // Update the dragged point and all points after it (same or later time)
for (i in index until updated.size) { for (i in index until updated.size) {
updated[i] = updated[i].copy(value01 = v) updated[i] = updated[i].copy(value01 = v)
@@ -235,7 +256,7 @@ data class TimelineCurve(
}.minByOrNull { it.second }?.first }.minByOrNull { it.second }?.first
} }
fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) fun extendTo(newDurationSec: Int, granularitySec: Int): TimelineCurve = ensureTimelinePoints(newDurationSec, granularitySec)
} }
data class TimelinePoint( data class TimelinePoint(

View File

@@ -73,6 +73,7 @@ object TimelineProgramFactory {
return TimelineEditorState( return TimelineEditorState(
durationSec = dur, durationSec = dur,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
selection = TimelineSelection(0, dur), selection = TimelineSelection(0, dur),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())), viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
@@ -122,6 +123,7 @@ object TimelineProgramFactory {
return TimelineEditorState( return TimelineEditorState(
durationSec = durationSec, durationSec = durationSec,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
selection = TimelineSelection(0, durationSec), selection = TimelineSelection(0, durationSec),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())), viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,

View File

@@ -11,13 +11,72 @@ data class SplitFlashFrame(
val right: FlashColor, 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( fun computeSplitFlashFrame(
frameTimeNanos: Long, frameTimeNanos: Long,
flashOnMs: Int, flashOnMs: Int,
flashOffMs: Int, flashOffMs: Int,
): SplitFlashFrame { ): SplitFlashFrame {
val onMs = flashOnMs.coerceIn(50, 2000).toLong() val onMs = clampIntervalMs(flashOnMs)
val offMs = flashOffMs.coerceIn(50, 2000).toLong() val offMs = clampIntervalMs(flashOffMs)
val elapsedMs = frameTimeNanos / 1_000_000L val elapsedMs = frameTimeNanos / 1_000_000L
// Four phases: on, off, on(swapped), off // Four phases: on, off, on(swapped), off

View File

@@ -171,7 +171,7 @@ class TimelineCurveGestureTest {
} }
@Test @Test
fun `ensureMinutePoints snaps points to minute boundaries`() { fun `ensureTimelinePoints snaps points to selected boundaries`() {
val points = listOf( val points = listOf(
TimelinePoint(0, 0.0f), TimelinePoint(0, 0.0f),
TimelinePoint(30, 0.5f), // 30 seconds - not a minute boundary TimelinePoint(30, 0.5f), // 30 seconds - not a minute boundary
@@ -179,26 +179,26 @@ class TimelineCurveGestureTest {
) )
val curve = TimelineCurve(points) 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 -> 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 // Should have points at 0, 30, 60, 90, 120, 150, 180 seconds
assertEquals(4, result.points.size) assertEquals(7, result.points.size)
} }
@Test @Test
fun `ensureMinutePoints preserves interpolated values`() { fun `ensureTimelinePoints preserves interpolated values`() {
val points = listOf( val points = listOf(
TimelinePoint(0, 0.0f), TimelinePoint(0, 0.0f),
TimelinePoint(120, 1.0f), TimelinePoint(120, 1.0f),
) )
val curve = TimelineCurve(points) 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 // Value at 60 seconds should interpolate to 0.5
val valueAt60 = result.valueAt(60f) val valueAt60 = result.valueAt(60f)
@@ -209,7 +209,7 @@ class TimelineCurveGestureTest {
fun `extendTo adds points for extended duration`() { fun `extendTo adds points for extended duration`() {
val curve = TimelineCurve.constant(0.5f, 60) 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 // Should have points covering up to 120 seconds
assertTrue(extended.points.any { it.tSec == 120 }) assertTrue(extended.points.any { it.tSec == 120 })

View File

@@ -1,6 +1,7 @@
package com.mindmachine.mvp package com.mindmachine.mvp
import com.mindmachine.mvp.session.FlashColor import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.SplitFlashSequencer
import com.mindmachine.mvp.session.computeSplitFlashFrame import com.mindmachine.mvp.session.computeSplitFlashFrame
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -39,4 +40,37 @@ class VisualSignalTest {
val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 2000, flashOffMs = 2000) val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 2000, flashOffMs = 2000)
assertEquals(validAt2000ms, tooSlow) 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)
}
} }