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

@@ -8,9 +8,8 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.background import androidx.compose.foundation.background
import com.mindmachine.mvp.session.TimelineEditorScreen import com.mindmachine.mvp.session.DurationSliderCard
import com.mindmachine.mvp.session.TimelineModel import com.mindmachine.mvp.session.SetupTimelineEditor
import com.mindmachine.mvp.session.TimelineParameterControl
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -194,13 +193,6 @@ fun App(vm: MainViewModel = viewModel()) {
nav.navigate("active") nav.navigate("active")
}, },
onHolder = { nav.navigate("holder") }, onHolder = { nav.navigate("holder") },
onOpenTimelineEditor = { nav.navigate("timeline") },
)
}
composable("timeline") {
TimelineEditorScreen(
vm = vm,
onBack = { nav.popBackStack() }
) )
} }
composable("active") { composable("active") {
@@ -267,13 +259,9 @@ fun SetupScreen(
vm: MainViewModel, vm: MainViewModel,
onStart: () -> Unit, onStart: () -> Unit,
onHolder: () -> Unit, onHolder: () -> Unit,
onOpenTimelineEditor: () -> Unit,
) { ) {
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val cfg = ui.config val cfg = ui.config
var model by remember(ui.selectedPreset.id, cfg.durationSec) {
mutableStateOf(TimelineModel.createForPreset(ui.selectedPreset, cfg.durationSec))
}
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val containerModifier = Modifier val containerModifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -282,32 +270,27 @@ fun SetupScreen(
.safeDrawingPadding() .safeDrawingPadding()
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
TimelineParameterControl(
model = model,
onParameterChanged = { id: String, newValue: Float ->
model = model.updateParameterValue(id, newValue)
when (id) {
"flash_interval" -> vm.setFlashIntervalMs((newValue * 1000f).roundToInt())
"carrier_frequency" -> vm.setCarrier(newValue)
"binaural_difference" -> vm.setDifference(newValue)
}
},
modifier = containerModifier,
showTimeline = true,
onTimelineChanged = { newDuration ->
vm.setDurationMin((newDuration / 60f).roundToInt())
}
)
// Additional controls
Column( Column(
Modifier.fillMaxWidth(), modifier = containerModifier,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
OutlinedButton(onClick = onOpenTimelineEditor, modifier = Modifier.fillMaxWidth()) { Text(
Text("Open Timeline Editor") ui.selectedPreset.name,
} style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
)
DurationSliderCard(
durationSec = ui.timeline.durationSec,
onDurationSecChanged = { vm.setDurationSec(it) },
)
// Integrated timeline editor (no separate screen).
SetupTimelineEditor(
state = ui.timeline,
onStateChanged = vm::updateTimeline,
)
TextButton(onClick = onHolder) { Text("Holder Guidance") } TextButton(onClick = onHolder) { Text("Holder Guidance") }
if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error) if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error)
Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") } Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") }
@@ -359,7 +342,8 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
val flashFrame by rememberSplitFlashFrame( val flashFrame by rememberSplitFlashFrame(
isRunning = ui.runtimeState == RuntimeState.RUNNING, isRunning = ui.runtimeState == RuntimeState.RUNNING,
mode = ui.config.mode, mode = ui.config.mode,
flashIntervalMs = ui.config.flashIntervalMs, flashOnMs = ui.runtimeParams.flashOnMs,
flashOffMs = ui.runtimeParams.flashOffMs,
) )
Box( Box(
@@ -471,15 +455,16 @@ private fun FlashColor.toComposeColor(): Color = when (this) {
private fun rememberSplitFlashFrame( private fun rememberSplitFlashFrame(
isRunning: Boolean, isRunning: Boolean,
mode: SessionMode, mode: SessionMode,
flashIntervalMs: Int, flashOnMs: Int,
) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashIntervalMs) { flashOffMs: Int,
) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashOnMs, flashOffMs) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) { if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState return@produceState
} }
while (true) { while (true) {
withFrameNanos { frameTimeNanos -> withFrameNanos { frameTimeNanos ->
value = computeSplitFlashFrame(frameTimeNanos, flashIntervalMs) value = computeSplitFlashFrame(frameTimeNanos, flashOnMs, flashOffMs)
} }
} }
} }

View File

@@ -18,8 +18,17 @@ class BinauralAudioEngine {
private var job: Job? = null private var job: Job? = null
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
@Volatile private var carrierHz: Float = 200f
@Volatile private var differenceHz: Float = 6f
fun setFrequencies(carrierHz: Float, differenceHz: Float) {
this.carrierHz = carrierHz
this.differenceHz = differenceHz
}
fun start(carrierHz: Float, differenceHz: Float) { fun start(carrierHz: Float, differenceHz: Float) {
stop() stop()
setFrequencies(carrierHz, differenceHz)
val sampleRate = 44100 val sampleRate = 44100
val bufferSize = AudioTrack.getMinBufferSize( val bufferSize = AudioTrack.getMinBufferSize(
sampleRate, sampleRate,
@@ -43,9 +52,13 @@ class BinauralAudioEngine {
val shorts = ShortArray(bufferSize) val shorts = ShortArray(bufferSize)
var phaseL = 0.0 var phaseL = 0.0
var phaseR = 0.0 var phaseR = 0.0
val leftHz = carrierHz - differenceHz / 2f
val rightHz = carrierHz + differenceHz / 2f
while (isActive) { while (isActive) {
// Read the latest modulation values once per buffer.
val carrier = this@BinauralAudioEngine.carrierHz
val diff = this@BinauralAudioEngine.differenceHz
val leftHz = carrier - diff / 2f
val rightHz = carrier + diff / 2f
for (i in shorts.indices step 2) { for (i in shorts.indices step 2) {
phaseL += 2 * PI * leftHz / sampleRate phaseL += 2 * PI * leftHz / sampleRate
phaseR += 2 * PI * rightHz / sampleRate phaseR += 2 * PI * rightHz / sampleRate

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 presets: List<SessionPreset> = Presets.builtIn,
val selectedPreset: SessionPreset = Presets.builtIn.first(), val selectedPreset: SessionPreset = Presets.builtIn.first(),
val config: SessionConfig = Presets.builtIn.first().toConfig(), 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 runtimeState: RuntimeState = RuntimeState.IDLE,
val error: String? = null, val error: String? = null,
val remainingSec: Int = 0, val remainingSec: Int = 0,
val countdownSec: Int = 0, val countdownSec: Int = 0,
val endedEarly: Boolean = false, val endedEarly: Boolean = false,
val interruptionReason: String? = null, val interruptionReason: String? = null,
// Current runtime-evaluated parameters (updated while running).
val runtimeParams: RuntimeParams = RuntimeParams(),
) )
class MainViewModel( class MainViewModel(
@@ -62,11 +69,37 @@ class MainViewModel(
fun choosePreset(id: String) = viewModelScope.launch { fun choosePreset(id: String) = viewModelScope.launch {
val preset = _ui.value.presets.first { it.id == id } val preset = _ui.value.presets.first { it.id == id }
settingsRepository.updateLastPreset(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 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 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 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) } 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) delay(1000)
} }
} }
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, remainingSec = state.config.durationSec, countdownSec = 0, error = null) } // Use the timeline curves as the actual runtime program.
if (state.config.mode != SessionMode.VISUAL_ONLY) { val program = _ui.value.timeline
audioEngine.start(state.config.carrierFrequencyHz, state.config.binauralDifferenceHz) 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) { if (state.config.mode != SessionMode.VISUAL_ONLY) {
delay(1000) 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()) { if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
audioEngine.stop() audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") } _ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
return@launch 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() audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) } _ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
} }
@@ -137,7 +203,8 @@ class MainViewModel(
} }
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, countdownSec = 0) } _ui.update { it.copy(runtimeState = RuntimeState.RUNNING, countdownSec = 0) }
if (state.config.mode != SessionMode.VISUAL_ONLY) { 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 var remaining = state.remainingSec
while (remaining > 0) { 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 { object SessionValidator {
fun validate(config: SessionConfig, headsetAvailable: Boolean): String? { 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.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.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." 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), .padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.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( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -174,9 +161,15 @@ private fun TimelineEditorContent(
} }
} }
CurveSelector( DurationSliderCard(
active = state.activeCurve, durationSec = state.durationSec,
onSelect = { state = state.copy(activeCurve = it) } 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( TimelineGraph(
@@ -241,68 +234,12 @@ private fun TimelineEditorContent(
} }
} }
private enum class Side { LEFT, RIGHT } internal 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)
}
}
private enum class HandleDrag { NONE, START, END } private enum class HandleDrag { NONE, START, END }
@Composable @Composable
private fun TimelineGraph( internal fun TimelineGraph(
title: String, title: String,
leftLabel: String, leftLabel: String,
rightLabel: String, rightLabel: String,
@@ -564,13 +501,3 @@ private fun valueToY(value01: Float, height: Float): Float {
return (1f - v) * height 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 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 { fun clampSelection(): TimelineEditorState {
val minDur = 60 val minDur = 60
val maxDur = 8 * 60 * 60 val maxDur = 8 * 60 * 60
@@ -94,7 +119,6 @@ data class TimelineEditorState(
var end = selection.endSec.coerceIn(0, durationSec) var end = selection.endSec.coerceIn(0, durationSec)
if (end - start < minDur) end = (start + minDur).coerceAtMost(durationSec) if (end - start < minDur) end = (start + minDur).coerceAtMost(durationSec)
val clamped = TimelineSelection(start, end) 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. // 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 { return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let {
it.copy(durationSec = it.durationSec.coerceIn(minDur, maxDur)) it.copy(durationSec = it.durationSec.coerceIn(minDur, maxDur))
@@ -156,6 +180,33 @@ data class TimelineViewport(
data class TimelineCurve( data class TimelineCurve(
val points: List<TimelinePoint> 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 { 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 t = viewport.xToTimeSec(offset.x, widthPx).toInt().coerceAtLeast(0)
val v = (1f - (offset.y / heightPx)).coerceIn(0f, 1f) 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( fun computeSplitFlashFrame(
frameTimeNanos: Long, frameTimeNanos: Long,
flashIntervalMs: Int, flashOnMs: Int,
flashOffMs: Int,
): SplitFlashFrame { ): 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 val elapsedMs = frameTimeNanos / 1_000_000L
return when (((elapsedMs / intervalMs) % 4).toInt()) {
0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN) // Four phases: on, off, on(swapped), off
1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK) val total = 2L * (onMs + offMs)
2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED) 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) else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
} }
} }

View File

@@ -38,7 +38,7 @@ class SessionValidatorTest {
@Test @Test
fun out_of_range_values_are_blocked() { fun out_of_range_values_are_blocked() {
assertEquals("Duration must be 1 to 30 minutes.", SessionValidator.validate(valid.copy(durationSec = 59), true)) assertEquals("Duration must be 1 minute to 8 hours.", SessionValidator.validate(valid.copy(durationSec = 59), true))
assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49), true)) assertEquals("Flash interval must be 0.05 to 2.00 seconds.", SessionValidator.validate(valid.copy(flashIntervalMs = 49), true))
assertEquals("Carrier frequency must be between 80 and 400.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 79f), true)) assertEquals("Carrier frequency must be between 80 and 400.", SessionValidator.validate(valid.copy(carrierFrequencyHz = 79f), true))
assertEquals("Binaural difference must be between 0.5 and 20.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f), true)) assertEquals("Binaural difference must be between 0.5 and 20.", SessionValidator.validate(valid.copy(binauralDifferenceHz = 0.1f), true))

View File

@@ -11,10 +11,10 @@ class VisualSignalTest {
fun splitFlashPattern_cycles_redGreen_black_greenRed_black() { fun splitFlashPattern_cycles_redGreen_black_greenRed_black() {
val intervalMs = 100 val intervalMs = 100
val phase0 = computeSplitFlashFrame(frameTimeNanos = 0L, flashIntervalMs = intervalMs) val phase0 = computeSplitFlashFrame(frameTimeNanos = 0L, flashOnMs = intervalMs, flashOffMs = intervalMs)
val phase1 = computeSplitFlashFrame(frameTimeNanos = 100_000_000L, flashIntervalMs = intervalMs) val phase1 = computeSplitFlashFrame(frameTimeNanos = 100_000_000L, flashOnMs = intervalMs, flashOffMs = intervalMs)
val phase2 = computeSplitFlashFrame(frameTimeNanos = 200_000_000L, flashIntervalMs = intervalMs) val phase2 = computeSplitFlashFrame(frameTimeNanos = 200_000_000L, flashOnMs = intervalMs, flashOffMs = intervalMs)
val phase3 = computeSplitFlashFrame(frameTimeNanos = 300_000_000L, flashIntervalMs = intervalMs) val phase3 = computeSplitFlashFrame(frameTimeNanos = 300_000_000L, flashOnMs = intervalMs, flashOffMs = intervalMs)
assertEquals(FlashColor.RED, phase0.left) assertEquals(FlashColor.RED, phase0.left)
assertEquals(FlashColor.GREEN, phase0.right) assertEquals(FlashColor.GREEN, phase0.right)
@@ -31,12 +31,12 @@ class VisualSignalTest {
@Test @Test
fun interval_is_clamped_to_valid_range() { fun interval_is_clamped_to_valid_range() {
val tooFast = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 1) val tooFast = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashOnMs = 1, flashOffMs = 1)
val validAt50ms = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 50) val validAt50ms = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashOnMs = 50, flashOffMs = 50)
assertEquals(validAt50ms, tooFast) assertEquals(validAt50ms, tooFast)
val tooSlow = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 5000) val tooSlow = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 5000, flashOffMs = 5000)
val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 2000) val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 2000, flashOffMs = 2000)
assertEquals(validAt2000ms, tooSlow) assertEquals(validAt2000ms, tooSlow)
} }
} }