Auto commit Thu Mar 19 08:01:01 PM CDT 2026

This commit is contained in:
Tretzi
2026-03-19 20:01:01 -05:00
parent c7518b7e9c
commit 3846f97fb2
17 changed files with 497 additions and 156 deletions

View File

@@ -0,0 +1,13 @@
package com.mindmachine.mvp.session
fun formatDuration(totalSec: Int): String {
val sec = totalSec.coerceAtLeast(0)
val h = sec / 3600
val m = (sec % 3600) / 60
val s = sec % 60
return when {
h > 0 -> String.format("%dh %02dm", h, m)
m > 0 -> String.format("%dm %02ds", m, s)
else -> String.format("%ds", s)
}
}

View File

@@ -0,0 +1,67 @@
package com.mindmachine.mvp.session
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
@Composable
fun DurationSliderCard(
durationSec: Int,
onDurationSecChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val clamped = remember(durationSec) { durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) }
Column(
modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Duration", color = MaterialTheme.colorScheme.onSurface)
Text(
formatDuration(clamped),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Slider(
value = clamped.toFloat(),
onValueChange = {
// Keep it sane: snap to whole minutes for UX.
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60
onDurationSecChanged(snapped)
},
valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(),
)
Text(
"1 min  8 hours",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
}

View File

@@ -29,12 +29,19 @@ data class UiState(
val presets: List<SessionPreset> = Presets.builtIn,
val selectedPreset: SessionPreset = Presets.builtIn.first(),
val config: SessionConfig = Presets.builtIn.first().toConfig(),
// Timeline program (curves) used for both editing + runtime modulation.
val timeline: TimelineEditorState = TimelineProgramFactory.fromPreset(Presets.builtIn.first()),
val runtimeState: RuntimeState = RuntimeState.IDLE,
val error: String? = null,
val remainingSec: Int = 0,
val countdownSec: Int = 0,
val endedEarly: Boolean = false,
val interruptionReason: String? = null,
// Current runtime-evaluated parameters (updated while running).
val runtimeParams: RuntimeParams = RuntimeParams(),
)
class MainViewModel(
@@ -62,11 +69,37 @@ class MainViewModel(
fun choosePreset(id: String) = viewModelScope.launch {
val preset = _ui.value.presets.first { it.id == id }
settingsRepository.updateLastPreset(id)
_ui.update { it.copy(selectedPreset = preset, config = preset.toConfig(it.settings.defaultModePreference), error = null) }
_ui.update {
val cfg = preset.toConfig(it.settings.defaultModePreference)
it.copy(
selectedPreset = preset,
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
timeline = TimelineProgramFactory.fromPreset(preset),
error = null,
)
}
}
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
fun setDurationMin(min: Int) = _ui.update { it.copy(config = it.config.copy(durationSec = (min.coerceIn(1, 30) * 60)), error = null) }
fun setDurationSec(seconds: Int) = _ui.update {
val clamped = seconds.coerceIn(60, 8 * 60 * 60)
it.copy(
config = it.config.copy(durationSec = clamped),
timeline = it.timeline.setDurationSec(clamped),
error = null,
)
}
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
it.copy(
timeline = newState,
config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)),
error = null,
)
}
// Legacy fixed-parameter setters kept for now but no longer used by Setup.
fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) }
fun setCarrier(value: Float) = _ui.update { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), error = null) }
fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }
@@ -96,21 +129,54 @@ class MainViewModel(
delay(1000)
}
}
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, remainingSec = state.config.durationSec, countdownSec = 0, error = null) }
if (state.config.mode != SessionMode.VISUAL_ONLY) {
audioEngine.start(state.config.carrierFrequencyHz, state.config.binauralDifferenceHz)
// Use the timeline curves as the actual runtime program.
val program = _ui.value.timeline
val durationSec = program.durationSec
_ui.update {
it.copy(
runtimeState = RuntimeState.RUNNING,
remainingSec = durationSec,
countdownSec = 0,
error = null,
runtimeParams = TimelineRuntimeEvaluator.evaluate(program, 0f),
)
}
var remaining = state.config.durationSec
while (remaining > 0) {
delay(1000)
if (state.config.mode != SessionMode.VISUAL_ONLY) {
val p0 = TimelineRuntimeEvaluator.evaluate(program, 0f)
audioEngine.start(p0.carrierHz, p0.binauralHz)
}
val tStart = System.currentTimeMillis()
var lastWholeSec = durationSec
while (true) {
delay(50)
val elapsedSec = (System.currentTimeMillis() - tStart) / 1000f
val remaining = (durationSec - elapsedSec.toInt()).coerceAtLeast(0)
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
return@launch
}
remaining -= 1
_ui.update { it.copy(remainingSec = remaining) }
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedSec)
_ui.update {
val nextRemaining = if (remaining != lastWholeSec) remaining else it.remainingSec
it.copy(runtimeParams = params, remainingSec = nextRemaining)
}
lastWholeSec = remaining
if (state.config.mode != SessionMode.VISUAL_ONLY) {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
}
if (elapsedSec >= durationSec.toFloat()) break
}
audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
}
@@ -137,7 +203,8 @@ class MainViewModel(
}
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, countdownSec = 0) }
if (state.config.mode != SessionMode.VISUAL_ONLY) {
audioEngine.start(state.config.carrierFrequencyHz, state.config.binauralDifferenceHz)
val p = _ui.value.runtimeParams
audioEngine.start(p.carrierHz, p.binauralHz)
}
var remaining = state.remainingSec
while (remaining > 0) {

View File

@@ -0,0 +1,20 @@
package com.mindmachine.mvp.session
import kotlin.math.ln
import kotlin.math.pow
object ParameterMapping {
fun logMap01(value01: Float, min: Float, max: Float): Float {
val v = value01.coerceIn(0f, 1f)
val a = ln(min)
val b = ln(max)
return kotlin.math.exp(a + (b - a) * v)
}
fun invLog01(value: Float, min: Float, max: Float): Float {
val v = value.coerceIn(min, max)
val a = ln(min)
val b = ln(max)
return ((ln(v) - a) / (b - a)).toFloat().coerceIn(0f, 1f)
}
}

View File

@@ -0,0 +1,17 @@
package com.mindmachine.mvp.session
object ParameterRanges {
const val FLASH_INTERVAL_MS_MIN = 50f
const val FLASH_INTERVAL_MS_MAX = 2000f
// New per spec.
const val BLANK_INTERVAL_MS_MIN = 50f
const val BLANK_INTERVAL_MS_MAX = 2000f
// Spec ranges.
const val CARRIER_HZ_MIN = 200f
const val CARRIER_HZ_MAX = 1200f
const val BINAURAL_HZ_MIN = 0.5f
const val BINAURAL_HZ_MAX = 30f
}

View File

@@ -0,0 +1,11 @@
package com.mindmachine.mvp.session
/**
* Concrete runtime parameters evaluated from the timeline curves at a specific time.
*/
data class RuntimeParams(
val flashOnMs: Int = 167,
val flashOffMs: Int = 167,
val carrierHz: Float = 200f,
val binauralHz: Float = 6f,
)

View File

@@ -5,7 +5,7 @@ import com.mindmachine.mvp.domain.SessionMode
object SessionValidator {
fun validate(config: SessionConfig, headsetAvailable: Boolean): String? {
if (config.durationSec !in 60..(30 * 60)) return "Duration must be 1 to 30 minutes."
if (config.durationSec !in 60..(8 * 60 * 60)) return "Duration must be 1 minute to 8 hours."
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds."
if (config.carrierFrequencyHz !in 80f..400f) return "Carrier frequency must be between 80 and 400."
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20."

View File

@@ -0,0 +1,92 @@
package com.mindmachine.mvp.session
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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
@Composable
fun SetupTimelineEditor(
state: TimelineEditorState,
onStateChanged: (TimelineEditorState) -> Unit,
modifier: Modifier = Modifier,
) {
var local by remember(state) { mutableStateOf(state) }
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Program curves",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"Visual: Flash Interval (L) / Blank Interval (R). Audio: Carrier (L) / Binaural (R).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
)
TimelineGraph(
title = "Visual",
leftLabel = "Flash Interval",
rightLabel = "Blank Interval",
curveLeft = local.visualLeft,
curveRight = local.visualRight,
activeCurve = local.activeCurve,
viewport = local.viewport,
selection = local.selection,
playheadSec = local.playheadSec,
onViewportChanged = { vp -> local = local.copy(viewport = vp).also(onStateChanged) },
onSelectionChanged = { sel -> local = local.copy(selection = sel).clampSelection().also(onStateChanged) },
onUpdateCurve = { curveId, newCurve ->
val candidate = local.withCurve(curveId, newCurve)
val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
local = candidate.extendDurationTo(maxT)
.copy(selection = candidate.selection.copy(endSec = max(candidate.selection.endSec, candidate.durationSec)))
.also(onStateChanged)
},
onTapSideSelect = { side ->
local = local.copy(activeCurve = if (side == Side.LEFT) TimelineEditorState.ActiveCurve.VISUAL_LEFT else TimelineEditorState.ActiveCurve.VISUAL_RIGHT)
.also(onStateChanged)
}
)
Spacer(Modifier.height(6.dp))
TimelineGraph(
title = "Audio",
leftLabel = "Carrier",
rightLabel = "Binaural",
curveLeft = local.audioLeft,
curveRight = local.audioRight,
activeCurve = local.activeCurve,
viewport = local.viewport,
selection = local.selection,
playheadSec = local.playheadSec,
onViewportChanged = { vp -> local = local.copy(viewport = vp).also(onStateChanged) },
onSelectionChanged = { sel -> local = local.copy(selection = sel).clampSelection().also(onStateChanged) },
onUpdateCurve = { curveId, newCurve ->
val candidate = local.withCurve(curveId, newCurve)
val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
local = candidate.extendDurationTo(maxT).also(onStateChanged)
},
onTapSideSelect = { side ->
local = local.copy(activeCurve = if (side == Side.LEFT) TimelineEditorState.ActiveCurve.AUDIO_LEFT else TimelineEditorState.ActiveCurve.AUDIO_RIGHT)
.also(onStateChanged)
}
)
}
}

View File

@@ -135,19 +135,6 @@ private fun TimelineEditorContent(
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Timeline Editor",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground
)
OutlinedButton(onClick = onBack) { Text("Back") }
}
Row(
Modifier
.fillMaxWidth()
@@ -174,9 +161,15 @@ private fun TimelineEditorContent(
}
}
CurveSelector(
active = state.activeCurve,
onSelect = { state = state.copy(activeCurve = it) }
DurationSliderCard(
durationSec = state.durationSec,
onDurationSecChanged = { state = state.setDurationSec(it) },
)
Text(
"Tap left/right side of a graph to select a curve. Pinch to zoom, drag empty space to pan. Long-press a point to delete.",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodySmall,
)
TimelineGraph(
@@ -241,68 +234,12 @@ private fun TimelineEditorContent(
}
}
private enum class Side { LEFT, RIGHT }
@Composable
private fun CurveSelector(
active: ActiveCurve,
onSelect: (ActiveCurve) -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Text("Curve selection", color = MaterialTheme.colorScheme.onSurface)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(Modifier.weight(1f)) {
Text("Visual", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SelectorChip("Brightness", active == ActiveCurve.VISUAL_LEFT) { onSelect(ActiveCurve.VISUAL_LEFT) }
SelectorChip("Blink", active == ActiveCurve.VISUAL_RIGHT) { onSelect(ActiveCurve.VISUAL_RIGHT) }
}
}
Column(Modifier.weight(1f)) {
Text("Audio", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SelectorChip("Carrier", active == ActiveCurve.AUDIO_LEFT) { onSelect(ActiveCurve.AUDIO_LEFT) }
SelectorChip("Binaural", active == ActiveCurve.AUDIO_RIGHT) { onSelect(ActiveCurve.AUDIO_RIGHT) }
}
}
}
Text(
"Tip: tap left/right side of a graph to select that curve. Double-tap between points to add a smoothed point.",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun SelectorChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = Modifier
.background(bg, RoundedCornerShape(999.dp))
.padding(horizontal = 10.dp, vertical = 6.dp)
.pointerInput(Unit) {
detectTapGestures(onTap = { onClick() })
}
) {
Text(label, color = fg, style = MaterialTheme.typography.labelLarge)
}
}
internal enum class Side { LEFT, RIGHT }
private enum class HandleDrag { NONE, START, END }
@Composable
private fun TimelineGraph(
internal fun TimelineGraph(
title: String,
leftLabel: String,
rightLabel: String,
@@ -564,13 +501,3 @@ private fun valueToY(value01: Float, height: Float): Float {
return (1f - v) * height
}
private fun formatDuration(totalSec: Int): String {
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return when {
h > 0 -> String.format("%dh %02dm", h, m)
m > 0 -> String.format("%dm %02ds", m, s)
else -> String.format("%ds", s)
}
}

View File

@@ -87,6 +87,31 @@ data class TimelineEditorState(
return updated
}
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
val clamped = newDurationSec.coerceIn(60, 8 * 60 * 60)
if (clamped == durationSec) return this
return if (clamped > durationSec) {
extendDurationTo(clamped).copy(
selection = selection.copy(startSec = 0, endSec = clamped),
)
} else {
// Shrink: trim curves and clamp playhead/viewport.
fun trimCurve(c: TimelineCurve) = c.trimTo(clamped)
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
copy(
durationSec = clamped,
selection = selection.copy(startSec = 0, endSec = clamped),
visualLeft = trimCurve(visualLeft),
visualRight = trimCurve(visualRight),
audioLeft = trimCurve(audioLeft),
audioRight = trimCurve(audioRight),
viewport = newViewport,
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
)
}
}
fun clampSelection(): TimelineEditorState {
val minDur = 60
val maxDur = 8 * 60 * 60
@@ -94,7 +119,6 @@ data class TimelineEditorState(
var end = selection.endSec.coerceIn(0, durationSec)
if (end - start < minDur) end = (start + minDur).coerceAtMost(durationSec)
val clamped = TimelineSelection(start, end)
val newDuration = (clamped.endSec - clamped.startSec).coerceIn(minDur, maxDur)
// For now we keep selection anchored at 0; if start moved, reflect it but don't shift curves.
return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let {
it.copy(durationSec = it.durationSec.coerceIn(minDur, maxDur))
@@ -156,6 +180,33 @@ data class TimelineViewport(
data class TimelineCurve(
val points: List<TimelinePoint>
) {
fun valueAt(tSec: Float): Float {
val sorted = points.sortedBy { it.tSec }
if (sorted.isEmpty()) return 0.5f
if (sorted.size == 1) return sorted.first().value01
val t = tSec.coerceAtLeast(0f)
if (t <= sorted.first().tSec) return sorted.first().value01
if (t >= sorted.last().tSec) return sorted.last().value01
val rightIdx = sorted.indexOfFirst { it.tSec.toFloat() >= t }.coerceAtLeast(1)
val left = sorted[rightIdx - 1]
val right = sorted[rightIdx]
val dt = (right.tSec - left.tSec).toFloat().coerceAtLeast(1e-3f)
val u = ((t - left.tSec) / dt).coerceIn(0f, 1f)
return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f)
}
fun trimTo(newDurationSec: Int): TimelineCurve {
if (points.isEmpty()) return this
val sorted = points.sortedBy { it.tSec }
val filtered = sorted.filter { it.tSec <= newDurationSec }
if (filtered.isEmpty()) return copy(points = listOf(TimelinePoint(0, 0.5f), TimelinePoint(newDurationSec, 0.5f)))
val last = filtered.last()
val withEnd = if (last.tSec == newDurationSec) filtered else (filtered + last.copy(tSec = newDurationSec))
// Ensure we keep at least 2 points.
val ensured = if (withEnd.size >= 2) withEnd else listOf(withEnd.first(), withEnd.first().copy(tSec = newDurationSec))
return copy(points = ensured)
}
fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
val t = viewport.xToTimeSec(offset.x, widthPx).toInt().coerceAtLeast(0)
val v = (1f - (offset.y / heightPx)).coerceIn(0f, 1f)

View File

@@ -0,0 +1,49 @@
package com.mindmachine.mvp.session
import com.mindmachine.mvp.domain.SessionPreset
object TimelineProgramFactory {
fun fromPreset(preset: SessionPreset): TimelineEditorState {
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
// Map preset fixed params into normalized 0..1 values.
val flash01 = ParameterMapping.invLog01(
value = preset.flashIntervalMs.toFloat(),
min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
max = ParameterRanges.FLASH_INTERVAL_MS_MAX,
)
val blank01 = flash01
val carrier01 = ParameterMapping.invLog01(
value = preset.carrierFrequencyHz,
min = ParameterRanges.CARRIER_HZ_MIN,
max = ParameterRanges.CARRIER_HZ_MAX,
)
val binaural01 = ParameterMapping.invLog01(
value = preset.binauralDifferenceHz,
min = ParameterRanges.BINAURAL_HZ_MIN,
max = ParameterRanges.BINAURAL_HZ_MAX,
)
fun constantCurve(v01: Float) = TimelineCurve(
points = listOf(
TimelinePoint(0, v01.coerceIn(0f, 1f)),
TimelinePoint(dur, v01.coerceIn(0f, 1f)),
)
)
return TimelineEditorState(
durationSec = dur,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
// Visual curves: Flash Interval (left), Blank Interval (right)
visualLeft = constantCurve(flash01),
visualRight = constantCurve(blank01),
// Audio curves: Carrier (left), Binaural (right)
audioLeft = constantCurve(carrier01),
audioRight = constantCurve(binaural01),
isPlaying = false,
playheadSec = 0f,
)
}
}

View File

@@ -0,0 +1,22 @@
package com.mindmachine.mvp.session
object TimelineRuntimeEvaluator {
fun evaluate(state: TimelineEditorState, tSec: Float): RuntimeParams {
val flash01 = state.visualLeft.valueAt(tSec)
val blank01 = state.visualRight.valueAt(tSec)
val carrier01 = state.audioLeft.valueAt(tSec)
val binaural01 = state.audioRight.valueAt(tSec)
val flashOnMs = ParameterMapping.logMap01(flash01, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX).toInt()
val flashOffMs = ParameterMapping.logMap01(blank01, ParameterRanges.BLANK_INTERVAL_MS_MIN, ParameterRanges.BLANK_INTERVAL_MS_MAX).toInt()
val carrierHz = ParameterMapping.logMap01(carrier01, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
val binauralHz = ParameterMapping.logMap01(binaural01, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)
return RuntimeParams(
flashOnMs = flashOnMs.coerceIn(50, 2000),
flashOffMs = flashOffMs.coerceIn(50, 2000),
carrierHz = carrierHz,
binauralHz = binauralHz,
)
}
}

View File

@@ -13,14 +13,21 @@ data class SplitFlashFrame(
fun computeSplitFlashFrame(
frameTimeNanos: Long,
flashIntervalMs: Int,
flashOnMs: Int,
flashOffMs: Int,
): SplitFlashFrame {
val intervalMs = flashIntervalMs.coerceIn(50, 2000).toLong()
val onMs = flashOnMs.coerceIn(50, 2000).toLong()
val offMs = flashOffMs.coerceIn(50, 2000).toLong()
val elapsedMs = frameTimeNanos / 1_000_000L
return when (((elapsedMs / intervalMs) % 4).toInt()) {
0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN)
1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED)
// Four phases: on, off, on(swapped), off
val total = 2L * (onMs + offMs)
val t = if (total <= 0L) 0L else (elapsedMs % total)
return when {
t < onMs -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN)
t < onMs + offMs -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
t < 2L * onMs + offMs -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED)
else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
}
}