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.activity.viewModels
import androidx.compose.foundation.background import androidx.compose.foundation.background
import solutions.tretter.mindmachine.session.DurationSliderCard import solutions.tretter.mindmachine.session.DurationSliderCard
import solutions.tretter.mindmachine.session.FlashSequenceEditor
import solutions.tretter.mindmachine.session.SetupTimelineEditor import solutions.tretter.mindmachine.session.SetupTimelineEditor
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -519,13 +520,10 @@ fun SetupScreen(
onCurveGranularityChanged = vm::setCurveGranularitySec, onCurveGranularityChanged = vm::setCurveGranularitySec,
) )
Row(verticalAlignment = Alignment.CenterVertically) { FlashSequenceEditor(
Checkbox( sequence = ui.timeline.flashSequence,
checked = ui.config.monochromeFlashMode, onSequenceChanged = { sequence -> vm.updateTimeline(ui.timeline.withFlashSequence(sequence)) },
onCheckedChange = vm::setMonochromeFlashMode,
) )
Text("Use black/white flashes for this session", color = MaterialTheme.colorScheme.onBackground)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
@@ -653,11 +651,13 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
SplitFlashRenderView(ctx).apply { SplitFlashRenderView(ctx).apply {
setRunning(ui.runtimeState == RuntimeState.RUNNING) setRunning(ui.runtimeState == RuntimeState.RUNNING)
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
setFlashSequence(ui.config.flashSequence)
setMonochrome(ui.config.monochromeFlashMode) setMonochrome(ui.config.monochromeFlashMode)
} }
}, },
update = { view -> update = { view ->
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
view.setFlashSequence(ui.config.flashSequence)
view.setMonochrome(ui.config.monochromeFlashMode) view.setMonochrome(ui.config.monochromeFlashMode)
view.setRunning(ui.runtimeState == RuntimeState.RUNNING) view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
} }
@@ -868,6 +868,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
private var onMs = 167L private var onMs = 167L
private var offMs = 167L private var offMs = 167L
private var monochrome = false private var monochrome = false
private var flashSequence = solutions.tretter.mindmachine.session.defaultFlashSequence()
private var started = false private var started = false
private var startUptimeMs = 0L private var startUptimeMs = 0L
private var nextToggleUptimeMs = 0L private var nextToggleUptimeMs = 0L
@@ -888,11 +889,11 @@ private class SplitFlashRenderView(context: Context) : View(context) {
maxLatenessMs = maxOf(maxLatenessMs, latenessMs) maxLatenessMs = maxOf(maxLatenessMs, latenessMs)
} }
phase = (phase + 1) % 4 phase = (phase + 1) % phaseCount()
toggleCount += 1 toggleCount += 1
invalidate() invalidate()
val nextDuration = if (phase == 0 || phase == 2) onMs else offMs val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
nextToggleUptimeMs += nextDuration nextToggleUptimeMs += nextDuration
val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs() || toggleCount % 60L == 0L 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) { fun updateIntervals(flashOnMs: Int, flashOffMs: Int) {
rawOnMs = flashOnMs.coerceIn(50, 2000).toLong() val newRawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
rawOffMs = flashOffMs.coerceIn(50, 2000).toLong() val newRawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
val frameMs = framePeriodMs().coerceAtLeast(1L) val frameMs = framePeriodMs().coerceAtLeast(1L)
onMs = snapToFrameMs(rawOnMs, frameMs) val newOnMs = snapToFrameMs(newRawOnMs, frameMs)
offMs = snapToFrameMs(rawOffMs, 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) { if (isRunning) {
Log.d( Log.d(
@@ -925,7 +934,14 @@ private class SplitFlashRenderView(context: Context) : View(context) {
} }
if (isRunning && started) { 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() 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() { override fun onDetachedFromWindow() {
handler.removeCallbacks(toggleRunnable) handler.removeCallbacks(toggleRunnable)
super.onDetachedFromWindow() super.onDetachedFromWindow()
@@ -981,24 +1007,35 @@ private class SplitFlashRenderView(context: Context) : View(context) {
handler.removeCallbacks(toggleRunnable) handler.removeCallbacks(toggleRunnable)
handler.postAtTime(toggleRunnable, nextToggleUptimeMs) handler.postAtTime(toggleRunnable, nextToggleUptimeMs)
} }
private fun colorsForPhase(phase: Int): Pair<Int, Int> {
private fun scheduleFromNow() { if (!isVisiblePhase(phase)) return Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
val now = SystemClock.uptimeMillis() if (monochrome) return Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE)
nextToggleUptimeMs = now + if (phase == 0 || phase == 2) onMs else offMs val row = flashSequence[(phase / 2).mod(flashSequence.size)]
scheduleNext() 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) { private fun phaseName(phase: Int): String {
0 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.RED, android.graphics.Color.GREEN) if (!isVisiblePhase(phase)) return "BLACK"
2 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.GREEN, android.graphics.Color.RED) val row = flashSequence[(phase / 2).mod(flashSequence.size)]
else -> Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK) return if (row.splitScreen) {
"${row.leftColor.name}_${row.rightColor.name}"
} else {
row.leftColor.name
}
} }
private fun phaseName(phase: Int): String = when (phase) { private fun isVisiblePhase(phase: Int): Boolean = phase % 2 == 0
0 -> "RED_GREEN"
1 -> "BLACK" private fun phaseCount(): Int = (flashSequence.size.coerceAtLeast(1)) * 2
2 -> "GREEN_RED"
else -> "BLACK" 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 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.TIMELINE_DEFAULT_GRANULARITY_SEC
import solutions.tretter.mindmachine.session.TimelineSelection import solutions.tretter.mindmachine.session.TimelineSelection
import solutions.tretter.mindmachine.session.TimelineViewport 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.json.JSONArray import org.json.JSONArray
@@ -83,8 +86,10 @@ class UserProgramRepository(private val context: Context) {
return TimelineEditorState( return TimelineEditorState(
durationSec = o.getInt("durationSec"), durationSec = o.getInt("durationSec"),
curveGranularitySec = TimelineEditorState.normalizeGranularitySec( 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( 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")),
@@ -115,6 +120,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("curveGranularitySec", program.timeline.curveGranularitySec)
timeline.put("flashSequence", flashSequenceArray(program.timeline.flashSequence))
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)
@@ -138,6 +144,35 @@ class UserProgramRepository(private val context: Context) {
} }
return arr 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( data class UserProgramEntity(

View File

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

View File

@@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt import kotlin.math.roundToInt
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 = 60 * 60
@Composable @Composable
fun DurationSliderCard( fun DurationSliderCard(
@@ -51,14 +51,15 @@ fun DurationSliderCard(
Slider( Slider(
value = clamped.toFloat(), value = clamped.toFloat(),
onValueChange = { onValueChange = {
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60 val snapped = (it / 60f).roundToInt().coerceIn(1, 60) * 60
onDurationSecChanged(snapped) onDurationSecChanged(snapped)
}, },
valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(), valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(),
steps = 58,
) )
Text( Text(
"Range: 1 minute to 8 hours", "Range: 1 minute to 60 minutes",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), 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) val savedSignature = timelineProgramSignature(timeline)
it.copy( it.copy(
selectedPreset = preset, selectedPreset = preset,
config = preset.toConfig(SessionMode.AUDIO_VISUAL), config = preset.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
timeline = timeline, timeline = timeline,
savedProgramName = displayName, savedProgramName = displayName,
savedTimelineSignature = savedSignature, savedTimelineSignature = savedSignature,
@@ -237,7 +237,7 @@ class MainViewModel(
val savedSignature = timelineProgramSignature(timeline) val savedSignature = timelineProgramSignature(timeline)
it.copy( it.copy(
selectedPreset = fallback, selectedPreset = fallback,
config = fallback.toConfig(SessionMode.AUDIO_VISUAL), config = fallback.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
timeline = timeline, timeline = timeline,
savedProgramName = fallback.name, savedProgramName = fallback.name,
savedTimelineSignature = savedSignature, 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 setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
fun setDurationSec(seconds: Int) = _ui.update { 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) val nextTimeline = it.timeline.setDurationSec(clamped)
it.copy( it.copy(
config = it.config.copy(durationSec = clamped), config = it.config.copy(durationSec = clamped),
@@ -273,7 +273,10 @@ class MainViewModel(
fun updateTimeline(newState: TimelineEditorState) = _ui.update { fun updateTimeline(newState: TimelineEditorState) = _ui.update {
it.copy( it.copy(
timeline = newState, 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), hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
error = null, error = null,
) )
@@ -451,7 +454,10 @@ class MainViewModel(
val savedSignature = timelineProgramSignature(timeline) val savedSignature = timelineProgramSignature(timeline)
it.copy( it.copy(
selectedPreset = preset, 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, timeline = timeline,
savedProgramName = preset.name, savedProgramName = preset.name,
savedTimelineSignature = savedSignature, savedTimelineSignature = savedSignature,

View File

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

View File

@@ -27,6 +27,9 @@ fun SetupTimelineEditor(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var local by remember(state) { mutableStateOf(state) } 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( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -38,7 +41,7 @@ fun SetupTimelineEditor(
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
) )
@@ -49,22 +52,22 @@ fun SetupTimelineEditor(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
var granularitySliderValue by remember(state.curveGranularitySec) { var granularitySliderValue by remember(state.curveGranularitySec, state.durationSec) {
mutableFloatStateOf(state.curveGranularitySec.toFloat()) mutableFloatStateOf(state.curveGranularitySec.toFloat())
} }
Slider( Slider(
value = granularitySliderValue, value = granularitySliderValue.coerceIn(sliderMin, sliderMax),
onValueChange = { granularitySliderValue = it }, onValueChange = { granularitySliderValue = it },
onValueChangeFinished = { onValueChangeFinished = {
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt()) val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt(), local.durationSec)
if (snapped != local.curveGranularitySec) { if (snapped != local.curveGranularitySec) {
local = local.setCurveGranularitySec(snapped).also(onStateChanged) local = local.setCurveGranularitySec(snapped).also(onStateChanged)
onCurveGranularityChanged(snapped) onCurveGranularityChanged(snapped)
} }
granularitySliderValue = local.curveGranularitySec.toFloat() granularitySliderValue = local.curveGranularitySec.toFloat()
}, },
valueRange = TIMELINE_MIN_GRANULARITY_SEC.toFloat()..TIMELINE_MAX_GRANULARITY_SEC.toFloat(), valueRange = sliderMin..sliderMax,
steps = ((TIMELINE_MAX_GRANULARITY_SEC - TIMELINE_MIN_GRANULARITY_SEC) / 5) - 1, steps = max(0, allowedGranularity.size - 2),
) )
TimelineGraph( TimelineGraph(

View File

@@ -1,17 +1,19 @@
package solutions.tretter.mindmachine.session package solutions.tretter.mindmachine.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 = 60 * 60
const val TIMELINE_MIN_GRANULARITY_SEC = 5 const val TIMELINE_MIN_GRANULARITY_SEC = 30
const val TIMELINE_MAX_GRANULARITY_SEC = 300 const val TIMELINE_MAX_GRANULARITY_SEC = 30 * 60
const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60 const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60
data class TimelineEditorState( data class TimelineEditorState(
val durationSec: Int, val durationSec: Int,
val curveGranularitySec: Int, val curveGranularitySec: Int,
val flashSequence: List<FlashSequenceRow>,
val selection: TimelineSelection, val selection: TimelineSelection,
val viewport: TimelineViewport, val viewport: TimelineViewport,
val activeCurve: ActiveCurve, val activeCurve: ActiveCurve,
@@ -32,11 +34,12 @@ 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 granularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC val granularitySec = normalizeGranularitySec(TIMELINE_DEFAULT_GRANULARITY_SEC, dur)
val initial = TimelineCurve.constant(0.5f, dur, granularitySec) val initial = TimelineCurve.constant(0.5f, dur, granularitySec)
return TimelineEditorState( return TimelineEditorState(
durationSec = dur, durationSec = dur,
curveGranularitySec = granularitySec, curveGranularitySec = granularitySec,
flashSequence = defaultFlashSequence(),
selection = TimelineSelection(0, dur), selection = TimelineSelection(0, dur),
viewport = TimelineViewport( viewport = TimelineViewport(
startSec = 0f, 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 ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
fun normalizeGranularitySec(granularitySec: Int): Int = fun allowedGranularitySteps(durationSec: Int): List<Int> {
((granularitySec.coerceIn(TIMELINE_MIN_GRANULARITY_SEC, TIMELINE_MAX_GRANULARITY_SEC) + 2) / 5) * 5 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) { 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 { 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 if (newDur <= durationSec) return this
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, newDur)
return copy( return copy(
durationSec = newDur, durationSec = newDur,
curveGranularitySec = normalizedGranularity,
selection = selection.copy(endSec = max(selection.endSec, newDur)), selection = selection.copy(endSec = max(selection.endSec, newDur)),
visualLeft = visualLeft.extendTo(newDur, curveGranularitySec), visualLeft = visualLeft.extendTo(newDur, normalizedGranularity),
visualRight = visualRight.extendTo(newDur, curveGranularitySec), visualRight = visualRight.extendTo(newDur, normalizedGranularity),
audioLeft = audioLeft.extendTo(newDur, curveGranularitySec), audioLeft = audioLeft.extendTo(newDur, normalizedGranularity),
audioRight = audioRight.extendTo(newDur, curveGranularitySec), audioRight = audioRight.extendTo(newDur, normalizedGranularity),
) )
} }
fun setDurationSec(newDurationSec: Int): TimelineEditorState { 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 if (clamped == durationSec) return this
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, clamped)
return if (clamped > durationSec) { 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 { } else {
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat())) val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
copy( copy(
durationSec = clamped, durationSec = clamped,
curveGranularitySec = normalizedGranularity,
selection = TimelineSelection(0, clamped), selection = TimelineSelection(0, clamped),
visualLeft = visualLeft.trimTo(clamped, curveGranularitySec), visualLeft = visualLeft.trimTo(clamped, normalizedGranularity),
visualRight = visualRight.trimTo(clamped, curveGranularitySec), visualRight = visualRight.trimTo(clamped, normalizedGranularity),
audioLeft = audioLeft.trimTo(clamped, curveGranularitySec), audioLeft = audioLeft.trimTo(clamped, normalizedGranularity),
audioRight = audioRight.trimTo(clamped, curveGranularitySec), audioRight = audioRight.trimTo(clamped, normalizedGranularity),
viewport = newViewport, viewport = newViewport,
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()), playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
) )
@@ -112,7 +135,7 @@ data class TimelineEditorState(
} }
fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState { fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState {
val normalized = normalizeGranularitySec(newGranularitySec) val normalized = normalizeGranularitySec(newGranularitySec, durationSec)
if (normalized == curveGranularitySec) return this if (normalized == curveGranularitySec) return this
return copy( return copy(
@@ -191,7 +214,7 @@ data class TimelineCurve(
companion object { companion object {
fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): 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) val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, durationSec)
return TimelineCurve( return TimelineCurve(
points = (0..durationSec step stepSec).map { TimelinePoint(it, v) } points = (0..durationSec step stepSec).map { TimelinePoint(it, v) }
) )
@@ -214,8 +237,8 @@ data class TimelineCurve(
} }
fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve { fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve {
val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60 val snappedDuration = TimelineEditorState.snapDuration(durationSec)
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec) val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, snappedDuration)
val normalized = (0..snappedDuration step stepSec).map { tSec -> val normalized = (0..snappedDuration step stepSec).map { tSec ->
TimelinePoint(tSec, valueAt(tSec.toFloat())) TimelinePoint(tSec, valueAt(tSec.toFloat()))
} }
@@ -236,7 +259,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()
// 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)
} }

View File

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

View File

@@ -48,6 +48,7 @@ object TimelineProgramFactory {
cautionNote = "Stop if discomfort occurs.", cautionNote = "Stop if discomfort occurs.",
sortOrder = sortOrder, sortOrder = sortOrder,
isUserProgram = isUserProgram, isUserProgram = isUserProgram,
flashSequence = timeline.flashSequence,
) )
} }
@@ -74,6 +75,7 @@ object TimelineProgramFactory {
return TimelineEditorState( return TimelineEditorState(
durationSec = dur, durationSec = dur,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC, curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
flashSequence = preset.flashSequence,
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,
@@ -124,6 +126,7 @@ object TimelineProgramFactory {
return TimelineEditorState( return TimelineEditorState(
durationSec = durationSec, durationSec = durationSec,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC, curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
flashSequence = defaultFlashSequence(),
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,