Auto commit Sun Mar 22 01:01:14 AM CDT 2026
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user