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)