Auto commit Thu Mar 19 05:01:02 PM CDT 2026
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<TimelinePoint>
|
||||
) {
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user