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