diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt index e3b194f..f255f53 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -21,12 +21,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -34,7 +32,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.input.pointer.pointerInput @@ -48,7 +45,6 @@ import com.mindmachine.mvp.session.TimelineEditorState.ActiveCurve import kotlin.math.abs import kotlin.math.max import kotlin.math.min -import kotlin.math.roundToInt @Composable fun TimelineEditorScreen( @@ -73,7 +69,9 @@ fun TimelineEditorScreen( var revealSignal by remember { mutableIntStateOf(0) } TimelineEditorRoot( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), onRevealSystemBars = { revealSignal += 1 }, revealSignal = revealSignal, ) { @@ -117,16 +115,64 @@ private fun TimelineEditorContent( ) { var state by remember { mutableStateOf(TimelineEditorState.default()) } - Column(Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("Timeline Editor", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground) + // Simple preview playhead (UI-only for now). + LaunchedEffect(state.isPlaying, state.durationSec) { + if (!state.isPlaying) return@LaunchedEffect + val start = state.playheadSec + val t0 = System.currentTimeMillis() + while (state.isPlaying) { + val elapsed = (System.currentTimeMillis() - t0) / 1000f + val next = (start + elapsed) + val wrapped = if (next > state.durationSec) 0f else next + state = state.copy(playheadSec = wrapped) + kotlinx.coroutines.delay(16) + } + } + + Column( + Modifier + .fillMaxSize() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Timeline Editor", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground + ) OutlinedButton(onClick = onBack) { Text("Back") } } - DurationSlider( - durationSec = state.durationSec, - onDurationChanged = { state = state.copy(durationSec = it) }, - ) + Row( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text("Selected duration", color = MaterialTheme.colorScheme.onSurface) + Text( + formatDuration(state.selection.durationSec.coerceAtLeast(0)), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = { state = state.copy(isPlaying = !state.isPlaying) }) { + Text(if (state.isPlaying) "Pause" else "Play") + } + OutlinedButton(onClick = { state = state.copy(playheadSec = 0f) }) { + Text("Rewind") + } + } + } CurveSelector( active = state.activeCurve, @@ -141,14 +187,14 @@ private fun TimelineEditorContent( curveRight = state.visualRight, activeCurve = state.activeCurve, viewport = state.viewport, + selection = state.selection, + playheadSec = state.playheadSec, onViewportChanged = { state = state.copy(viewport = it) }, + onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, onUpdateCurve = { curveId, newCurve -> - state = when (curveId) { - ActiveCurve.VISUAL_LEFT -> state.copy(visualLeft = newCurve) - ActiveCurve.VISUAL_RIGHT -> state.copy(visualRight = newCurve) - ActiveCurve.AUDIO_LEFT -> state // ignored - ActiveCurve.AUDIO_RIGHT -> state // ignored - } + val candidate = state.withCurve(curveId, newCurve) + val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0 + state = candidate.extendDurationTo(maxT).copy(selection = candidate.selection.copy(endSec = max(candidate.selection.endSec, state.durationSec))) }, onTapSideSelect = { side -> state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT) @@ -157,20 +203,20 @@ private fun TimelineEditorContent( TimelineGraph( title = "Audio", - leftLabel = "Carrier", - rightLabel = "Binaural", + leftLabel = "Carrier Frequency", + rightLabel = "Binaural Beat", curveLeft = state.audioLeft, curveRight = state.audioRight, activeCurve = state.activeCurve, viewport = state.viewport, + selection = state.selection, + playheadSec = state.playheadSec, onViewportChanged = { state = state.copy(viewport = it) }, + onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, onUpdateCurve = { curveId, newCurve -> - state = when (curveId) { - ActiveCurve.AUDIO_LEFT -> state.copy(audioLeft = newCurve) - ActiveCurve.AUDIO_RIGHT -> state.copy(audioRight = newCurve) - ActiveCurve.VISUAL_LEFT -> state // ignored - ActiveCurve.VISUAL_RIGHT -> state // ignored - } + val candidate = state.withCurve(curveId, newCurve) + val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0 + state = candidate.extendDurationTo(maxT) }, onTapSideSelect = { side -> state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.AUDIO_LEFT else ActiveCurve.AUDIO_RIGHT) @@ -179,38 +225,22 @@ private fun TimelineEditorContent( Spacer(Modifier.height(4.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Button(onClick = { state = TimelineEditorState.default(durationSec = state.durationSec) }, modifier = Modifier.weight(1f)) { + Button( + onClick = { state = TimelineEditorState.default(durationSec = state.durationSec) }, + modifier = Modifier.weight(1f) + ) { Text("Reset") } - OutlinedButton(onClick = { /* TODO persist into session config */ }, modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { /* TODO persist into session config */ }, + modifier = Modifier.weight(1f) + ) { Text("Save") } } } } -@Composable -private fun DurationSlider( - durationSec: Int, - onDurationChanged: (Int) -> Unit, -) { - val minSec = 60 - val maxSec = 8 * 60 * 60 - val clamped = durationSec.coerceIn(minSec, maxSec) - val value = clamped.toFloat() - - Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)).padding(12.dp)) { - Text("Duration", color = MaterialTheme.colorScheme.onSurface) - Text(formatDuration(clamped), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) - Slider( - value = value, - onValueChange = { onDurationChanged(it.roundToInt().coerceIn(minSec, maxSec)) }, - valueRange = minSec.toFloat()..maxSec.toFloat(), - ) - Text("1 minute  8 hours", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodySmall) - } -} - private enum class Side { LEFT, RIGHT } @Composable @@ -218,7 +248,12 @@ private fun CurveSelector( active: ActiveCurve, onSelect: (ActiveCurve) -> Unit, ) { - Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)).padding(12.dp)) { + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) + .padding(12.dp) + ) { Text("Curve selection", color = MaterialTheme.colorScheme.onSurface) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Column(Modifier.weight(1f)) { @@ -237,7 +272,7 @@ private fun CurveSelector( } } Text( - "Tip: you can also tap left/right side of a graph to select the corresponding curve.", + "Tip: tap left/right side of a graph to select that curve. Double-tap between points to add a smoothed point.", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodySmall, ) @@ -264,6 +299,8 @@ private fun SelectorChip( } } +private enum class HandleDrag { NONE, START, END } + @Composable private fun TimelineGraph( title: String, @@ -273,7 +310,10 @@ private fun TimelineGraph( curveRight: TimelineCurve, activeCurve: ActiveCurve, viewport: TimelineViewport, + selection: TimelineSelection, + playheadSec: Float, onViewportChanged: (TimelineViewport) -> Unit, + onSelectionChanged: (TimelineSelection) -> Unit, onUpdateCurve: (ActiveCurve, TimelineCurve) -> Unit, onTapSideSelect: (Side) -> Unit, ) { @@ -286,7 +326,15 @@ private fun TimelineGraph( val context = LocalContext.current - Column(Modifier.fillMaxWidth().height(240.dp).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)).padding(10.dp)) { + var dragMode by remember { mutableStateOf(HandleDrag.NONE) } + + Column( + Modifier + .fillMaxWidth() + .height(260.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) + .padding(10.dp) + ) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(title, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium) Text( @@ -308,22 +356,43 @@ private fun TimelineGraph( Box( modifier = Modifier .fillMaxWidth() - .height(180.dp) + .height(190.dp) .background(Color(0xFF0A0E18), RoundedCornerShape(10.dp)) - .pointerInput(Unit) { + .pointerInput(viewport, selection) { detectDragGestures( + onDragStart = { start -> + val w = size.width.toFloat() + val startX = viewport.timeToX(selection.startSec, w) + val endX = viewport.timeToX(selection.endSec, w) + val hitPx = 32f + dragMode = when { + abs(start.x - startX) <= hitPx -> HandleDrag.START + abs(start.x - endX) <= hitPx -> HandleDrag.END + else -> HandleDrag.NONE + } + }, + onDragEnd = { dragMode = HandleDrag.NONE }, + onDragCancel = { dragMode = HandleDrag.NONE }, onDrag = { change, dragAmount -> - // If user drags without grabbing a point, treat as pan. - val didHitLeft = curveLeft.hitTestPoint(change.position, viewport, size.width.toFloat(), size.height.toFloat()) != null - val didHitRight = curveRight.hitTestPoint(change.position, viewport, size.width.toFloat(), size.height.toFloat()) != null - if (!didHitLeft && !didHitRight) { - val newViewport = viewport.applyZoomPan( - zoomChange = 1f, - panPx = dragAmount.x, - widthPx = size.width.toFloat(), - centroidPx = size.width.toFloat() / 2f, - ) - onViewportChanged(newViewport) + val w = size.width.toFloat() + val t = viewport.xToTimeSec(change.position.x + dragAmount.x, w).toInt().coerceAtLeast(0) + when (dragMode) { + HandleDrag.START -> onSelectionChanged(TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec)) + HandleDrag.END -> onSelectionChanged(TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60))) + HandleDrag.NONE -> { + // background pan (when not dragging a point) + val didHitLeft = curveLeft.hitTestPoint(change.position, viewport, w, size.height.toFloat()) != null + val didHitRight = curveRight.hitTestPoint(change.position, viewport, w, size.height.toFloat()) != null + if (!didHitLeft && !didHitRight) { + val newViewport = viewport.applyZoomPan( + zoomChange = 1f, + panPx = dragAmount.x, + widthPx = w, + centroidPx = w / 2f, + ) + onViewportChanged(newViewport) + } + } } } ) @@ -345,9 +414,21 @@ private fun TimelineGraph( val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT onTapSideSelect(side) }, - onDoubleTap = { /* TODO smooth */ }, + onDoubleTap = { offset -> + val curveId = when (activeCurve) { + leftId, rightId -> activeCurve + else -> leftId + } + val targetCurve = if (curveId == leftId) curveLeft else curveRight + val updated = targetCurve.insertSmoothedPointAt(offset, viewport, size.width.toFloat(), size.height.toFloat()) + onCurveEditedHaptic() + onUpdateCurve(curveId, updated) + }, onLongPress = { offset -> - val curveId = if (activeCurve == leftId || activeCurve == rightId) activeCurve else leftId + val curveId = when (activeCurve) { + leftId, rightId -> activeCurve + else -> leftId + } val targetCurve = if (curveId == leftId) curveLeft else curveRight val hit = targetCurve.hitTestPoint(offset, viewport, size.width.toFloat(), size.height.toFloat()) if (hit != null) { @@ -378,7 +459,6 @@ private fun TimelineGraph( } .pointerInput(activeCurve, viewport, curveLeft, curveRight) { detectDragGestures( - onDragStart = { /* no-op */ }, onDrag = { change, dragAmount -> val curveId = when (activeCurve) { leftId, rightId -> activeCurve @@ -402,17 +482,39 @@ private fun TimelineGraph( ) { Canvas(modifier = Modifier.fillMaxSize()) { drawGrid() + + // selection range overlay + handles + val startX = viewport.timeToX(selection.startSec, size.width) + val endX = viewport.timeToX(selection.endSec, size.width) + drawRect( + color = Color.White.copy(alpha = 0.06f), + topLeft = Offset(min(startX, endX), 0f), + size = androidx.compose.ui.geometry.Size(abs(endX - startX), size.height) + ) + drawLine(Color.White.copy(alpha = 0.35f), Offset(startX, 0f), Offset(startX, size.height), strokeWidth = 3f) + drawLine(Color.White.copy(alpha = 0.35f), Offset(endX, 0f), Offset(endX, size.height), strokeWidth = 3f) + + val handleColor = Color.White.copy(alpha = 0.9f) + drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f)) + drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f)) + + // playhead + val playX = viewport.timeToX(playheadSec.toInt(), size.width) + drawLine(Color(0xFFFFD54F).copy(alpha = 0.9f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f) + drawCurve(curveLeft, selected = selectedLeft, color = if (isVisual) Color(0xFF4D8BFF) else Color(0xFF55E39A), viewport = viewport) drawCurve(curveRight, selected = selectedRight, color = if (isVisual) Color(0xFF7AB8FF) else Color(0xFF2FCB7C), viewport = viewport) } } Text( - "Tap to add • Drag to move • Long-press to delete • Pinch to zoom • Drag background to pan (WIP)", + "Tap add • Drag point move • Long-press delete • Double-tap smooth • Pinch zoom • Drag background pan • Drag handles to set range", color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp) + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) ) } } @@ -444,7 +546,11 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurve( if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y) } val stroke = if (selected) 6f else 3f - drawPath(path, color = if (selected) color.copy(alpha = 1f) else color.copy(alpha = 0.55f), style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke)) + drawPath( + path, + color = if (selected) color.copy(alpha = 1f) else color.copy(alpha = 0.55f), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke) + ) pts.forEach { p -> val x = viewport.timeToX(p.tSec, size.width) diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt index 4aefec4..198a110 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -1,16 +1,20 @@ package com.mindmachine.mvp.session +import kotlin.math.abs import kotlin.math.max import kotlin.math.min data class TimelineEditorState( val durationSec: Int, + val selection: TimelineSelection, val viewport: TimelineViewport, val activeCurve: ActiveCurve, val visualLeft: TimelineCurve, val visualRight: TimelineCurve, val audioLeft: TimelineCurve, val audioRight: TimelineCurve, + val isPlaying: Boolean, + val playheadSec: Float, ) { enum class ActiveCurve { VISUAL_LEFT, @@ -21,26 +25,92 @@ data class TimelineEditorState( companion object { fun default(durationSec: Int = 20 * 60): TimelineEditorState { + val dur = durationSec.coerceIn(60, 8 * 60 * 60) val initial = TimelineCurve( points = listOf( TimelinePoint(0, 0.5f), - TimelinePoint(durationSec, 0.5f), + TimelinePoint(dur, 0.5f), ) ) return TimelineEditorState( - durationSec = durationSec, + durationSec = dur, + selection = TimelineSelection(0, dur), viewport = TimelineViewport( startSec = 0f, - secondsPerScreen = min(10f * 60f, durationSec.toFloat()) + secondsPerScreen = min(10f * 60f, dur.toFloat()) ), activeCurve = ActiveCurve.VISUAL_LEFT, visualLeft = initial, visualRight = initial, audioLeft = initial, audioRight = initial, + isPlaying = false, + playheadSec = 0f, ) } } + + fun curveFor(id: ActiveCurve): TimelineCurve = when (id) { + ActiveCurve.VISUAL_LEFT -> visualLeft + ActiveCurve.VISUAL_RIGHT -> visualRight + ActiveCurve.AUDIO_LEFT -> audioLeft + ActiveCurve.AUDIO_RIGHT -> audioRight + } + + fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState = when (id) { + ActiveCurve.VISUAL_LEFT -> copy(visualLeft = curve) + ActiveCurve.VISUAL_RIGHT -> copy(visualRight = curve) + ActiveCurve.AUDIO_LEFT -> copy(audioLeft = curve) + ActiveCurve.AUDIO_RIGHT -> copy(audioRight = curve) + } + + /** + * Spec: auto-extend timeline when user creates/moves points beyond current duration. + * Also preserve the last value to the new end of the session. + */ + fun extendDurationTo(atLeastSec: Int): TimelineEditorState { + val newDur = atLeastSec.coerceIn(60, 8 * 60 * 60) + if (newDur <= durationSec) return this + + fun extendCurve(c: TimelineCurve): TimelineCurve = c.extendTo(newDur) + + val updated = copy( + durationSec = newDur, + selection = selection.copy(endSec = max(selection.endSec, newDur)), + visualLeft = extendCurve(visualLeft), + visualRight = extendCurve(visualRight), + audioLeft = extendCurve(audioLeft), + audioRight = extendCurve(audioRight), + ) + + // Keep viewport zoom but ensure end is reachable by panning. + return updated + } + + fun clampSelection(): TimelineEditorState { + val minDur = 60 + val maxDur = 8 * 60 * 60 + val start = selection.startSec.coerceIn(0, durationSec) + var end = selection.endSec.coerceIn(0, durationSec) + if (end - start < minDur) end = (start + minDur).coerceAtMost(durationSec) + val clamped = TimelineSelection(start, end) + val newDuration = (clamped.endSec - clamped.startSec).coerceIn(minDur, maxDur) + // For now we keep selection anchored at 0; if start moved, reflect it but don't shift curves. + return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let { + it.copy(durationSec = it.durationSec.coerceIn(minDur, maxDur)) + } + } +} + +data class TimelineSelection( + val startSec: Int, + val endSec: Int, +) { + init { + require(endSec >= startSec) { "endSec must be >= startSec" } + } + + val durationSec: Int get() = endSec - startSec } data class TimelineViewport( @@ -53,6 +123,7 @@ data class TimelineViewport( widthPx: Float, centroidPx: Float, ): TimelineViewport { + // Spec: Minimum zoom = 30 seconds per screen width; maximum zoom = 24 hours visible. val minSecondsPerScreen = 30f val maxSecondsPerScreen = 24f * 60f * 60f @@ -76,13 +147,17 @@ data class TimelineViewport( val rel = tSec.toFloat() - startSec return (rel / secondsPerScreen) * widthPx } + + fun xToTimeSec(xPx: Float, widthPx: Float): Float { + return startSec + (xPx / widthPx) * secondsPerScreen + } } data class TimelineCurve( val points: List ) { fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve { - val t = (viewport.startSec + (offset.x / widthPx) * viewport.secondsPerScreen).toInt().coerceAtLeast(0) + val t = viewport.xToTimeSec(offset.x, widthPx).toInt().coerceAtLeast(0) val v = (1f - (offset.y / heightPx)).coerceIn(0f, 1f) return copy(points = (points + TimelinePoint(t, v)).sortedBy { it.tSec }) } @@ -93,7 +168,7 @@ data class TimelineCurve( } fun movePoint(index: Int, newPosition: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve { - val t = (viewport.startSec + (newPosition.x / widthPx) * viewport.secondsPerScreen).toInt().coerceAtLeast(0) + val t = viewport.xToTimeSec(newPosition.x, widthPx).toInt().coerceAtLeast(0) val v = (1f - (newPosition.y / heightPx)).coerceIn(0f, 1f) val updated = points.toMutableList() updated[index] = updated[index].copy(tSec = t, value01 = v) @@ -111,6 +186,36 @@ data class TimelineCurve( } return null } + + fun extendTo(newDurationSec: Int): TimelineCurve { + if (points.isEmpty()) return this + val sorted = points.sortedBy { it.tSec } + val last = sorted.last() + if (last.tSec >= newDurationSec) return this + return copy(points = (sorted + last.copy(tSec = newDurationSec)).sortedBy { it.tSec }) + } + + /** + * Double-tap gesture: "smooth" by inserting an interpolated point in the nearest segment. + */ + fun insertSmoothedPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve { + val t = viewport.xToTimeSec(offset.x, widthPx).toInt().coerceAtLeast(0) + val sorted = points.sortedBy { it.tSec } + if (sorted.size < 2) return this + + val rightIndex = sorted.indexOfFirst { it.tSec >= t }.let { if (it == -1) sorted.lastIndex else it } + val leftIndex = (rightIndex - 1).coerceAtLeast(0) + if (leftIndex == rightIndex) return this + + val a = sorted[leftIndex] + val b = sorted[rightIndex] + val tt = if (b.tSec == a.tSec) 0f else (t - a.tSec).toFloat() / (b.tSec - a.tSec).toFloat() + val v = a.value01 + (b.value01 - a.value01) * tt + + // Only insert if not extremely close to an existing point. + if (abs(a.tSec - t) < 1 || abs(b.tSec - t) < 1) return this + return copy(points = (sorted + TimelinePoint(t, v.coerceIn(0f, 1f))).sortedBy { it.tSec }) + } } data class TimelinePoint( diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineParameters.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineParameters.kt new file mode 100644 index 0000000..eb85e95 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineParameters.kt @@ -0,0 +1,102 @@ +package com.mindmachine.mvp.session + +import kotlin.math.ln +import kotlin.math.pow + +/** + * Parameter definitions + scaling helpers. + * + * Internally, the editor stores point values as normalized [0,1]. + * We map to real parameter values using a logarithmic scale where required. + */ +object TimelineParameters { + + enum class ParameterId { + BRIGHTNESS, + BLINK_RATE, + CARRIER_FREQUENCY, + BINAURAL_BEAT, + } + + data class Range( + val min: Float, + val max: Float, + val logScale: Boolean, + val units: String, + ) { + init { + require(min > 0f) { "min must be > 0 for log scaling math" } + require(max > min) { "max must be > min" } + } + } + + val ranges: Map = mapOf( + // Brightness is special-cased to allow true 0 while still using a log-ish curve above 0. + ParameterId.BRIGHTNESS to Range(min = 0.0001f, max = 1.0f, logScale = true, units = "%"), + ParameterId.BLINK_RATE to Range(min = 0.1f, max = 30f, logScale = true, units = "Hz"), + ParameterId.CARRIER_FREQUENCY to Range(min = 200f, max = 1200f, logScale = true, units = "Hz"), + ParameterId.BINAURAL_BEAT to Range(min = 0.5f, max = 30f, logScale = true, units = "Hz"), + ) + + fun parameterForCurve(curve: TimelineEditorState.ActiveCurve): ParameterId { + return when (curve) { + TimelineEditorState.ActiveCurve.VISUAL_LEFT -> ParameterId.BRIGHTNESS + TimelineEditorState.ActiveCurve.VISUAL_RIGHT -> ParameterId.BLINK_RATE + TimelineEditorState.ActiveCurve.AUDIO_LEFT -> ParameterId.CARRIER_FREQUENCY + TimelineEditorState.ActiveCurve.AUDIO_RIGHT -> ParameterId.BINAURAL_BEAT + } + } + + /** + * normalized [0,1] -> actual value in parameter units. + */ + fun denormalize(curve: TimelineEditorState.ActiveCurve, value01: Float): Float { + val p = parameterForCurve(curve) + val r = ranges.getValue(p) + val t = value01.coerceIn(0f, 1f) + + // Brightness: allow exact 0. + if (p == ParameterId.BRIGHTNESS && t <= 0f) return 0f + + return if (r.logScale) logDenormalize(t, r.min, r.max) else lerp(r.min, r.max, t) + } + + /** + * actual value -> normalized [0,1]. + */ + fun normalize(curve: TimelineEditorState.ActiveCurve, value: Float): Float { + val p = parameterForCurve(curve) + val r = ranges.getValue(p) + + // Brightness: allow exact 0. + if (p == ParameterId.BRIGHTNESS && value <= 0f) return 0f + + val clamped = value.coerceIn(r.min, r.max) + return if (r.logScale) logNormalize(clamped, r.min, r.max) else invLerp(r.min, r.max, clamped) + } + + private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t + private fun invLerp(a: Float, b: Float, v: Float): Float = ((v - a) / (b - a)).coerceIn(0f, 1f) + + private fun logNormalize(v: Float, min: Float, max: Float): Float { + // Map multiplicative range to linear [0,1]. + val lnMin = ln(min) + val lnMax = ln(max) + return ((ln(v) - lnMin) / (lnMax - lnMin)).toFloat().coerceIn(0f, 1f) + } + + private fun logDenormalize(t: Float, min: Float, max: Float): Float { + val lnMin = ln(min) + val lnMax = ln(max) + val lnV = lnMin + (lnMax - lnMin) * t + return kotlin.math.exp(lnV).toFloat().coerceIn(min, max) + } + + /** Brightness is stored in 0..1 but user-facing is 0..100%. */ + fun brightnessPercentFromNormalized(value01: Float): Int { + val t = value01.coerceIn(0f, 1f) + if (t <= 0f) return 0 + val v = logDenormalize(t, ranges.getValue(ParameterId.BRIGHTNESS).min, 1.0f) + return (v * 100f).toInt().coerceIn(0, 100) + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/TimelineParametersTest.kt b/app/src/test/java/com/mindmachine/mvp/TimelineParametersTest.kt new file mode 100644 index 0000000..6103a6c --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/TimelineParametersTest.kt @@ -0,0 +1,34 @@ +package com.mindmachine.mvp + +import com.mindmachine.mvp.session.TimelineEditorState +import com.mindmachine.mvp.session.TimelineParameters +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TimelineParametersTest { + + @Test + fun `log scale normalize-denormalize roundtrip is stable`() { + val curves = listOf( + TimelineEditorState.ActiveCurve.VISUAL_RIGHT, + TimelineEditorState.ActiveCurve.AUDIO_LEFT, + TimelineEditorState.ActiveCurve.AUDIO_RIGHT, + ) + + for (c in curves) { + val n = 0.37f + val v = TimelineParameters.denormalize(c, n) + val n2 = TimelineParameters.normalize(c, v) + assertTrue("Expected normalized roundtrip close for $c but got $n2", kotlin.math.abs(n - n2) < 1e-3f) + } + } + + @Test + fun `brightness percent helper stays in range`() { + assertEquals(0, TimelineParameters.brightnessPercentFromNormalized(0f)) + assertEquals(100, TimelineParameters.brightnessPercentFromNormalized(1f)) + val mid = TimelineParameters.brightnessPercentFromNormalized(0.5f) + assertTrue(mid in 1..99) + } +}