Auto commit Thu Mar 19 08:01:01 PM CDT 2026
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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."
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user