Auto commit Sun Mar 22 12:01:01 AM CDT 2026
This commit is contained in:
@@ -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<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 ->
|
||||
sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value)
|
||||
value = sequencer.frameAt(frameTimeNanos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user