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

@@ -79,7 +79,6 @@ import com.mindmachine.mvp.audio.HeadsetMonitor
import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.data.UserProgramRepository
import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.domain.SessionMode
import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.SplitFlashFrame
@@ -144,6 +143,15 @@ fun App(vm: MainViewModel = viewModel()) {
onDispose { lifecycle.removeObserver(observer) }
}
if (!ui.settingsInitialized) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)
return
}
val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome"
NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
composable("welcome") {
@@ -235,10 +243,10 @@ fun App(vm: MainViewModel = viewModel()) {
}
}
composable("settings") {
SettingsScreen(vm) { nav.popBackStack() }
SettingsScreen(vm)
}
composable("holder") {
HolderScreen { nav.popBackStack() }
HolderScreen()
}
}
}
@@ -281,7 +289,6 @@ fun SetupScreen(
onHolder: () -> Unit,
) {
val ui by vm.ui.collectAsStateWithLifecycle()
val cfg = ui.config
val scrollState = rememberScrollState()
val containerModifier = Modifier
.fillMaxSize()
@@ -292,6 +299,12 @@ fun SetupScreen(
var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) }
val hasUnsavedChanges = setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges = ui.hasUnsavedChanges,
editedProgramName = programName,
savedProgramName = ui.savedProgramName,
)
Column(
modifier = containerModifier,
verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -323,10 +336,13 @@ fun SetupScreen(
TextButton(onClick = onHolder) { Text("Holder Guidance") }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
Text("Save")
if (hasUnsavedChanges) {
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
Text("Save")
}
} else {
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") }
}
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") }
}
if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
@@ -336,6 +352,15 @@ fun SetupScreen(
internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String =
"Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s"
internal fun setupScreenHasUnsavedChanges(
hasUnsavedTimelineChanges: Boolean,
editedProgramName: String,
savedProgramName: String,
): Boolean {
val nameChanged = editedProgramName.trim() != savedProgramName.trim()
return hasUnsavedTimelineChanges || nameChanged
}
internal fun setupScreenUsesScrollableContainer(): Boolean = true
@Composable
@@ -348,7 +373,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
onBackToSetup()
}
val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY
val isVisualMode = true
val context = LocalContext.current
DisposableEffect(isVisualMode) {
val activity = context as? Activity
@@ -376,7 +401,6 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
}
val flashFrame by rememberSplitFlashFrame(
isRunning = ui.runtimeState == RuntimeState.RUNNING,
mode = ui.config.mode,
flashOnMs = ui.runtimeParams.flashOnMs,
flashOffMs = ui.runtimeParams.flashOffMs,
)
@@ -506,15 +530,14 @@ private fun FlashColor.toComposeColor(): Color = when (this) {
@Composable
private fun rememberSplitFlashFrame(
isRunning: Boolean,
mode: SessionMode,
flashOnMs: Int,
flashOffMs: Int,
): androidx.compose.runtime.State<SplitFlashFrame> {
val latestFlashOnMs = rememberUpdatedState(flashOnMs)
val latestFlashOffMs = rememberUpdatedState(flashOffMs)
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning) {
if (!isRunning) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState
}
@@ -535,7 +558,7 @@ private fun rememberSplitFlashFrame(
}
@Composable
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
fun SettingsScreen(vm: MainViewModel) {
val ui by vm.ui.collectAsStateWithLifecycle()
Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
@@ -551,16 +574,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
steps = 9,
)
Text("Default mode", color = MaterialTheme.colorScheme.onBackground)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionMode.values().forEach { mode ->
OutlinedButton(onClick = { vm.updateDefaultMode(mode) }) { Text(mode.name) }
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance)
Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground)
Checkbox(
checked = ui.settings.forceAudioWithoutHeadphones,
onCheckedChange = vm::updateForceAudioWithoutHeadphones,
)
Text("force audio without headphones", color = MaterialTheme.colorScheme.onBackground)
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -570,12 +589,11 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
HorizontalDivider()
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", color = MaterialTheme.colorScheme.onBackground)
OutlinedButton(onClick = onBack) { Text("Back") }
}
}
@Composable
fun HolderScreen(onBack: () -> Unit) {
fun HolderScreen() {
Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -587,6 +605,5 @@ fun HolderScreen(onBack: () -> Unit) {
Text("• Allow airflow and comfort.", color = MaterialTheme.colorScheme.onBackground)
Text("• Test fit before session.", color = MaterialTheme.colorScheme.onBackground)
Text("• Sit or lie down in a safe place.", color = MaterialTheme.colorScheme.onBackground)
OutlinedButton(onClick = onBack) { Text("Back") }
}
}

View File

@@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.mindmachine.mvp.domain.AppSettings
import com.mindmachine.mvp.domain.SessionMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -19,8 +18,7 @@ class SettingsRepository(private val context: Context) {
val safetyVersion = intPreferencesKey("safety_version")
val countdownPref = stringPreferencesKey("countdown_pref")
val countdownSeconds = intPreferencesKey("countdown_seconds")
val defaultMode = stringPreferencesKey("default_mode")
val showGuidance = booleanPreferencesKey("show_guidance")
val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones")
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
val lastPresetId = stringPreferencesKey("last_preset")
}
@@ -32,8 +30,7 @@ class SettingsRepository(private val context: Context) {
countdownSeconds = p[Keys.countdownSeconds]
?: legacyCountdownStringToSeconds(p[Keys.countdownPref])
?: 5,
defaultModePreference = runCatching { SessionMode.valueOf(p[Keys.defaultMode] ?: "AUDIO_VISUAL") }.getOrDefault(SessionMode.AUDIO_VISUAL),
showHolderGuidanceBeforeSession = p[Keys.showGuidance] ?: false,
forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
lastPresetId = p[Keys.lastPresetId],
)
@@ -50,9 +47,14 @@ class SettingsRepository(private val context: Context) {
it[Keys.countdownSeconds] = value.coerceIn(0, 10)
}
suspend fun updateDefaultMode(value: SessionMode) = context.dataStore.edit { it[Keys.defaultMode] = value.name }
suspend fun updateGuidance(value: Boolean) = context.dataStore.edit { it[Keys.showGuidance] = value }
suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit { it[Keys.showImmersiveProgressBar] = value }
suspend fun updateForceAudioWithoutHeadphones(value: Boolean) = context.dataStore.edit {
it[Keys.forceAudioWithoutHeadphones] = value
}
suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit {
it[Keys.showImmersiveProgressBar] = value
}
suspend fun updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id }
private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) {

View File

@@ -34,8 +34,7 @@ data class AppSettings(
val safetyAcknowledged: Boolean = false,
val safetyAcknowledgedVersion: Int = 0,
val countdownSeconds: Int = 5,
val defaultModePreference: SessionMode = SessionMode.AUDIO_VISUAL,
val showHolderGuidanceBeforeSession: Boolean = false,
val forceAudioWithoutHeadphones: Boolean = false,
val showImmersiveProgressBar: Boolean = true,
val lastPresetId: String? = null,
)

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
}
}