Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/LUMOS_SEQUENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# LumosSequence

A `LumosSequence` is an orb's choreography as data: steps of look, form,
atmosphere and glyph with durations and transitions. One definition is captured
by `Oscilloscope` into a `.vxt` with named segments and played live by
`SignalGenerator`, frame for frame, so a consumer authors an orb's "then this"
once and ships it as JSON.

It lives in `:phosphor-lumos`, is `@Serializable`, and needs no framework and no
coroutines. `LumosSequencePlayer` is its clock.

## Example

```json
{
"version": 1,
"loop": true,
"steps": [
{ "name": "idle", "durationSeconds": 2.0, "atmospherePreset": "idle", "loop": true },
{ "name": "idle→thinking", "durationSeconds": 0.8, "atmospherePreset": "thinking" },
{ "name": "thinking", "durationSeconds": 2.0, "atmospherePreset": "thinking", "loop": true,
"look": { "look": "WORKING" } },
{ "name": "thinking→idle", "durationSeconds": 1.0, "atmospherePreset": "idle",
"transition": { "durationSeconds": 1.0, "easingName": "settled" } },
{ "name": "done", "durationSeconds": 1.5, "glyph": "CHECK" },
{ "name": "idle→fibonacci", "durationSeconds": 2.5, "form": "fibonacci",
"transition": { "durationSeconds": 2.4, "choreography": "SCATTER" } },
{ "name": "fibonacci→idle", "durationSeconds": 2.5, "form": "sphere" }
]
}
```

```kotlin
val sequence = LumosSequence.fromJson(text).getOrThrow()

// Live, the generator being the clock:
val player = generator.play(sequence)
player.pause(); player.seek(3f); player.play()

// Live, under an orb's clock (Compose):
val source = remember { LiveFrameSource(AtmospherePresets.IDLE).also { it.follow(player) } }
LumosOrb(source, player, modifier = Modifier.size(160.dp))

// Captured, one TraceSegment per step:
val trace = Oscilloscope.capture(CaptureConfig.forSequence(sequence, fps = 24)).getOrThrow()
TraceStateMachine(trace, initialState = "idle").requestState("thinking")
```

## The step

| Field | Meaning |
|---|---|
| `name` | Non-blank, unique in the sequence. Becomes the `TraceSegment` name, so follow its convention: a resting beat `"idle"`, a transition `"idle→thinking"`, a morph `"sphere→fibonacci"`. |
| `durationSeconds` | The step's length including its arriving transition, `0.05..3600`. |
| `look` | `{ "look": "WORKING", "variant": "gyro" }`; variant optional. Blends over `VoxelFrameBuilder.LOOK_BLEND_SECONDS` unless `transition` says. |
| `form` | A catalogue name (`fibonacci`, `cube`) or `sphere`, the lattice at the atmosphere's resolution. Morphs over `VoxelFrameBuilder.DEFAULT_MORPH_SECONDS` unless `transition` says. |
| `atmospherePreset` | A preset name, case-insensitive. Transitions on the `AtmosphereChoreographer` table unless `transition` says. |
| `glyph` | A `LumosGlyph` name. Fires on entering the step, every time, for 1.5 s unless `transition.durationSeconds` says. |
| `transition` | `{ durationSeconds, choreography = FLOW, easingName = "easeInOut" }`. No longer than the step. `choreography` is read by forms only. |
| `loop` | Carried onto the captured segment. Does not change how the live sequence advances. |

A step names exactly one of `look`, `form`, `atmospherePreset`, `glyph`, or a
`look` together with an `atmospherePreset`.

**A step that names what the orb already shows applies nothing.** The orb rests
in the state the previous one-shot landed, so `"thinking"` after
`"idle→thinking"` names `atmospherePreset: "thinking"` again without restarting
the transition, and `sample` reports no arrival for it. Glyphs are the exception:
they are one-shots and fire on every entry, including after a wrap.

**A `loop` step should start where its arrival has landed**, as a cue-captured
loop does. A loop step that carries its own transition pops when the recording
wraps. Write the transition as its own step, named with the arrow.

## Time

`sequence.sample(seconds)` is a pure seek: which step, how far in, whether its
arrival is still in flight, how many times the sequence has wrapped. Step
boundaries are compared with a 0.1 ms tolerance, so a clock accumulated as
`Float` lands the frame the author expects; write durations with enough digits
to land on whole frames at your capture rate (`20 / 24` is `0.83333`, not `0.8`).

`LumosSequencePlayer` ticks only while **connected** (`connect()` returned a
handle nobody released), **uncontrolled** (no `control()` handle outstanding)
and **playing**. Each `advance(dt)` is capped at 0.1 s, the same cap `OrbStepper`
puts on a frame gap. `control()` is how a scroll position or any external driver
owns time: while it holds, `advance` moves nothing and `seek` is the clock, and
the renderer keeps drawing. `connect()` lets several consumers share one clock.

Who advances the player:

| Driver | Clock |
|---|---|
| `SignalGenerator.play(sequence)` | the generator, by `1 / fps` after each `nextFrame`; frame `k` is the sequence at `k / fps` |
| `SignalGenerator.play(player)` | someone else; the generator only follows |
| `LumosOrb(source, player)` | the orb, by each frame's `dt`, connected only while the orb is on screen, started and not paused |
| `Oscilloscope.capture` with `CaptureConfig.sequence` | the fixed step, through `SignalGenerator.play(sequence)` |

## Capture

`CaptureConfig.forSequence(sequence, fps, …)` sizes a clip to one pass of the
sequence. Each step becomes one `TraceSegment(name, start, end, loop)`; the
layout is found by stepping a player at `1 / fps`, so it cannot disagree with
the frames. A step shorter than a frame, a rate under 10 fps, or a sequence given
alongside `segments`, `cues`, `formCues` or `phaseCues` is a
`CaptureError.InvalidConfig`. The trace's `paramSnapshot` carries the sequence's
JSON under `"sequence"`, so a clip can be traced back to its script.

Cue-driven capture produces the same frames: a sequence step naming a preset is
the `AtmosphereCue` at its first frame, a form step the `FormCue`.
`FormSeamByteIdentityTest` and `StateMachineDemoSequenceTest` hold the two
paths to the same bytes.

## Validation

`LumosSequence.fromJson` returns a `Result` whose failure is a sealed
`LumosSequenceError`:

| Error | When |
|---|---|
| `Malformed` | not a sequence document, or a value no type accepts (a `Look` variant its look does not offer) |
| `UnsupportedVersion` | `version` is not `1` |
| `StepCount` | fewer than 1 or more than 128 steps |
| `Step(index, name, reason)` | blank or duplicate name; duration out of range; not exactly one primary field; transition longer than the step or negative; unknown form, preset or easing |

The constructor throws the same errors, so a sequence that exists is valid.

## Not here

The `timeline { }` DSL in `phosphor-core` is code, not data, and is untouched.
Scroll-driven progress is enabled by `control()` but no scroll code lives in
Phosphor. Steps are discrete; interpolating arbitrary numeric channels between
them is not in the format.
Original file line number Diff line number Diff line change
Expand Up @@ -349,12 +349,25 @@ class AtmosphereChoreographer(
private fun resolveSpec(
fromName: String?,
toName: String?,
): AtmosphereTransitionSpec {
if (fromName == null || toName == null) return DefaultSpec
return DefaultTransitionTable[fromName to toName] ?: DefaultSpec
}
): AtmosphereTransitionSpec = transitionSpec(fromName, toName)

companion object {
/**
* The duration and easing the table gives a transition from the preset
* named [fromName] to the one named [toName], or [DefaultSpec] when
* either is null or the pair is not tabled, matched exactly against the
* lower-case names in `AtmospherePresets.ALL`. What [setAtmosphere] uses
* when no spec is passed, exposed so a `LumosSequence` can say how long
* a step's arrival lasts without replaying it.
*/
fun transitionSpec(
fromName: String?,
toName: String?,
): AtmosphereTransitionSpec {
if (fromName == null || toName == null) return DefaultSpec
return DefaultTransitionTable[fromName to toName] ?: DefaultSpec
}

/**
* Fallback transition spec used when the lookup against
* [DefaultTransitionTable] does not match. 1.1 seconds, easeInOut.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ class TimelineBuilder {
/**
* Create a timeline using the DSL.
*
* This is code: phases carry lambdas, so a timeline cannot be written as data or
* shipped in a file. The Lumos orb's choreography — steps of look, form,
* atmosphere and glyph with durations — is `LumosSequence` in `:phosphor-lumos`,
* which is `@Serializable`, captured by `Oscilloscope` and played live by
* `SignalGenerator` from one definition. Reach for that when the "then this" is
* an orb's; reach for this when it is arbitrary code.
*
* Example:
* ```kotlin
* val timeline = timeline {
Expand Down
2 changes: 2 additions & 0 deletions phosphor-lumos-cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ tasks.register<JavaExec>("runStateMachineDemo") {
val jvmMainCompilation = kotlin.targets.getByName("jvm").compilations.getByName("main")
dependsOn(jvmMainCompilation.compileTaskProvider)
classpath = files(jvmMainCompilation.output.allOutputs, jvmMainCompilation.runtimeDependencyFiles)
// The demo's script is a resource, demo-sequence.json, so the resources must be processed too.
dependsOn("jvmProcessResources")
mainClass.set("link.socket.phosphor.lumos.cli.demo.StateMachineDemoKt")
// Keypresses drive the state changes, so the task must own stdin as well as stdout.
standardInput = System.`in`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,13 @@ import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import link.socket.phosphor.lumos.Forms
import link.socket.phosphor.lumos.LumosForm
import link.socket.phosphor.lumos.LumosSequence
import link.socket.phosphor.lumos.cli.projection.CliLattice
import link.socket.phosphor.lumos.cli.renderer.CliOrb
import link.socket.phosphor.lumos.cli.renderer.DefaultTerminalSize
import link.socket.phosphor.palette.AtmospherePresets
import link.socket.phosphor.signal.AtmosphereState
import link.socket.phosphor.trace.AtmosphereCue
import link.socket.phosphor.trace.CaptureConfig
import link.socket.phosphor.trace.CaptureParams
import link.socket.phosphor.trace.FormCue
import link.socket.phosphor.trace.Oscilloscope
import link.socket.phosphor.trace.TraceSegment
import link.socket.phosphor.trace.TraceStateMachine
Expand Down Expand Up @@ -45,14 +41,15 @@ import link.socket.phosphor.trace.seamErrors
* falls through to the crossfade, which drags cells and is the pop the morph
* exists to avoid.
*
* The clip is captured fresh on every run, so the segment layout in the status
* header is the layout the state machine is actually reading. It is captured
* with a baked seam blend (PHO-43), so a loop left alone — idle, most of the
* orb's life — iterates without popping too; the header prints each loop's
* measured seam. Each loop follows its transition directly: the blend fades
* toward a lead-in synthesized from the loop's own steady state (PHO-49), so no
* one-shot carries settle padding and `state` flips as soon as the authored
* motion lands.
* The clip is captured fresh on every run from [DEMO_SEQUENCE_RESOURCE], a
* `LumosSequence` written as JSON (PHO-65): thirteen steps, each the segment the
* state machine reads, so the layout in the status header is the one the data
* says. It is the first clip authored in the format. The capture bakes a seam
* blend (PHO-43), so a loop left alone — idle, most of the orb's life — iterates
* without popping too; the header prints each loop's measured seam. Each loop
* follows its transition directly: the blend fades toward a lead-in synthesized
* from the loop's own steady state (PHO-49), so no one-shot carries settle
* padding and `state` flips as soon as the authored motion lands.
*
* Not a test. Nothing here is asserted; it is meant to be watched.
*
Expand Down Expand Up @@ -81,58 +78,22 @@ private const val UNCERTAIN = "uncertain"
private const val FIBONACCI = "fibonacci"
private const val CUBE = "cube"

/** The morph's own 2.4 s at 24 fps, rounded up, so the loop that follows starts settled. */
private const val MORPH_FRAMES = 58

/** The lattice every preset renders on, to morph back to. */
private val SPHERE: LumosForm = Forms.Lattice(AtmospherePresets.IDLE.resolution)

/**
* One stretch of the captured clip.
*
* @property name Segment name written into the trace, arrow convention included.
* @property frames How many captured frames the stretch covers.
* @property loop Whether a player should loop the stretch or play it once.
* @property cue Atmosphere to fire at the stretch's first frame, or null to carry on.
* @property form Form to morph to at the stretch's first frame, or null to keep the current one.
*/
private data class Beat(
val name: String,
val frames: Int,
val loop: Boolean,
val cue: AtmosphereState? = null,
val form: LumosForm? = null,
)

/**
* The capture script.
* The capture script, as data: the bundled `LumosSequence`.
*
* Three resting loops and four of the six directed transitions between them.
* Transition lengths are the authored durations in `AtmosphereChoreographer`'s
* table — 0.8 s, 1.0 s, 1.4 s, 1.15 s at 24 fps — rounded up to whole frames,
* so each one has landed by the time the loop that follows starts on the very
* next frame. Nothing is padded: the loop's tail fades toward a lead-in
* synthesized from its own steady state, not toward these frames (PHO-49). The
* two `thinking↔uncertain` pairs are left out on purpose: they are what the
* crossfade covers.
* Three resting loops and four of the six directed transitions between them,
* then the lattice reshaping into each catalogue form and back (PHO-57).
* Transition steps last the authored durations in `AtmosphereChoreographer`'s
* table — 0.8 s, 1.0 s, 1.4 s, 1.15 s — and the morph's 2.4 s, each rounded up to
* whole frames at 24 fps and written with enough digits to land on them, so each
* one has landed by the time the loop that follows starts on the very next
* frame. Nothing is padded: a loop's tail fades toward a lead-in synthesized
* from its own steady state, not toward these frames (PHO-49). The two
* `thinking↔uncertain` pairs are left out on purpose: they are what the
* crossfade covers. Every loop step names the state the one-shot before it
* landed, which applies nothing — the format's rule for resting beats.
*/
private val SCRIPT =
listOf(
Beat(IDLE, frames = 48, loop = true),
Beat("$IDLE→$THINKING", frames = 20, loop = false, cue = AtmospherePresets.THINKING),
Beat(THINKING, frames = 48, loop = true),
Beat("$THINKING→$IDLE", frames = 24, loop = false, cue = AtmospherePresets.IDLE),
Beat("$IDLE→$UNCERTAIN", frames = 34, loop = false, cue = AtmospherePresets.UNCERTAIN),
Beat(UNCERTAIN, frames = 48, loop = true),
Beat("$UNCERTAIN→$IDLE", frames = 28, loop = false, cue = AtmospherePresets.IDLE),
// Form morphs, idle only: the lattice reshaping into each catalogue form and back (PHO-57).
Beat("$IDLE→$FIBONACCI", frames = MORPH_FRAMES, loop = false, form = Forms.Fibonacci),
Beat(FIBONACCI, frames = 48, loop = true),
Beat("$FIBONACCI→$IDLE", frames = MORPH_FRAMES, loop = false, form = SPHERE),
Beat("$IDLE→$CUBE", frames = MORPH_FRAMES, loop = false, form = Forms.Cube),
Beat(CUBE, frames = 48, loop = true),
Beat("$CUBE→$IDLE", frames = MORPH_FRAMES, loop = false, form = SPHERE),
)
internal const val DEMO_SEQUENCE_RESOURCE = "/lumos/demo-sequence.json"

fun main(args: Array<String>) {
val runSeconds = args.getOrNull(0)?.toIntOrNull()?.coerceAtLeast(1) ?: DEFAULT_SECONDS
Expand All @@ -147,43 +108,35 @@ fun main(args: Array<String>) {
withRawKeys { play(trace, runSeconds) }
}

/** Read the bundled sequence; a malformed resource is a build error, so this throws. */
internal fun demoSequence(): LumosSequence {
val text =
checkNotNull(object {}.javaClass.getResourceAsStream(DEMO_SEQUENCE_RESOURCE)) {
"$DEMO_SEQUENCE_RESOURCE is not on the classpath"
}.use { stream -> stream.readBytes().decodeToString() }
return LumosSequence.fromJson(text).getOrThrow()
}

/**
* Capture [SCRIPT] into a trace. [seamBlendFrames] is a parameter so a test can
* capture the same script unblended and measure what the blend changed.
* Capture the bundled sequence into a trace. [seamBlendFrames] is a parameter so
* a test can capture the same script unblended and measure what the blend changed.
*/
internal fun captureDemoTrace(seamBlendFrames: Int = SEAM_BLEND_FRAMES): Result<VoxelTrace> {
val totalFrames = SCRIPT.sumOf(Beat::frames)
val segments = ArrayList<TraceSegment>(SCRIPT.size)
val cues = ArrayList<AtmosphereCue>()
val formCues = ArrayList<FormCue>()
var cursor = 0

SCRIPT.forEach { beat ->
segments += TraceSegment(beat.name, cursor, cursor + beat.frames - 1, beat.loop)
beat.cue?.let { cues += AtmosphereCue(atFrame = cursor, atmosphere = it) }
beat.form?.let { formCues += FormCue(atFrame = cursor, form = it) }
cursor += beat.frames
}

return Oscilloscope.capture(
CaptureConfig(
internal fun captureDemoTrace(seamBlendFrames: Int = SEAM_BLEND_FRAMES): Result<VoxelTrace> =
Oscilloscope.capture(
CaptureConfig.forSequence(
sequence = demoSequence(),
fps = CAPTURE_FPS,
params = CaptureParams(atmosphere = AtmospherePresets.IDLE),
seed = 0x10_5E55L,
durationMs = (totalFrames * 1_000L + CAPTURE_FPS - 1) / CAPTURE_FPS,
fps = CAPTURE_FPS,
segments = segments,
cues = cues,
seamBlendFrames = seamBlendFrames,
formCues = formCues,
),
)
}

private fun describe(trace: VoxelTrace) {
val loops = trace.segments.filter(TraceSegment::loop).map(TraceSegment::name)
val recorded = trace.segments.filterNot(TraceSegment::loop).map(TraceSegment::name)

println("Lumos state-driven playback - PHO-39")
println("Lumos state-driven playback - PHO-39, scripted by $DEMO_SEQUENCE_RESOURCE (PHO-65)")
println(
"frames=${trace.frameCount} fps=${trace.fps} resolution=${trace.staticLattice.resolution} " +
"cells/frame=${trace.cellCounts.maxOrNull() ?: 0}",
Expand Down
19 changes: 19 additions & 0 deletions phosphor-lumos-cli/src/jvmMain/resources/lumos/demo-sequence.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"version": 1,
"loop": true,
"steps": [
{ "name": "idle", "durationSeconds": 2.0, "atmospherePreset": "idle", "loop": true },
{ "name": "idle→thinking", "durationSeconds": 0.83333, "atmospherePreset": "thinking" },
{ "name": "thinking", "durationSeconds": 2.0, "atmospherePreset": "thinking", "loop": true },
{ "name": "thinking→idle", "durationSeconds": 1.0, "atmospherePreset": "idle" },
{ "name": "idle→uncertain", "durationSeconds": 1.41667, "atmospherePreset": "uncertain" },
{ "name": "uncertain", "durationSeconds": 2.0, "atmospherePreset": "uncertain", "loop": true },
{ "name": "uncertain→idle", "durationSeconds": 1.16667, "atmospherePreset": "idle" },
{ "name": "idle→fibonacci", "durationSeconds": 2.41667, "form": "fibonacci" },
{ "name": "fibonacci", "durationSeconds": 2.0, "form": "fibonacci", "loop": true },
{ "name": "fibonacci→idle", "durationSeconds": 2.41667, "form": "sphere" },
{ "name": "idle→cube", "durationSeconds": 2.41667, "form": "cube" },
{ "name": "cube", "durationSeconds": 2.0, "form": "cube", "loop": true },
{ "name": "cube→idle", "durationSeconds": 2.41667, "form": "sphere" }
]
}
Loading
Loading