Auto commit Thu Mar 19 05:01:02 PM CDT 2026

This commit is contained in:
Tretzi
2026-03-19 17:01:02 -05:00
parent aa0b51fbc4
commit c7518b7e9c
4 changed files with 424 additions and 77 deletions

View File

@@ -21,12 +21,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -34,7 +32,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.input.pointer.pointerInput 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.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt
@Composable @Composable
fun TimelineEditorScreen( fun TimelineEditorScreen(
@@ -73,7 +69,9 @@ fun TimelineEditorScreen(
var revealSignal by remember { mutableIntStateOf(0) } var revealSignal by remember { mutableIntStateOf(0) }
TimelineEditorRoot( TimelineEditorRoot(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
onRevealSystemBars = { revealSignal += 1 }, onRevealSystemBars = { revealSignal += 1 },
revealSignal = revealSignal, revealSignal = revealSignal,
) { ) {
@@ -117,16 +115,64 @@ private fun TimelineEditorContent(
) { ) {
var state by remember { mutableStateOf(TimelineEditorState.default()) } var state by remember { mutableStateOf(TimelineEditorState.default()) }
Column(Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { // Simple preview playhead (UI-only for now).
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { LaunchedEffect(state.isPlaying, state.durationSec) {
Text("Timeline Editor", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground) 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") } OutlinedButton(onClick = onBack) { Text("Back") }
} }
DurationSlider( Row(
durationSec = state.durationSec, Modifier
onDurationChanged = { state = state.copy(durationSec = it) }, .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( CurveSelector(
active = state.activeCurve, active = state.activeCurve,
@@ -141,14 +187,14 @@ private fun TimelineEditorContent(
curveRight = state.visualRight, curveRight = state.visualRight,
activeCurve = state.activeCurve, activeCurve = state.activeCurve,
viewport = state.viewport, viewport = state.viewport,
selection = state.selection,
playheadSec = state.playheadSec,
onViewportChanged = { state = state.copy(viewport = it) }, onViewportChanged = { state = state.copy(viewport = it) },
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
onUpdateCurve = { curveId, newCurve -> onUpdateCurve = { curveId, newCurve ->
state = when (curveId) { val candidate = state.withCurve(curveId, newCurve)
ActiveCurve.VISUAL_LEFT -> state.copy(visualLeft = newCurve) val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
ActiveCurve.VISUAL_RIGHT -> state.copy(visualRight = newCurve) state = candidate.extendDurationTo(maxT).copy(selection = candidate.selection.copy(endSec = max(candidate.selection.endSec, state.durationSec)))
ActiveCurve.AUDIO_LEFT -> state // ignored
ActiveCurve.AUDIO_RIGHT -> state // ignored
}
}, },
onTapSideSelect = { side -> onTapSideSelect = { side ->
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT) state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT)
@@ -157,20 +203,20 @@ private fun TimelineEditorContent(
TimelineGraph( TimelineGraph(
title = "Audio", title = "Audio",
leftLabel = "Carrier", leftLabel = "Carrier Frequency",
rightLabel = "Binaural", rightLabel = "Binaural Beat",
curveLeft = state.audioLeft, curveLeft = state.audioLeft,
curveRight = state.audioRight, curveRight = state.audioRight,
activeCurve = state.activeCurve, activeCurve = state.activeCurve,
viewport = state.viewport, viewport = state.viewport,
selection = state.selection,
playheadSec = state.playheadSec,
onViewportChanged = { state = state.copy(viewport = it) }, onViewportChanged = { state = state.copy(viewport = it) },
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
onUpdateCurve = { curveId, newCurve -> onUpdateCurve = { curveId, newCurve ->
state = when (curveId) { val candidate = state.withCurve(curveId, newCurve)
ActiveCurve.AUDIO_LEFT -> state.copy(audioLeft = newCurve) val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
ActiveCurve.AUDIO_RIGHT -> state.copy(audioRight = newCurve) state = candidate.extendDurationTo(maxT)
ActiveCurve.VISUAL_LEFT -> state // ignored
ActiveCurve.VISUAL_RIGHT -> state // ignored
}
}, },
onTapSideSelect = { side -> onTapSideSelect = { side ->
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.AUDIO_LEFT else ActiveCurve.AUDIO_RIGHT) 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)) Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.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") 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") 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 } private enum class Side { LEFT, RIGHT }
@Composable @Composable
@@ -218,7 +248,12 @@ private fun CurveSelector(
active: ActiveCurve, active: ActiveCurve,
onSelect: (ActiveCurve) -> Unit, 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) Text("Curve selection", color = MaterialTheme.colorScheme.onSurface)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
@@ -237,7 +272,7 @@ private fun CurveSelector(
} }
} }
Text( 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, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
@@ -264,6 +299,8 @@ private fun SelectorChip(
} }
} }
private enum class HandleDrag { NONE, START, END }
@Composable @Composable
private fun TimelineGraph( private fun TimelineGraph(
title: String, title: String,
@@ -273,7 +310,10 @@ private fun TimelineGraph(
curveRight: TimelineCurve, curveRight: TimelineCurve,
activeCurve: ActiveCurve, activeCurve: ActiveCurve,
viewport: TimelineViewport, viewport: TimelineViewport,
selection: TimelineSelection,
playheadSec: Float,
onViewportChanged: (TimelineViewport) -> Unit, onViewportChanged: (TimelineViewport) -> Unit,
onSelectionChanged: (TimelineSelection) -> Unit,
onUpdateCurve: (ActiveCurve, TimelineCurve) -> Unit, onUpdateCurve: (ActiveCurve, TimelineCurve) -> Unit,
onTapSideSelect: (Side) -> Unit, onTapSideSelect: (Side) -> Unit,
) { ) {
@@ -286,7 +326,15 @@ private fun TimelineGraph(
val context = LocalContext.current 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) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(title, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium) Text(title, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium)
Text( Text(
@@ -308,24 +356,45 @@ private fun TimelineGraph(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(190.dp)
.background(Color(0xFF0A0E18), RoundedCornerShape(10.dp)) .background(Color(0xFF0A0E18), RoundedCornerShape(10.dp))
.pointerInput(Unit) { .pointerInput(viewport, selection) {
detectDragGestures( 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 -> onDrag = { change, dragAmount ->
// If user drags without grabbing a point, treat as pan. val w = size.width.toFloat()
val didHitLeft = curveLeft.hitTestPoint(change.position, viewport, size.width.toFloat(), size.height.toFloat()) != null val t = viewport.xToTimeSec(change.position.x + dragAmount.x, w).toInt().coerceAtLeast(0)
val didHitRight = curveRight.hitTestPoint(change.position, viewport, size.width.toFloat(), size.height.toFloat()) != null 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) { if (!didHitLeft && !didHitRight) {
val newViewport = viewport.applyZoomPan( val newViewport = viewport.applyZoomPan(
zoomChange = 1f, zoomChange = 1f,
panPx = dragAmount.x, panPx = dragAmount.x,
widthPx = size.width.toFloat(), widthPx = w,
centroidPx = size.width.toFloat() / 2f, centroidPx = w / 2f,
) )
onViewportChanged(newViewport) onViewportChanged(newViewport)
} }
} }
}
}
) )
} }
.pointerInput(activeCurve, viewport, curveLeft, curveRight) { .pointerInput(activeCurve, viewport, curveLeft, curveRight) {
@@ -345,9 +414,21 @@ private fun TimelineGraph(
val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT
onTapSideSelect(side) 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 -> 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 targetCurve = if (curveId == leftId) curveLeft else curveRight
val hit = targetCurve.hitTestPoint(offset, viewport, size.width.toFloat(), size.height.toFloat()) val hit = targetCurve.hitTestPoint(offset, viewport, size.width.toFloat(), size.height.toFloat())
if (hit != null) { if (hit != null) {
@@ -378,7 +459,6 @@ private fun TimelineGraph(
} }
.pointerInput(activeCurve, viewport, curveLeft, curveRight) { .pointerInput(activeCurve, viewport, curveLeft, curveRight) {
detectDragGestures( detectDragGestures(
onDragStart = { /* no-op */ },
onDrag = { change, dragAmount -> onDrag = { change, dragAmount ->
val curveId = when (activeCurve) { val curveId = when (activeCurve) {
leftId, rightId -> activeCurve leftId, rightId -> activeCurve
@@ -402,17 +482,39 @@ private fun TimelineGraph(
) { ) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
drawGrid() 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(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) drawCurve(curveRight, selected = selectedRight, color = if (isVisual) Color(0xFF7AB8FF) else Color(0xFF2FCB7C), viewport = viewport)
} }
} }
Text( 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), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center, 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) if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y)
} }
val stroke = if (selected) 6f else 3f 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 -> pts.forEach { p ->
val x = viewport.timeToX(p.tSec, size.width) val x = viewport.timeToX(p.tSec, size.width)

View File

@@ -1,16 +1,20 @@
package com.mindmachine.mvp.session package com.mindmachine.mvp.session
import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
data class TimelineEditorState( data class TimelineEditorState(
val durationSec: Int, val durationSec: Int,
val selection: TimelineSelection,
val viewport: TimelineViewport, val viewport: TimelineViewport,
val activeCurve: ActiveCurve, val activeCurve: ActiveCurve,
val visualLeft: TimelineCurve, val visualLeft: TimelineCurve,
val visualRight: TimelineCurve, val visualRight: TimelineCurve,
val audioLeft: TimelineCurve, val audioLeft: TimelineCurve,
val audioRight: TimelineCurve, val audioRight: TimelineCurve,
val isPlaying: Boolean,
val playheadSec: Float,
) { ) {
enum class ActiveCurve { enum class ActiveCurve {
VISUAL_LEFT, VISUAL_LEFT,
@@ -21,26 +25,92 @@ data class TimelineEditorState(
companion object { companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState { fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val dur = durationSec.coerceIn(60, 8 * 60 * 60)
val initial = TimelineCurve( val initial = TimelineCurve(
points = listOf( points = listOf(
TimelinePoint(0, 0.5f), TimelinePoint(0, 0.5f),
TimelinePoint(durationSec, 0.5f), TimelinePoint(dur, 0.5f),
) )
) )
return TimelineEditorState( return TimelineEditorState(
durationSec = durationSec, durationSec = dur,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport( viewport = TimelineViewport(
startSec = 0f, startSec = 0f,
secondsPerScreen = min(10f * 60f, durationSec.toFloat()) secondsPerScreen = min(10f * 60f, dur.toFloat())
), ),
activeCurve = ActiveCurve.VISUAL_LEFT, activeCurve = ActiveCurve.VISUAL_LEFT,
visualLeft = initial, visualLeft = initial,
visualRight = initial, visualRight = initial,
audioLeft = initial, audioLeft = initial,
audioRight = 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( data class TimelineViewport(
@@ -53,6 +123,7 @@ data class TimelineViewport(
widthPx: Float, widthPx: Float,
centroidPx: Float, centroidPx: Float,
): TimelineViewport { ): TimelineViewport {
// Spec: Minimum zoom = 30 seconds per screen width; maximum zoom = 24 hours visible.
val minSecondsPerScreen = 30f val minSecondsPerScreen = 30f
val maxSecondsPerScreen = 24f * 60f * 60f val maxSecondsPerScreen = 24f * 60f * 60f
@@ -76,13 +147,17 @@ data class TimelineViewport(
val rel = tSec.toFloat() - startSec val rel = tSec.toFloat() - startSec
return (rel / secondsPerScreen) * widthPx return (rel / secondsPerScreen) * widthPx
} }
fun xToTimeSec(xPx: Float, widthPx: Float): Float {
return startSec + (xPx / widthPx) * secondsPerScreen
}
} }
data class TimelineCurve( data class TimelineCurve(
val points: List<TimelinePoint> val points: List<TimelinePoint>
) { ) {
fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve { 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) val v = (1f - (offset.y / heightPx)).coerceIn(0f, 1f)
return copy(points = (points + TimelinePoint(t, v)).sortedBy { it.tSec }) 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 { 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 v = (1f - (newPosition.y / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList() val updated = points.toMutableList()
updated[index] = updated[index].copy(tSec = t, value01 = v) updated[index] = updated[index].copy(tSec = t, value01 = v)
@@ -111,6 +186,36 @@ data class TimelineCurve(
} }
return null 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( data class TimelinePoint(

View File

@@ -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<ParameterId, Range> = 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)
}
}

View File

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