From 3846f97fb2cedc613a507b267370f04650ea4cda Mon Sep 17 00:00:00 2001 From: Tretzi Date: Thu, 19 Mar 2026 20:01:01 -0500 Subject: [PATCH] Auto commit Thu Mar 19 08:01:01 PM CDT 2026 --- .../java/com/mindmachine/mvp/MainActivity.kt | 69 ++++++-------- .../mvp/audio/BinauralAudioEngine.kt | 17 +++- .../mindmachine/mvp/session/DurationFormat.kt | 13 +++ .../mvp/session/DurationSliderCard.kt | 67 +++++++++++++ .../mindmachine/mvp/session/MainViewModel.kt | 89 ++++++++++++++--- .../mvp/session/ParameterMapping.kt | 20 ++++ .../mvp/session/ParameterRanges.kt | 17 ++++ .../mindmachine/mvp/session/RuntimeParams.kt | 11 +++ .../mvp/session/SessionValidator.kt | 2 +- .../mvp/session/SetupTimelineEditor.kt | 92 ++++++++++++++++++ .../mvp/session/TimelineEditorScreen.kt | 95 +++---------------- .../mvp/session/TimelineEditorState.kt | 53 ++++++++++- .../mvp/session/TimelineProgramFactory.kt | 49 ++++++++++ .../mvp/session/TimelineRuntimeEvaluator.kt | 22 +++++ .../mindmachine/mvp/session/VisualSignal.kt | 19 ++-- .../mindmachine/mvp/SessionValidatorTest.kt | 2 +- .../com/mindmachine/mvp/VisualSignalTest.kt | 16 ++-- 17 files changed, 497 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/ParameterMapping.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/ParameterRanges.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/RuntimeParams.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt create mode 100644 app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 3b655af..ef17494 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -8,9 +8,8 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.background -import com.mindmachine.mvp.session.TimelineEditorScreen -import com.mindmachine.mvp.session.TimelineModel -import com.mindmachine.mvp.session.TimelineParameterControl +import com.mindmachine.mvp.session.DurationSliderCard +import com.mindmachine.mvp.session.SetupTimelineEditor import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -194,13 +193,6 @@ fun App(vm: MainViewModel = viewModel()) { nav.navigate("active") }, onHolder = { nav.navigate("holder") }, - onOpenTimelineEditor = { nav.navigate("timeline") }, - ) - } - composable("timeline") { - TimelineEditorScreen( - vm = vm, - onBack = { nav.popBackStack() } ) } composable("active") { @@ -267,13 +259,9 @@ fun SetupScreen( vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit, - onOpenTimelineEditor: () -> Unit, ) { val ui by vm.ui.collectAsStateWithLifecycle() val cfg = ui.config - var model by remember(ui.selectedPreset.id, cfg.durationSec) { - mutableStateOf(TimelineModel.createForPreset(ui.selectedPreset, cfg.durationSec)) - } val scrollState = rememberScrollState() val containerModifier = Modifier .fillMaxSize() @@ -282,32 +270,27 @@ fun SetupScreen( .safeDrawingPadding() .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( - Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = containerModifier, + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - OutlinedButton(onClick = onOpenTimelineEditor, modifier = Modifier.fillMaxWidth()) { - Text("Open Timeline Editor") - } + Text( + 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") } if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error) 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( isRunning = ui.runtimeState == RuntimeState.RUNNING, mode = ui.config.mode, - flashIntervalMs = ui.config.flashIntervalMs, + flashOnMs = ui.runtimeParams.flashOnMs, + flashOffMs = ui.runtimeParams.flashOffMs, ) Box( @@ -471,15 +455,16 @@ private fun FlashColor.toComposeColor(): Color = when (this) { private fun rememberSplitFlashFrame( isRunning: Boolean, mode: SessionMode, - flashIntervalMs: Int, -) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashIntervalMs) { + flashOnMs: Int, + flashOffMs: Int, +) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashOnMs, flashOffMs) { if (!isRunning || mode == SessionMode.AUDIO_ONLY) { value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) return@produceState } while (true) { withFrameNanos { frameTimeNanos -> - value = computeSplitFlashFrame(frameTimeNanos, flashIntervalMs) + value = computeSplitFlashFrame(frameTimeNanos, flashOnMs, flashOffMs) } } } diff --git a/app/src/main/java/com/mindmachine/mvp/audio/BinauralAudioEngine.kt b/app/src/main/java/com/mindmachine/mvp/audio/BinauralAudioEngine.kt index fd2d78c..e3d0204 100644 --- a/app/src/main/java/com/mindmachine/mvp/audio/BinauralAudioEngine.kt +++ b/app/src/main/java/com/mindmachine/mvp/audio/BinauralAudioEngine.kt @@ -18,8 +18,17 @@ class BinauralAudioEngine { private var job: Job? = 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) { stop() + setFrequencies(carrierHz, differenceHz) val sampleRate = 44100 val bufferSize = AudioTrack.getMinBufferSize( sampleRate, @@ -43,9 +52,13 @@ class BinauralAudioEngine { val shorts = ShortArray(bufferSize) var phaseL = 0.0 var phaseR = 0.0 - val leftHz = carrierHz - differenceHz / 2f - val rightHz = carrierHz + differenceHz / 2f 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) { phaseL += 2 * PI * leftHz / sampleRate phaseR += 2 * PI * rightHz / sampleRate diff --git a/app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt b/app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt new file mode 100644 index 0000000..7004e8e --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/DurationFormat.kt @@ -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) + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt b/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt new file mode 100644 index 0000000..5737552 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt index 40bac70..4e0543e 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -29,12 +29,19 @@ data class UiState( val presets: List = 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) { diff --git a/app/src/main/java/com/mindmachine/mvp/session/ParameterMapping.kt b/app/src/main/java/com/mindmachine/mvp/session/ParameterMapping.kt new file mode 100644 index 0000000..c55769b --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/ParameterMapping.kt @@ -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) + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/ParameterRanges.kt b/app/src/main/java/com/mindmachine/mvp/session/ParameterRanges.kt new file mode 100644 index 0000000..7bf4cad --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/ParameterRanges.kt @@ -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 +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/RuntimeParams.kt b/app/src/main/java/com/mindmachine/mvp/session/RuntimeParams.kt new file mode 100644 index 0000000..c49ae48 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/RuntimeParams.kt @@ -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, +) diff --git a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt index b39c59c..b25c7f4 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt @@ -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." diff --git a/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt new file mode 100644 index 0000000..22e875e --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt @@ -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) + } + ) + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt index f255f53..8f493b6 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -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) - } -} diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt index 198a110..642a89b 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -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 ) { + 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) diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt new file mode 100644 index 0000000..b8d7579 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt new file mode 100644 index 0000000..99f8ffd --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineRuntimeEvaluator.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt index 13061d6..3c7de55 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/VisualSignal.kt @@ -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) } } diff --git a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt index 1f33b69..182fc13 100644 --- a/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/SessionValidatorTest.kt @@ -38,7 +38,7 @@ class SessionValidatorTest { @Test 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("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)) diff --git a/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt index 755e31f..c4b3cb8 100644 --- a/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt +++ b/app/src/test/java/com/mindmachine/mvp/VisualSignalTest.kt @@ -11,10 +11,10 @@ class VisualSignalTest { fun splitFlashPattern_cycles_redGreen_black_greenRed_black() { val intervalMs = 100 - val phase0 = computeSplitFlashFrame(frameTimeNanos = 0L, flashIntervalMs = intervalMs) - val phase1 = computeSplitFlashFrame(frameTimeNanos = 100_000_000L, flashIntervalMs = intervalMs) - val phase2 = computeSplitFlashFrame(frameTimeNanos = 200_000_000L, flashIntervalMs = intervalMs) - val phase3 = computeSplitFlashFrame(frameTimeNanos = 300_000_000L, flashIntervalMs = intervalMs) + val phase0 = computeSplitFlashFrame(frameTimeNanos = 0L, flashOnMs = intervalMs, flashOffMs = intervalMs) + val phase1 = computeSplitFlashFrame(frameTimeNanos = 100_000_000L, flashOnMs = intervalMs, flashOffMs = intervalMs) + val phase2 = computeSplitFlashFrame(frameTimeNanos = 200_000_000L, flashOnMs = intervalMs, flashOffMs = intervalMs) + val phase3 = computeSplitFlashFrame(frameTimeNanos = 300_000_000L, flashOnMs = intervalMs, flashOffMs = intervalMs) assertEquals(FlashColor.RED, phase0.left) assertEquals(FlashColor.GREEN, phase0.right) @@ -31,12 +31,12 @@ class VisualSignalTest { @Test fun interval_is_clamped_to_valid_range() { - val tooFast = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 1) - val validAt50ms = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashIntervalMs = 50) + val tooFast = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashOnMs = 1, flashOffMs = 1) + val validAt50ms = computeSplitFlashFrame(frameTimeNanos = 49_000_000L, flashOnMs = 50, flashOffMs = 50) assertEquals(validAt50ms, tooFast) - val tooSlow = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 5000) - val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashIntervalMs = 2000) + val tooSlow = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 5000, flashOffMs = 5000) + val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 2000, flashOffMs = 2000) assertEquals(validAt2000ms, tooSlow) } }