Auto commit (MindMachine) Mon Apr 13 08:01:01 PM CDT 2026

This commit is contained in:
Tretzi
2026-04-13 20:01:01 -05:00
parent e3ac06b047
commit 14dd04413b
12 changed files with 342 additions and 67 deletions

View File

@@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import solutions.tretter.mindmachine.session.DurationSliderCard
import solutions.tretter.mindmachine.session.FlashSequenceEditor
import solutions.tretter.mindmachine.session.SetupTimelineEditor
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -519,13 +520,10 @@ fun SetupScreen(
onCurveGranularityChanged = vm::setCurveGranularitySec,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = ui.config.monochromeFlashMode,
onCheckedChange = vm::setMonochromeFlashMode,
)
Text("Use black/white flashes for this session", color = MaterialTheme.colorScheme.onBackground)
}
FlashSequenceEditor(
sequence = ui.timeline.flashSequence,
onSequenceChanged = { sequence -> vm.updateTimeline(ui.timeline.withFlashSequence(sequence)) },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (hasUnsavedChanges) {
@@ -653,11 +651,13 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
SplitFlashRenderView(ctx).apply {
setRunning(ui.runtimeState == RuntimeState.RUNNING)
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
setFlashSequence(ui.config.flashSequence)
setMonochrome(ui.config.monochromeFlashMode)
}
},
update = { view ->
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
view.setFlashSequence(ui.config.flashSequence)
view.setMonochrome(ui.config.monochromeFlashMode)
view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
}
@@ -868,6 +868,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
private var onMs = 167L
private var offMs = 167L
private var monochrome = false
private var flashSequence = solutions.tretter.mindmachine.session.defaultFlashSequence()
private var started = false
private var startUptimeMs = 0L
private var nextToggleUptimeMs = 0L
@@ -888,11 +889,11 @@ private class SplitFlashRenderView(context: Context) : View(context) {
maxLatenessMs = maxOf(maxLatenessMs, latenessMs)
}
phase = (phase + 1) % 4
phase = (phase + 1) % phaseCount()
toggleCount += 1
invalidate()
val nextDuration = if (phase == 0 || phase == 2) onMs else offMs
val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
nextToggleUptimeMs += nextDuration
val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs() || toggleCount % 60L == 0L
@@ -911,11 +912,19 @@ private class SplitFlashRenderView(context: Context) : View(context) {
}
fun updateIntervals(flashOnMs: Int, flashOffMs: Int) {
rawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
rawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
val newRawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
val newRawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
val frameMs = framePeriodMs().coerceAtLeast(1L)
onMs = snapToFrameMs(rawOnMs, frameMs)
offMs = snapToFrameMs(rawOffMs, frameMs)
val newOnMs = snapToFrameMs(newRawOnMs, frameMs)
val newOffMs = snapToFrameMs(newRawOffMs, frameMs)
val changed = newRawOnMs != rawOnMs || newRawOffMs != rawOffMs || newOnMs != onMs || newOffMs != offMs
if (!changed) return
rawOnMs = newRawOnMs
rawOffMs = newRawOffMs
onMs = newOnMs
offMs = newOffMs
if (isRunning) {
Log.d(
@@ -925,7 +934,14 @@ private class SplitFlashRenderView(context: Context) : View(context) {
}
if (isRunning && started) {
scheduleFromNow()
val now = SystemClock.uptimeMillis()
val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
nextToggleUptimeMs = maxOf(nextToggleUptimeMs, now + 1L)
val remaining = (nextToggleUptimeMs - now).coerceAtLeast(1L)
if (remaining > nextDuration) {
nextToggleUptimeMs = now + nextDuration
}
scheduleNext()
}
}
@@ -962,6 +978,16 @@ private class SplitFlashRenderView(context: Context) : View(context) {
invalidate()
}
fun setFlashSequence(sequence: List<solutions.tretter.mindmachine.session.FlashSequenceRow>) {
val normalized = sequence.ifEmpty { solutions.tretter.mindmachine.session.defaultFlashSequence() }
if (flashSequence == normalized) return
flashSequence = normalized
if (started) {
phase %= phaseCount()
}
invalidate()
}
override fun onDetachedFromWindow() {
handler.removeCallbacks(toggleRunnable)
super.onDetachedFromWindow()
@@ -981,24 +1007,35 @@ private class SplitFlashRenderView(context: Context) : View(context) {
handler.removeCallbacks(toggleRunnable)
handler.postAtTime(toggleRunnable, nextToggleUptimeMs)
}
private fun scheduleFromNow() {
val now = SystemClock.uptimeMillis()
nextToggleUptimeMs = now + if (phase == 0 || phase == 2) onMs else offMs
scheduleNext()
private fun colorsForPhase(phase: Int): Pair<Int, Int> {
if (!isVisiblePhase(phase)) return Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
if (monochrome) return Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE)
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
val left = paletteColor(row.leftColor)
val right = if (row.splitScreen) paletteColor(row.rightColor) else left
return Pair(left, right)
}
private fun colorsForPhase(phase: Int): Pair<Int, Int> = when (phase) {
0 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.RED, android.graphics.Color.GREEN)
2 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.GREEN, android.graphics.Color.RED)
else -> Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
private fun phaseName(phase: Int): String {
if (!isVisiblePhase(phase)) return "BLACK"
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
return if (row.splitScreen) {
"${row.leftColor.name}_${row.rightColor.name}"
} else {
row.leftColor.name
}
}
private fun phaseName(phase: Int): String = when (phase) {
0 -> "RED_GREEN"
1 -> "BLACK"
2 -> "GREEN_RED"
else -> "BLACK"
private fun isVisiblePhase(phase: Int): Boolean = phase % 2 == 0
private fun phaseCount(): Int = (flashSequence.size.coerceAtLeast(1)) * 2
private fun paletteColor(color: solutions.tretter.mindmachine.session.FlashPaletteColor): Int = when (color) {
solutions.tretter.mindmachine.session.FlashPaletteColor.BLACK -> android.graphics.Color.BLACK
solutions.tretter.mindmachine.session.FlashPaletteColor.RED -> android.graphics.Color.RED
solutions.tretter.mindmachine.session.FlashPaletteColor.GREEN -> android.graphics.Color.GREEN
solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW
solutions.tretter.mindmachine.session.FlashPaletteColor.BLUE -> android.graphics.Color.BLUE
}
private fun refreshRate(): Float = display?.refreshRate?.takeIf { it > 0f } ?: 60f

View File

@@ -10,6 +10,9 @@ import solutions.tretter.mindmachine.session.TimelinePoint
import solutions.tretter.mindmachine.session.TIMELINE_DEFAULT_GRANULARITY_SEC
import solutions.tretter.mindmachine.session.TimelineSelection
import solutions.tretter.mindmachine.session.TimelineViewport
import solutions.tretter.mindmachine.session.FlashPaletteColor
import solutions.tretter.mindmachine.session.FlashSequenceRow
import solutions.tretter.mindmachine.session.defaultFlashSequence
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.json.JSONArray
@@ -83,8 +86,10 @@ class UserProgramRepository(private val context: Context) {
return TimelineEditorState(
durationSec = o.getInt("durationSec"),
curveGranularitySec = TimelineEditorState.normalizeGranularitySec(
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC)
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC),
o.getInt("durationSec")
),
flashSequence = parseFlashSequence(o.optJSONArray("flashSequence")),
selection = TimelineSelection(
startSec = o.optInt("selectionStartSec", 0),
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
@@ -115,6 +120,7 @@ class UserProgramRepository(private val context: Context) {
val timeline = JSONObject()
timeline.put("durationSec", program.timeline.durationSec)
timeline.put("curveGranularitySec", program.timeline.curveGranularitySec)
timeline.put("flashSequence", flashSequenceArray(program.timeline.flashSequence))
timeline.put("selectionStartSec", program.timeline.selection.startSec)
timeline.put("selectionEndSec", program.timeline.selection.endSec)
timeline.put("viewportStartSec", program.timeline.viewport.startSec)
@@ -138,6 +144,35 @@ class UserProgramRepository(private val context: Context) {
}
return arr
}
private fun parseFlashSequence(arr: JSONArray?): List<FlashSequenceRow> {
if (arr == null || arr.length() == 0) return defaultFlashSequence()
return buildList {
for (i in 0 until arr.length()) {
val row = arr.getJSONObject(i)
add(
FlashSequenceRow(
splitScreen = row.optBoolean("splitScreen", true),
leftColor = row.optString("leftColor", FlashPaletteColor.RED.name).let(FlashPaletteColor::valueOf),
rightColor = row.optString("rightColor", FlashPaletteColor.GREEN.name).let(FlashPaletteColor::valueOf),
)
)
}
}.ifEmpty { defaultFlashSequence() }
}
private fun flashSequenceArray(sequence: List<FlashSequenceRow>): JSONArray {
val arr = JSONArray()
sequence.forEach { row ->
arr.put(
JSONObject()
.put("splitScreen", row.splitScreen)
.put("leftColor", row.leftColor.name)
.put("rightColor", row.rightColor.name)
)
}
return arr
}
}
data class UserProgramEntity(

View File

@@ -1,5 +1,8 @@
package solutions.tretter.mindmachine.domain
import solutions.tretter.mindmachine.session.FlashSequenceRow
import solutions.tretter.mindmachine.session.defaultFlashSequence
enum class VisualPattern { FLASH, PULSE }
enum class SessionMode { AUDIO_VISUAL, AUDIO_ONLY, VISUAL_ONLY }
enum class RuntimeState { IDLE, COUNTDOWN, RUNNING, PAUSED, INTERRUPTED, COMPLETED, STOPPED, ERROR }
@@ -17,6 +20,7 @@ data class SessionPreset(
val cautionNote: String,
val sortOrder: Int,
val isUserProgram: Boolean = false,
val flashSequence: List<FlashSequenceRow> = defaultFlashSequence(),
)
data class SessionConfig(
@@ -25,6 +29,7 @@ data class SessionConfig(
val mode: SessionMode,
val visualPatternType: VisualPattern,
val flashIntervalMs: Int,
val flashSequence: List<FlashSequenceRow> = defaultFlashSequence(),
val monochromeFlashMode: Boolean = false,
val intensityPercent: Int,
val carrierFrequencyHz: Float,
@@ -97,6 +102,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL)
mode = defaultMode,
visualPatternType = visualPatternType,
flashIntervalMs = flashIntervalMs,
flashSequence = flashSequence,
monochromeFlashMode = false,
intensityPercent = intensityPercent,
carrierFrequencyHz = carrierFrequencyHz,

View File

@@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
private const val MIN_DURATION_SEC = 60
private const val MAX_DURATION_SEC = 8 * 60 * 60
private const val MAX_DURATION_SEC = 60 * 60
@Composable
fun DurationSliderCard(
@@ -51,14 +51,15 @@ fun DurationSliderCard(
Slider(
value = clamped.toFloat(),
onValueChange = {
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60
val snapped = (it / 60f).roundToInt().coerceIn(1, 60) * 60
onDurationSecChanged(snapped)
},
valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(),
steps = 58,
)
Text(
"Range: 1 minute to 8 hours",
"Range: 1 minute to 60 minutes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)

View File

@@ -0,0 +1,19 @@
package solutions.tretter.mindmachine.session
enum class FlashPaletteColor {
BLACK,
RED,
GREEN,
YELLOW,
BLUE,
}
data class FlashSequenceRow(
val splitScreen: Boolean = true,
val leftColor: FlashPaletteColor = FlashPaletteColor.RED,
val rightColor: FlashPaletteColor = FlashPaletteColor.GREEN,
)
fun defaultFlashSequence(): List<FlashSequenceRow> = listOf(
FlashSequenceRow(splitScreen = true, leftColor = FlashPaletteColor.RED, rightColor = FlashPaletteColor.GREEN)
)

View File

@@ -0,0 +1,142 @@
package solutions.tretter.mindmachine.session
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun FlashSequenceEditor(
sequence: List<FlashSequenceRow>,
onSequenceChanged: (List<FlashSequenceRow>) -> Unit,
modifier: Modifier = Modifier,
) {
val rows = sequence.ifEmpty { defaultFlashSequence() }
Column(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Color sequence", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
Text(
"Each row is one visible color state. Split screen uses separate left/right colors. Non-split rows fill the whole screen with one color.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
)
rows.forEachIndexed { index, row ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Checkbox(
checked = row.splitScreen,
onCheckedChange = { checked ->
onSequenceChanged(rows.toMutableList().also { it[index] = row.copy(splitScreen = checked) })
}
)
Text("Split screen", color = MaterialTheme.colorScheme.onSurface)
}
ColorCycleButton(
label = if (row.splitScreen) "Left" else "Color",
color = row.leftColor,
onClick = {
onSequenceChanged(rows.toMutableList().also {
it[index] = row.copy(leftColor = row.leftColor.next())
})
},
modifier = Modifier.weight(1f),
)
if (row.splitScreen) {
ColorCycleButton(
label = "Right",
color = row.rightColor,
onClick = {
onSequenceChanged(rows.toMutableList().also {
it[index] = row.copy(rightColor = row.rightColor.next())
})
},
modifier = Modifier.weight(1f),
)
} else {
Box(modifier = Modifier.weight(1f))
}
IconButton(
onClick = {
onSequenceChanged(rows.filterIndexed { i, _ -> i != index }.ifEmpty { defaultFlashSequence() })
}
) {
Icon(Icons.Default.Delete, contentDescription = "Remove row", tint = MaterialTheme.colorScheme.onSurface)
}
}
}
OutlinedButton(
onClick = { onSequenceChanged(rows + FlashSequenceRow()) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Add, contentDescription = null)
Text(" Add color row")
}
}
}
@Composable
private fun ColorCycleButton(
label: String,
color: FlashPaletteColor,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
OutlinedButton(onClick = onClick, modifier = modifier) {
Box(
modifier = Modifier
.padding(end = 8.dp)
.background(color.previewColor(), RoundedCornerShape(6.dp))
.padding(horizontal = 10.dp, vertical = 10.dp)
)
Text("$label: ${color.displayName}")
}
}
private val FlashPaletteColor.displayName: String
get() = name.lowercase().replaceFirstChar { it.uppercase() }
private fun FlashPaletteColor.next(): FlashPaletteColor {
val values = FlashPaletteColor.entries
return values[(ordinal + 1) % values.size]
}
private fun FlashPaletteColor.previewColor(): Color = when (this) {
FlashPaletteColor.BLACK -> Color.Black
FlashPaletteColor.RED -> Color(0xFFD32F2F)
FlashPaletteColor.GREEN -> Color(0xFF2E7D32)
FlashPaletteColor.YELLOW -> Color(0xFFF9A825)
FlashPaletteColor.BLUE -> Color(0xFF1565C0)
}

View File

@@ -175,7 +175,7 @@ class MainViewModel(
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = preset,
config = preset.toConfig(SessionMode.AUDIO_VISUAL),
config = preset.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
timeline = timeline,
savedProgramName = displayName,
savedTimelineSignature = savedSignature,
@@ -237,7 +237,7 @@ class MainViewModel(
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = fallback,
config = fallback.toConfig(SessionMode.AUDIO_VISUAL),
config = fallback.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
timeline = timeline,
savedProgramName = fallback.name,
savedTimelineSignature = savedSignature,
@@ -251,7 +251,7 @@ class MainViewModel(
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
fun setDurationSec(seconds: Int) = _ui.update {
val clamped = seconds.coerceIn(60, 8 * 60 * 60)
val clamped = seconds.coerceIn(60, 60 * 60)
val nextTimeline = it.timeline.setDurationSec(clamped)
it.copy(
config = it.config.copy(durationSec = clamped),
@@ -273,7 +273,10 @@ class MainViewModel(
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
it.copy(
timeline = newState,
config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)),
config = it.config.copy(
durationSec = newState.durationSec.coerceIn(60, 60 * 60),
flashSequence = newState.flashSequence,
),
hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
error = null,
)
@@ -451,7 +454,10 @@ class MainViewModel(
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = preset,
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
config = cfg.copy(
durationSec = cfg.durationSec.coerceIn(60, 60 * 60),
flashSequence = timeline.flashSequence,
),
timeline = timeline,
savedProgramName = preset.name,
savedTimelineSignature = savedSignature,

View File

@@ -3,7 +3,7 @@ package solutions.tretter.mindmachine.session
import solutions.tretter.mindmachine.domain.SessionConfig
object SessionValidator {
fun validate(config: SessionConfig): String? {
if (config.durationSec !in 60..(8 * 60 * 60)) return "Duration must be 1 minute to 8 hours."
if (config.durationSec !in 60..(60 * 60)) return "Duration must be 1 minute to 60 minutes."
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds."
val carrier = config.carrierFrequencyHz
if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) {

View File

@@ -27,6 +27,9 @@ fun SetupTimelineEditor(
modifier: Modifier = Modifier,
) {
var local by remember(state) { mutableStateOf(state) }
val allowedGranularity = remember(local.durationSec) { TimelineEditorState.allowedGranularitySteps(local.durationSec) }
val sliderMin = allowedGranularity.first().toFloat()
val sliderMax = allowedGranularity.last().toFloat()
Column(
modifier = modifier.fillMaxWidth(),
@@ -38,7 +41,7 @@ fun SetupTimelineEditor(
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"Adjust curve granularity, then 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. Granularity always divides the full duration evenly.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
)
@@ -49,22 +52,22 @@ fun SetupTimelineEditor(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
)
var granularitySliderValue by remember(state.curveGranularitySec) {
var granularitySliderValue by remember(state.curveGranularitySec, state.durationSec) {
mutableFloatStateOf(state.curveGranularitySec.toFloat())
}
Slider(
value = granularitySliderValue,
value = granularitySliderValue.coerceIn(sliderMin, sliderMax),
onValueChange = { granularitySliderValue = it },
onValueChangeFinished = {
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt())
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt(), local.durationSec)
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,
valueRange = sliderMin..sliderMax,
steps = max(0, allowedGranularity.size - 2),
)
TimelineGraph(

View File

@@ -1,17 +1,19 @@
package solutions.tretter.mindmachine.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
const val TIMELINE_MIN_GRANULARITY_SEC = 5
const val TIMELINE_MAX_GRANULARITY_SEC = 300
private const val MAX_DURATION_SEC = 60 * 60
const val TIMELINE_MIN_GRANULARITY_SEC = 30
const val TIMELINE_MAX_GRANULARITY_SEC = 30 * 60
const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60
data class TimelineEditorState(
val durationSec: Int,
val curveGranularitySec: Int,
val flashSequence: List<FlashSequenceRow>,
val selection: TimelineSelection,
val viewport: TimelineViewport,
val activeCurve: ActiveCurve,
@@ -32,11 +34,12 @@ data class TimelineEditorState(
companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val dur = snapDuration(durationSec)
val granularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC
val granularitySec = normalizeGranularitySec(TIMELINE_DEFAULT_GRANULARITY_SEC, dur)
val initial = TimelineCurve.constant(0.5f, dur, granularitySec)
return TimelineEditorState(
durationSec = dur,
curveGranularitySec = granularitySec,
flashSequence = defaultFlashSequence(),
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(
startSec = 0f,
@@ -52,11 +55,21 @@ data class TimelineEditorState(
)
}
private fun snapDuration(durationSec: Int): Int =
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 allowedGranularitySteps(durationSec: Int): List<Int> {
val duration = snapDuration(durationSec)
val maxStep = min(TIMELINE_MAX_GRANULARITY_SEC, duration)
return (TIMELINE_MIN_GRANULARITY_SEC..maxStep step TIMELINE_MIN_GRANULARITY_SEC)
.filter { duration % it == 0 }
.ifEmpty { listOf(duration) }
}
fun normalizeGranularitySec(granularitySec: Int, durationSec: Int): Int {
val allowed = allowedGranularitySteps(durationSec)
return allowed.minByOrNull { abs(it - granularitySec) } ?: allowed.first()
}
}
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
@@ -76,35 +89,45 @@ data class TimelineEditorState(
}
}
fun withFlashSequence(sequence: List<FlashSequenceRow>): TimelineEditorState =
copy(flashSequence = sequence.ifEmpty { defaultFlashSequence() })
fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
val newDur = ((atLeastSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
val newDur = snapDuration(atLeastSec)
if (newDur <= durationSec) return this
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, newDur)
return copy(
durationSec = newDur,
curveGranularitySec = normalizedGranularity,
selection = selection.copy(endSec = max(selection.endSec, newDur)),
visualLeft = visualLeft.extendTo(newDur, curveGranularitySec),
visualRight = visualRight.extendTo(newDur, curveGranularitySec),
audioLeft = audioLeft.extendTo(newDur, curveGranularitySec),
audioRight = audioRight.extendTo(newDur, curveGranularitySec),
visualLeft = visualLeft.extendTo(newDur, normalizedGranularity),
visualRight = visualRight.extendTo(newDur, normalizedGranularity),
audioLeft = audioLeft.extendTo(newDur, normalizedGranularity),
audioRight = audioRight.extendTo(newDur, normalizedGranularity),
)
}
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
val clamped = ((newDurationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
val clamped = snapDuration(newDurationSec)
if (clamped == durationSec) return this
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, clamped)
return if (clamped > durationSec) {
extendDurationTo(clamped).copy(selection = selection.copy(startSec = 0, endSec = clamped))
extendDurationTo(clamped).copy(
curveGranularitySec = normalizedGranularity,
selection = selection.copy(startSec = 0, endSec = clamped),
)
} else {
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
copy(
durationSec = clamped,
curveGranularitySec = normalizedGranularity,
selection = TimelineSelection(0, clamped),
visualLeft = visualLeft.trimTo(clamped, curveGranularitySec),
visualRight = visualRight.trimTo(clamped, curveGranularitySec),
audioLeft = audioLeft.trimTo(clamped, curveGranularitySec),
audioRight = audioRight.trimTo(clamped, curveGranularitySec),
visualLeft = visualLeft.trimTo(clamped, normalizedGranularity),
visualRight = visualRight.trimTo(clamped, normalizedGranularity),
audioLeft = audioLeft.trimTo(clamped, normalizedGranularity),
audioRight = audioRight.trimTo(clamped, normalizedGranularity),
viewport = newViewport,
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
)
@@ -112,7 +135,7 @@ data class TimelineEditorState(
}
fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState {
val normalized = normalizeGranularitySec(newGranularitySec)
val normalized = normalizeGranularitySec(newGranularitySec, durationSec)
if (normalized == curveGranularitySec) return this
return copy(
@@ -191,7 +214,7 @@ data class TimelineCurve(
companion object {
fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): TimelineCurve {
val v = value01.coerceIn(0f, 1f)
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, durationSec)
return TimelineCurve(
points = (0..durationSec step stepSec).map { TimelinePoint(it, v) }
)
@@ -214,8 +237,8 @@ data class TimelineCurve(
}
fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve {
val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
val snappedDuration = TimelineEditorState.snapDuration(durationSec)
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, snappedDuration)
val normalized = (0..snappedDuration step stepSec).map { tSec ->
TimelinePoint(tSec, valueAt(tSec.toFloat()))
}
@@ -236,7 +259,6 @@ data class TimelineCurve(
if (index !in points.indices) return this
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList()
// 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)
}

View File

@@ -84,6 +84,7 @@ data class TimelineModel(
mode = mode,
visualPatternType = preset.visualPatternType,
flashIntervalMs = flashIntervalMs,
flashSequence = preset.flashSequence,
monochromeFlashMode = false,
intensityPercent = preset.intensityPercent,
carrierFrequencyHz = carrier,

View File

@@ -48,6 +48,7 @@ object TimelineProgramFactory {
cautionNote = "Stop if discomfort occurs.",
sortOrder = sortOrder,
isUserProgram = isUserProgram,
flashSequence = timeline.flashSequence,
)
}
@@ -74,6 +75,7 @@ object TimelineProgramFactory {
return TimelineEditorState(
durationSec = dur,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
flashSequence = preset.flashSequence,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
@@ -124,6 +126,7 @@ object TimelineProgramFactory {
return TimelineEditorState(
durationSec = durationSec,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
flashSequence = defaultFlashSequence(),
selection = TimelineSelection(0, durationSec),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,