Auto commit Sat Mar 21 11:01:02 PM CDT 2026

This commit is contained in:
Tretzi
2026-03-21 23:01:02 -05:00
parent 5c48387dc2
commit 869c98a47c
11 changed files with 354 additions and 104 deletions

View File

@@ -5,3 +5,9 @@ import com.mindmachine.mvp.domain.RuntimeState
fun shouldShowActiveControlsByDefault(runtimeState: RuntimeState): Boolean {
return runtimeState != RuntimeState.RUNNING && runtimeState != RuntimeState.COUNTDOWN
}
fun sessionProgressFraction(durationSec: Int, remainingSec: Int): Float {
val total = durationSec.coerceAtLeast(1)
val elapsed = (total - remainingSec).coerceIn(0, total)
return elapsed.toFloat() / total.toFloat()
}

View File

@@ -7,5 +7,5 @@ fun shouldUseImmersiveFullscreen(isVisualMode: Boolean, runtimeState: RuntimeSta
}
fun shouldRevealSystemBarsOnTap(isVisualMode: Boolean, runtimeState: RuntimeState): Boolean {
return shouldUseImmersiveFullscreen(isVisualMode, runtimeState)
return false
}

View File

@@ -9,7 +9,6 @@ import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.data.UserProgramEntity
import com.mindmachine.mvp.data.UserProgramRepository
import com.mindmachine.mvp.domain.AppSettings
import com.mindmachine.mvp.domain.CountdownPreference
import com.mindmachine.mvp.domain.Presets
import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.domain.SessionConfig
@@ -59,15 +58,27 @@ class MainViewModel(
private var runJob: Job? = null
private var elapsedBeforePauseSec: Float = 0f
private var cachedUserPrograms: List<UserProgramEntity> = emptyList()
private var cachedPrograms: List<UserProgramEntity> = emptyList()
init {
viewModelScope.launch {
combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
settings to userPrograms
}.collect { (settings, userPrograms) ->
cachedUserPrograms = userPrograms
val userPresets = userPrograms.map {
val builtInPrograms = Presets.builtIn.map { preset ->
userPrograms.firstOrNull { it.id == preset.id } ?: UserProgramEntity(
id = preset.id,
name = preset.name,
description = preset.description,
timeline = TimelineProgramFactory.fromPreset(preset),
sortOrder = preset.sortOrder,
)
}
val mergedPrograms = (builtInPrograms + userPrograms.filterNot { program -> Presets.builtIn.any { it.id == program.id } })
.sortedBy { it.sortOrder }
cachedPrograms = mergedPrograms
val allPresets = mergedPrograms.map {
TimelineProgramFactory.toPreset(
id = it.id,
name = it.name,
@@ -77,7 +88,6 @@ class MainViewModel(
isUserProgram = true,
)
}
val allPresets = (Presets.builtIn + userPresets).sortedBy { it.sortOrder }
_ui.update { current ->
val selectedId = settings.lastPresetId ?: current.selectedPreset.id
@@ -88,7 +98,7 @@ class MainViewModel(
presets = allPresets,
selectedPreset = selectedPreset,
config = current.config.copy(mode = settings.defaultModePreference),
timeline = if (sameSelection) current.timeline else timelineForPreset(selectedPreset, userPrograms),
timeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms),
)
}
}
@@ -106,7 +116,7 @@ class MainViewModel(
it.copy(
selectedPreset = preset,
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
timeline = timelineForPreset(preset, cachedUserPrograms),
timeline = timelineForProgramId(preset.id, cachedPrograms),
error = null,
)
}
@@ -143,8 +153,8 @@ class MainViewModel(
}
val state = _ui.value
val id = if (state.selectedPreset.isUserProgram) state.selectedPreset.id else "user-${UUID.randomUUID()}"
val sortOrder = cachedUserPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder()
val id = state.selectedPreset.id
val sortOrder = cachedPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder()
userProgramRepository.upsert(
UserProgramEntity(
@@ -176,13 +186,13 @@ class MainViewModel(
fun deleteProgram(id: String) = viewModelScope.launch {
userProgramRepository.delete(id)
if (_ui.value.selectedPreset.id == id) {
val fallback = Presets.builtIn.first()
val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first()
settingsRepository.updateLastPreset(fallback.id)
_ui.update {
it.copy(
selectedPreset = fallback,
config = fallback.toConfig(it.settings.defaultModePreference),
timeline = TimelineProgramFactory.fromPreset(fallback),
timeline = timelineForProgramId(fallback.id, cachedPrograms),
error = null,
)
}
@@ -212,9 +222,10 @@ class MainViewModel(
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 updateCountdown(pref: CountdownPreference) = viewModelScope.launch { settingsRepository.updateCountdown(pref) }
fun updateCountdownSeconds(seconds: Int) = viewModelScope.launch { settingsRepository.updateCountdownSeconds(seconds) }
fun updateDefaultMode(mode: SessionMode) = viewModelScope.launch { settingsRepository.updateDefaultMode(mode) }
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
fun startSession() {
val state = _ui.value
@@ -281,7 +292,7 @@ class MainViewModel(
val clampedStartSec = startElapsedSec.coerceIn(0f, durationSec.toFloat())
if (includeCountdown) {
val count = _ui.value.settings.countdownPreference.seconds
val count = _ui.value.settings.countdownSeconds.coerceIn(0, 10)
if (count > 0) {
for (i in count downTo 1) {
_ui.update {
@@ -358,14 +369,18 @@ class MainViewModel(
return (total - state.remainingSec).coerceIn(0f, total)
}
private fun timelineForPreset(preset: SessionPreset, userPrograms: List<UserProgramEntity>): TimelineEditorState {
return userPrograms.firstOrNull { it.id == preset.id }?.timeline ?: TimelineProgramFactory.fromPreset(preset)
private fun timelineForProgramId(programId: String, programs: List<UserProgramEntity>): TimelineEditorState {
val fromSaved = programs.firstOrNull { it.id == programId }?.timeline
if (fromSaved != null) return fromSaved
val builtInPreset = Presets.builtIn.firstOrNull { it.id == programId }
return if (builtInPreset != null) TimelineProgramFactory.fromPreset(builtInPreset) else TimelineEditorState.default()
}
private fun nextSortOrder(): Int = (cachedUserPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1
private fun nextSortOrder(): Int = (cachedPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1
private fun nextUntitledName(): String {
val names = cachedUserPrograms.map { it.name.lowercase() }.toSet()
val names = cachedPrograms.map { it.name.lowercase() }.toSet()
var i = 1
while (true) {
val candidate = "Custom $i"

View File

@@ -409,7 +409,12 @@ internal fun TimelineGraph(
val curveId = draggingCurveId
if (pointIndex != null && curveId != null) {
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, primary.position.y, h)
val constrainedY = constrainYForCurve(
y = primary.position.y,
height = h,
curveId = curveId,
)
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, constrainedY, h)
if (curveId == leftId) curveLeft = updated else curveRight = updated
currentOnUpdateCurve(curveId, updated)
onCurveEditedHaptic()
@@ -576,3 +581,22 @@ private fun valueToY(value01: Float, height: Float): Float {
val v = value01.coerceIn(0f, 1f)
return (1f - v) * height
}
private fun constrainYForCurve(y: Float, height: Float, curveId: ActiveCurve): Float {
val clampedY = y.coerceIn(0f, height)
val value01 = (1f - (clampedY / height)).coerceIn(0f, 1f)
return when (curveId) {
ActiveCurve.AUDIO_LEFT -> {
val hz = ParameterMapping.logMap01(value01, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
val bounded01 = ParameterMapping.invLog01(hz, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
valueToY(bounded01, height)
}
ActiveCurve.AUDIO_RIGHT -> {
val hz = ParameterMapping.logMap01(value01, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)
val bounded01 = ParameterMapping.invLog01(hz, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)
valueToY(bounded01, height)
}
else -> clampedY
}
}

View File

@@ -5,39 +5,11 @@ import com.mindmachine.mvp.domain.VisualPattern
object TimelineProgramFactory {
fun fromPreset(preset: SessionPreset): TimelineEditorState {
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
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,
)
return TimelineEditorState(
durationSec = dur,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
visualLeft = TimelineCurve.constant(flash01, dur),
visualRight = TimelineCurve.constant(blank01, dur),
audioLeft = TimelineCurve.constant(carrier01, dur),
audioRight = TimelineCurve.constant(binaural01, dur),
isPlaying = false,
playheadSec = 0f,
)
return builtInTimelineForPresetId(preset.id) ?: constantTimelineFromPreset(preset)
}
fun builtInTimelineForPresetId(id: String): TimelineEditorState? = builtInPrograms[id]
fun toPreset(
id: String,
name: String,
@@ -78,4 +50,146 @@ object TimelineProgramFactory {
isUserProgram = isUserProgram,
)
}
private fun constantTimelineFromPreset(preset: SessionPreset): TimelineEditorState {
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
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,
)
return TimelineEditorState(
durationSec = dur,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
visualLeft = TimelineCurve.constant(flash01, dur),
visualRight = TimelineCurve.constant(blank01, dur),
audioLeft = TimelineCurve.constant(carrier01, dur),
audioRight = TimelineCurve.constant(binaural01, dur),
isPlaying = false,
playheadSec = 0f,
)
}
private fun timeline(
durationSec: Int,
visualLeftMs: List<Int>,
visualRightMs: List<Int>,
audioLeftHz: List<Float>,
audioRightHz: List<Float>,
): TimelineEditorState {
val minutes = durationSec / 60
require(visualLeftMs.size == minutes + 1)
require(visualRightMs.size == minutes + 1)
require(audioLeftHz.size == minutes + 1)
require(audioRightHz.size == minutes + 1)
fun curveFromMs(values: List<Int>): TimelineCurve = TimelineCurve(
values.mapIndexed { index, value ->
TimelinePoint(
tSec = index * 60,
value01 = ParameterMapping.invLog01(
value = value.toFloat(),
min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
max = ParameterRanges.FLASH_INTERVAL_MS_MAX,
)
)
}
)
fun curveFromHz(values: List<Float>, min: Float, max: Float): TimelineCurve = TimelineCurve(
values.mapIndexed { index, value ->
TimelinePoint(
tSec = index * 60,
value01 = ParameterMapping.invLog01(value, min, max)
)
}
)
return TimelineEditorState(
durationSec = durationSec,
selection = TimelineSelection(0, durationSec),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
visualLeft = curveFromMs(visualLeftMs),
visualRight = curveFromMs(visualRightMs),
audioLeft = curveFromHz(audioLeftHz, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX),
audioRight = curveFromHz(audioRightHz, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX),
isPlaying = false,
playheadSec = 0f,
)
}
private val builtInPrograms: Map<String, TimelineEditorState> = mapOf(
"deep_release" to timeline(
durationSec = 18 * 60,
visualLeftMs = listOf(320, 300, 280, 260, 250, 240, 230, 220, 210, 200, 210, 220, 240, 260, 280, 300, 320, 340, 360),
visualRightMs = listOf(360, 340, 320, 300, 280, 270, 260, 250, 240, 230, 240, 250, 270, 290, 310, 330, 350, 370, 390),
audioLeftHz = listOf(190f, 188f, 186f, 184f, 182f, 180f, 178f, 176f, 174f, 172f, 170f, 168f, 166f, 164f, 162f, 160f, 158f, 156f, 154f),
audioRightHz = listOf(7f, 6.6f, 6.2f, 5.8f, 5.4f, 5f, 4.8f, 4.6f, 4.4f, 4.2f, 4f, 3.8f, 3.6f, 3.4f, 3.2f, 3f, 2.8f, 2.6f, 2.4f),
),
"laser_focus" to timeline(
durationSec = 16 * 60,
visualLeftMs = listOf(180, 170, 160, 150, 140, 130, 120, 110, 100, 95, 95, 95, 100, 105, 110, 115, 120),
visualRightMs = listOf(200, 190, 180, 170, 160, 150, 140, 130, 120, 115, 115, 115, 120, 125, 130, 135, 140),
audioLeftHz = listOf(205f, 208f, 212f, 216f, 220f, 224f, 228f, 232f, 236f, 240f, 242f, 244f, 246f, 248f, 250f, 248f, 245f),
audioRightHz = listOf(8f, 8.5f, 9f, 9.5f, 10f, 10.5f, 11f, 11.5f, 12f, 12f, 11.5f, 11f, 10.5f, 10f, 9.5f, 9f, 8.5f),
),
"creative_drift" to timeline(
durationSec = 22 * 60,
visualLeftMs = listOf(220, 200, 180, 160, 180, 210, 240, 270, 250, 220, 190, 170, 190, 220, 250, 280, 260, 230, 200, 180, 200, 230, 260),
visualRightMs = listOf(260, 240, 220, 200, 220, 250, 280, 310, 290, 260, 230, 210, 230, 260, 290, 320, 300, 270, 240, 220, 240, 270, 300),
audioLeftHz = listOf(200f, 204f, 208f, 212f, 216f, 220f, 224f, 228f, 224f, 220f, 216f, 212f, 208f, 204f, 200f, 196f, 192f, 196f, 200f, 204f, 208f, 212f, 216f),
audioRightHz = listOf(6f, 6.8f, 7.6f, 8.4f, 9.2f, 10f, 9.4f, 8.8f, 8.2f, 7.6f, 7f, 6.4f, 5.8f, 5.2f, 4.6f, 4f, 4.8f, 5.6f, 6.4f, 7.2f, 8f, 8.8f, 9.6f),
),
"reset_breath" to timeline(
durationSec = 12 * 60,
visualLeftMs = listOf(360, 340, 320, 300, 280, 260, 240, 260, 280, 300, 320, 340, 360),
visualRightMs = listOf(390, 370, 350, 330, 310, 290, 270, 290, 310, 330, 350, 370, 390),
audioLeftHz = listOf(175f, 173f, 171f, 169f, 167f, 165f, 163f, 165f, 167f, 169f, 171f, 173f, 175f),
audioRightHz = listOf(6.5f, 6.2f, 5.9f, 5.6f, 5.3f, 5f, 4.7f, 5f, 5.3f, 5.6f, 5.9f, 6.2f, 6.5f),
),
"night_rampdown" to timeline(
durationSec = 28 * 60,
visualLeftMs = listOf(300, 290, 280, 270, 260, 250, 240, 230, 220, 210, 205, 200, 205, 210, 220, 230, 240, 250, 260, 270, 280, 290, 300, 320, 340, 360, 380, 400, 420),
visualRightMs = listOf(330, 320, 310, 300, 290, 280, 270, 260, 250, 240, 235, 230, 235, 240, 250, 260, 270, 280, 290, 300, 310, 320, 330, 350, 370, 390, 410, 430, 450),
audioLeftHz = listOf(182f, 181f, 180f, 179f, 178f, 177f, 176f, 175f, 174f, 173f, 172f, 171f, 170f, 169f, 168f, 167f, 166f, 165f, 164f, 163f, 162f, 161f, 160f, 159f, 158f, 157f, 156f, 155f, 154f),
audioRightHz = listOf(5f, 4.9f, 4.8f, 4.7f, 4.6f, 4.5f, 4.4f, 4.3f, 4.2f, 4.1f, 4f, 3.9f, 3.8f, 3.7f, 3.6f, 3.5f, 3.4f, 3.3f, 3.2f, 3.1f, 3f, 2.9f, 2.8f, 2.7f, 2.6f, 2.5f, 2.4f, 2.3f, 2.2f),
),
"power_warmup" to timeline(
durationSec = 9 * 60,
visualLeftMs = listOf(140, 130, 120, 110, 100, 90, 85, 90, 100, 115),
visualRightMs = listOf(160, 150, 140, 130, 120, 110, 105, 110, 120, 135),
audioLeftHz = listOf(220f, 225f, 230f, 235f, 240f, 245f, 250f, 248f, 244f, 238f),
audioRightHz = listOf(10f, 11f, 12f, 13f, 14f, 15f, 14f, 13f, 12f, 11f),
),
"body_scan" to timeline(
durationSec = 20 * 60,
visualLeftMs = listOf(260, 250, 240, 230, 220, 210, 200, 190, 200, 210, 220, 230, 240, 250, 260, 250, 240, 230, 220, 210, 200),
visualRightMs = listOf(290, 280, 270, 260, 250, 240, 230, 220, 230, 240, 250, 260, 270, 280, 290, 280, 270, 260, 250, 240, 230),
audioLeftHz = listOf(195f, 194f, 193f, 192f, 191f, 190f, 189f, 188f, 187f, 186f, 185f, 186f, 187f, 188f, 189f, 190f, 191f, 192f, 193f, 194f, 195f),
audioRightHz = listOf(7.2f, 7f, 6.8f, 6.6f, 6.4f, 6.2f, 6f, 5.8f, 5.6f, 5.4f, 5.2f, 5.4f, 5.6f, 5.8f, 6f, 6.2f, 6.4f, 6.6f, 6.8f, 7f, 7.2f),
),
"storm_to_still" to timeline(
durationSec = 24 * 60,
visualLeftMs = listOf(140, 130, 120, 110, 100, 95, 90, 100, 120, 150, 180, 210, 240, 270, 300, 330, 340, 350, 360, 370, 380, 390, 400, 410, 420),
visualRightMs = listOf(170, 160, 150, 140, 130, 125, 120, 130, 150, 180, 210, 240, 270, 300, 330, 360, 370, 380, 390, 400, 410, 420, 430, 440, 450),
audioLeftHz = listOf(230f, 232f, 234f, 236f, 238f, 240f, 242f, 238f, 232f, 226f, 220f, 214f, 208f, 202f, 196f, 190f, 186f, 182f, 178f, 174f, 170f, 166f, 162f, 158f, 154f),
audioRightHz = listOf(12f, 12.5f, 13f, 13.5f, 14f, 14.5f, 15f, 14f, 13f, 12f, 11f, 10f, 9f, 8f, 7f, 6f, 5.5f, 5f, 4.5f, 4f, 3.6f, 3.2f, 2.9f, 2.6f, 2.4f),
),
)
}

View File

@@ -15,8 +15,8 @@ object TimelineRuntimeEvaluator {
return RuntimeParams(
flashOnMs = flashOnMs.coerceIn(50, 2000),
flashOffMs = flashOffMs.coerceIn(50, 2000),
carrierHz = carrierHz,
binauralHz = binauralHz,
carrierHz = carrierHz.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX),
binauralHz = binauralHz.coerceIn(ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX),
)
}
}