Auto commit Sun Mar 22 01:01:14 AM CDT 2026

This commit is contained in:
Tretzi
2026-03-22 01:01:14 -05:00
parent 88723bf214
commit ab5f4d5acf
7 changed files with 200 additions and 92 deletions

View File

@@ -28,6 +28,7 @@ import java.util.UUID
const val SAFETY_VERSION = 1
data class UiState(
val settingsInitialized: Boolean = false,
val settings: AppSettings = AppSettings(),
val presets: List<SessionPreset> = Presets.builtIn,
val selectedPreset: SessionPreset = Presets.builtIn.first(),
@@ -35,6 +36,9 @@ data class UiState(
// Timeline program (curves) used for both editing + runtime modulation.
val timeline: TimelineEditorState = TimelineProgramFactory.fromPreset(Presets.builtIn.first()),
val savedProgramName: String = Presets.builtIn.first().name,
val savedTimelineSignature: TimelineProgramSignature = timelineProgramSignature(TimelineProgramFactory.fromPreset(Presets.builtIn.first())),
val hasUnsavedChanges: Boolean = false,
val runtimeState: RuntimeState = RuntimeState.IDLE,
val error: String? = null,
@@ -47,6 +51,30 @@ data class UiState(
val runtimeParams: RuntimeParams = RuntimeParams(),
)
data class TimelineProgramSignature(
val durationSec: Int,
val curveGranularitySec: Int,
val visualLeft: List<TimelinePoint>,
val visualRight: List<TimelinePoint>,
val audioLeft: List<TimelinePoint>,
val audioRight: List<TimelinePoint>,
)
private fun timelineProgramSignature(timeline: TimelineEditorState): TimelineProgramSignature = TimelineProgramSignature(
durationSec = timeline.durationSec,
curveGranularitySec = timeline.curveGranularitySec,
visualLeft = timeline.visualLeft.points,
visualRight = timeline.visualRight.points,
audioLeft = timeline.audioLeft.points,
audioRight = timeline.audioRight.points,
)
private fun isDirty(name: String, timeline: TimelineEditorState, savedName: String, savedTimelineSignature: TimelineProgramSignature): Boolean {
val timelineChanged = timelineProgramSignature(timeline) != savedTimelineSignature
val nameChanged = name.trim() != savedName.trim()
return timelineChanged || nameChanged
}
class MainViewModel(
private val settingsRepository: SettingsRepository,
private val userProgramRepository: UserProgramRepository,
@@ -93,12 +121,19 @@ class MainViewModel(
val selectedId = settings.lastPresetId ?: current.selectedPreset.id
val selectedPreset = allPresets.find { it.id == selectedId } ?: allPresets.first()
val sameSelection = selectedPreset.id == current.selectedPreset.id
val nextTimeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms)
val savedName = if (sameSelection) current.savedProgramName else selectedPreset.name
val savedSignature = if (sameSelection) current.savedTimelineSignature else timelineProgramSignature(nextTimeline)
current.copy(
settingsInitialized = true,
settings = settings,
presets = allPresets,
selectedPreset = selectedPreset,
config = current.config.copy(mode = settings.defaultModePreference),
timeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms),
config = current.config.copy(mode = SessionMode.AUDIO_VISUAL),
timeline = nextTimeline,
savedProgramName = savedName,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = isDirty(selectedPreset.name, nextTimeline, savedName, savedSignature),
)
}
}
@@ -112,11 +147,16 @@ class MainViewModel(
val preset = state.presets.first { it.id == id }
settingsRepository.updateLastPreset(id)
_ui.update {
val cfg = preset.toConfig(it.settings.defaultModePreference)
val cfg = preset.toConfig(SessionMode.AUDIO_VISUAL)
val timeline = timelineForProgramId(preset.id, cachedPrograms)
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = preset,
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
timeline = timelineForProgramId(preset.id, cachedPrograms),
timeline = timeline,
savedProgramName = preset.name,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null,
)
}
@@ -136,10 +176,14 @@ class MainViewModel(
isUserProgram = true,
)
_ui.update {
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = preset,
config = preset.toConfig(it.settings.defaultModePreference),
config = preset.toConfig(SessionMode.AUDIO_VISUAL),
timeline = timeline,
savedProgramName = displayName,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null,
)
}
@@ -176,8 +220,12 @@ class MainViewModel(
sortOrder = sortOrder,
isUserProgram = true,
)
val savedSignature = timelineProgramSignature(it.timeline)
it.copy(
selectedPreset = savedPreset,
savedProgramName = trimmed,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = "Saved \"$trimmed\"",
)
}
@@ -189,10 +237,15 @@ class MainViewModel(
val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first()
settingsRepository.updateLastPreset(fallback.id)
_ui.update {
val timeline = timelineForProgramId(fallback.id, cachedPrograms)
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = fallback,
config = fallback.toConfig(it.settings.defaultModePreference),
timeline = timelineForProgramId(fallback.id, cachedPrograms),
config = fallback.toConfig(SessionMode.AUDIO_VISUAL),
timeline = timeline,
savedProgramName = fallback.name,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null,
)
}
@@ -203,16 +256,20 @@ class MainViewModel(
fun setDurationSec(seconds: Int) = _ui.update {
val clamped = seconds.coerceIn(60, 8 * 60 * 60)
val nextTimeline = it.timeline.setDurationSec(clamped)
it.copy(
config = it.config.copy(durationSec = clamped),
timeline = it.timeline.setDurationSec(clamped),
timeline = nextTimeline,
hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature),
error = null,
)
}
fun setCurveGranularitySec(seconds: Int) = _ui.update {
val nextTimeline = it.timeline.setCurveGranularitySec(seconds)
it.copy(
timeline = it.timeline.setCurveGranularitySec(seconds),
timeline = nextTimeline,
hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature),
error = null,
)
}
@@ -221,17 +278,22 @@ class MainViewModel(
it.copy(
timeline = newState,
config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)),
hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
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 {
val stableCarrier = value.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
it.copy(config = it.config.copy(carrierFrequencyHz = stableCarrier), error = null)
}
fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }
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 updateForceAudioWithoutHeadphones(value: Boolean) = viewModelScope.launch {
settingsRepository.updateForceAudioWithoutHeadphones(value)
}
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
fun startSession(): Boolean {
@@ -240,8 +302,7 @@ class MainViewModel(
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
return false
}
val headset = headsetMonitor.isStereoHeadsetAvailable()
val validation = SessionValidator.validate(state.config, headset)
val validation = SessionValidator.validate(state.config)
if (validation != null) {
_ui.update { it.copy(error = validation) }
return false
@@ -268,10 +329,6 @@ class MainViewModel(
fun resume() {
val state = _ui.value
if (state.runtimeState != RuntimeState.PAUSED && state.runtimeState != RuntimeState.INTERRUPTED) return
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
_ui.update { it.copy(error = "Headphones are required to resume audio mode.") }
return
}
runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true)
}
@@ -283,7 +340,13 @@ class MainViewModel(
}
fun switchToVisualOnlyAndResume() {
_ui.update { it.copy(config = it.config.copy(mode = SessionMode.VISUAL_ONLY), error = null) }
_ui.update {
it.copy(
settings = it.settings.copy(forceAudioWithoutHeadphones = false),
error = null,
)
}
viewModelScope.launch { settingsRepository.updateForceAudioWithoutHeadphones(false) }
resume()
}
@@ -331,8 +394,10 @@ class MainViewModel(
)
}
if (_ui.value.config.mode != SessionMode.VISUAL_ONLY) {
var isAudioPlaying = false
if (shouldPlayAudio(_ui.value.settings, headsetMonitor.isStereoHeadsetAvailable())) {
audioEngine.start(initialParams.carrierHz, initialParams.binauralHz)
isAudioPlaying = true
}
val tStart = System.currentTimeMillis()
@@ -343,23 +408,22 @@ class MainViewModel(
elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat())
val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0)
val currentUi = _ui.value
if (currentUi.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
audioEngine.stop()
_ui.update {
it.copy(
runtimeState = RuntimeState.INTERRUPTED,
interruptionReason = "Headphones disconnected. Session paused.",
)
}
return@launch
}
val headsetAvailable = headsetMonitor.isStereoHeadsetAvailable()
val shouldPlayAudioNow = shouldPlayAudio(currentUi.settings, headsetAvailable)
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedBeforePauseSec)
_ui.update { it.copy(runtimeParams = params, remainingSec = remaining) }
if (currentUi.config.mode != SessionMode.VISUAL_ONLY) {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
if (shouldPlayAudioNow) {
if (!isAudioPlaying) {
audioEngine.start(params.carrierHz, params.binauralHz)
isAudioPlaying = true
} else {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
}
} else if (isAudioPlaying) {
audioEngine.stop()
isAudioPlaying = false
}
if (elapsedBeforePauseSec >= durationSec.toFloat()) break
@@ -371,6 +435,10 @@ class MainViewModel(
}
}
private fun shouldPlayAudio(settings: AppSettings, headsetAvailable: Boolean): Boolean {
return settings.forceAudioWithoutHeadphones || headsetAvailable
}
private fun currentElapsedSec(): Float {
val state = _ui.value
val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f)

View File

@@ -1,17 +1,15 @@
package com.mindmachine.mvp.session
import com.mindmachine.mvp.domain.SessionConfig
import com.mindmachine.mvp.domain.SessionMode
object SessionValidator {
fun validate(config: SessionConfig, headsetAvailable: Boolean): String? {
fun validate(config: SessionConfig): String? {
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."
if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) {
return "Stereo headphones are required for binaural audio. Connect headphones or switch to Visual-only."
val carrier = config.carrierFrequencyHz
if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) {
return "Carrier frequency must be between ${ParameterRanges.CARRIER_HZ_MIN.toInt()} and ${ParameterRanges.CARRIER_HZ_MAX.toInt()} Hz."
}
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20."
return null
}
}