Commit Graph

34 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 16e3d676d6 Cycle recording: multi-take capture, take folders, comping
Recording into a cycle region now produces one take per pass. Pick a take
from a badge on the clip; split the clip and pick different takes on the
halves, and you've comped.

Data model (phase 2):
- AudioClipType::TakeFolder { takes, recorded_loop_beats } holds the take
  list on the CLIP; ClipInstance::active_take holds the selection on the
  INSTANCE. That split is what makes comping fall out of the existing
  split action for free — split clones the instance, so the two halves
  share one take list but choose independently. recorded_loop_beats lets
  a future time-stretch/conform pass reconcile audio takes if the tempo
  moves under them.
- AudioClip::resolve(active_take) -> ResolvedContent{Audio|Midi|Recording}
  collapses a take folder to what an instance actually plays. A folder is
  not a distinct *case* at call sites — it's an audio or MIDI clip whose
  identity depends on which take is live — so every backend-sync site now
  resolves through this instead of matching clip_type raw. Reverse lookups
  go through owns_audio_pool_index/owns_midi_clip_id, since a folder owns
  one pool file per take, not just the active one.
- BackendContext::add_clip_instance/remove_clip_instance: switching takes
  is a remove + re-add (there's no in-place pool-swap command), and that's
  the same work AddClipInstanceAction does. One implementation, on the
  context that already owns the controller and both ID maps, so the
  seconds-vs-beats conversions can't drift between copies.

Capture (phase 3):
- Takes are cut GEOMETRICALLY at stop, in exact loop-length multiples. The
  playhead advances before the capture block in process(), so the wrap
  instant isn't sample-exact against the buffer just captured — but the
  geometry is. wrap_count only decides *whether* the recording is
  multi-take, never where the cuts land.
- Partial passes are padded with silence: punch in mid-region and take 1
  gets silence prepended back to the region start; stop mid-pass and the
  last take gets silence appended. Every take is the same length, which is
  the invariant comping depends on. A final take under 50ms of real audio
  is dropped as a stop artifact (but a take that FILLED the region never
  is, however short the region).
- MIDI merges, and it falls out for free: anchoring the recording at
  loop_start rather than the punch-in point means the transport always
  wraps back INTO the region, so every note's offset already lands inside
  [0, loop_len) and passes overdub with no folding logic at all.
- The whole session commits as ONE undoable action via push_applied.

Fixes found on the way:
- Split was seconds/beats confused on MIDI. trim_start/trim_end are
  domain-polymorphic exactly like AudioClip::duration was — SECONDS for
  audio/video/vector, BEATS for MIDI — and split mapped the split point
  into clip content in seconds unconditionally. Now it works in the clip's
  own domain via Document::clip_trim_duration(). Regression test included.
- TrimClip took raw f64s whose meaning flipped by track type, and the
  engine set an AUDIO clip's external_duration = Beats(end - start) where
  those bounds were SECONDS — so a 1-second split played back as half a
  second at 120 BPM. Replaced with a domain-tagged TrimRange, built from
  the clip (clip.trim_range()) so the wrong unit isn't expressible, and
  the span is now converted at the clip's position on the timeline.
- The live preview grew past the loop end while the playhead wrapped. It
  now grows through the first pass then pins at the region length, and the
  waveform inside restarts at the region start on each pass. The pass
  offset is derived from the captured buffer, not the playhead — those
  advance on different clocks, and differencing them made the waveform
  jitter horizontally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:40:10 -04:00
Skyler Lehmkuhl f957b01dcf Cycle recording phase 1: transport loop region
Adds a cycle (loop) region: a range on the timeline ruler that the
transport wraps at during playback. This is the substrate for
GarageBand-style multi-take cycle recording (phases 2 and 3).

The region is authored in BEATS so it stays put musically across tempo
changes. It lives on the Document (saved in the .beam, serde-defaulted
so old files load) and is edited through SetCycleRegionAction, so it is
undoable and marks the document modified like any other edit.

Backend:
- Engine gains loop_region/loop_enabled plus a wrap at the single
  playhead-advance point in process(). The wrap is phase-preserving
  (modulo, so an overshoot larger than the loop can't strand the
  playhead outside the region) and gated on playhead >= 0 so a count-in
  pre-roll never wraps.
- Sounding voices are deliberately NOT reset at the wrap the way
  Command::Seek does, since that would chop sustain and reverb tails at
  every pass.
- MidiRecordingState::wrap_at_cycle writes note-offs for held notes at
  the region end and re-opens them at the region start, so a key held
  across the boundary can't end up with a negative duration or hang.
- loop_bounds_frozen freezes the region's sample bounds for the duration
  of an *audio* recording. Phrased positively on audio so future
  multi-track recording inherits it: MIDI is beat-segmented and
  tempo-invariant, but audio is segmented geometrically and we have no
  time-stretch, so cross-tempo audio takes wouldn't be compable anyway.
- Command::Play jumps to loop_start when starting from outside the
  region; starting inside it plays from where you are.

Editor:
- Cycle lane along the bottom of the ruler (bottom, so it doesn't cover
  the bar numbers), painted inside render_ruler under the ticks. Only
  exists while looping is armed; with cycle off the ruler is entirely
  the playhead scrubber, as before.
- Drag to create/move/resize with a three-zone hit test, previewed
  locally and committed as ONE action on release. Driven off raw pointer
  state rather than an egui Response: the lane sits inside the timeline's
  content response, and a second widget on the same pixels just contests
  hover every frame.
- snap_to_grid/quantize_grid_size take a min_grid_px "visual coarseness"
  parameter instead of a hardcoded constant, with two named profiles:
  SNAP_PX_FINE for the playhead and clip edges, SNAP_PX_CYCLE (coarser)
  for the cycle region, so loops land on bars rather than odd
  subdivisions.
- Cycle toggle button using the Lucide repeat glyph.

Cargo.lock picks up the 1.0.9-alpha version bump it missed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:13:06 -04:00
Skyler Lehmkuhl 8ed2320dca Type AudioClip duration by clip kind; fix MIDI clips growing too fast
The MIDI-clip-end-grows-too-fast bug was a units confusion: AudioClip.duration
is documented "seconds" but MIDI clips store their length in BEATS (they share
the AudioClip struct with sampled clips). The timeline's effective_clip_duration
read it as raw seconds, so at 120 BPM a MIDI clip rendered ~2x too long. The old
backend-snapshot display had hidden this by forcing timeline_duration in beats.

Root fix — make the domain explicit and unforgeable:
- `duration` is now a private field. Reading it in the wrong unit is impossible
  because access goes through typed accessors: AudioClip::content_duration() ->
  ClipDuration (a Seconds|Beats enum tagged by clip_type) and
  set_content_duration(). serde still serializes the private field, so the .beam
  format is unchanged (bare number).
- ClipDuration::to_seconds(tempo_map) for display/sizing; ::native() for code
  that already works in the clip's native domain (trim math shares it).
- get_clip_duration + the timeline-endpoint calc go through to_seconds, so MIDI
  is converted correctly; effective_clip_duration delegates to get_clip_duration.
- Recording mirrors (audio/MIDI progress + finalize) write via set_content_duration
  (debug-asserts the value's domain matches the clip type).

Every former raw read/write of the field (core actions, timeline, piano roll,
infopanel, asset library, recording handlers) now goes through the accessors.
Whole workspace compiles; 299 core tests pass.
2026-07-11 19:23:37 -04:00
Skyler Lehmkuhl c747126759 Timeline: display audio/MIDI clips from the doc, not a backend snapshot
The timeline was the only place that reconstructed audio-layer clip instances
from a backend snapshot (build_audio_clip_cache) instead of reading the
document like every other layer type. But the document is the actual source of
truth: clip actions mutate *Layer::clip_instances, it's what persists to .beam,
and it drives the backend on load. The snapshot is downstream of it.

That dual source caused the second-recording preview bug: a recording clip
lives only in the document (its backend clip has a temporary pool index of 0,
so the snapshot can't represent it). layer_clips() only fell back to the doc
when the snapshot cache was *empty*, so the first recording previewed but the
second — once the layer already had a finalized clip — was dropped and showed
as zero-length until finalized.

Fix: make layer_clips() read <layer>.clip_instances for audio too, and delete
build_audio_clip_cache + the audio_cache plumbing threaded through ~7 methods
and ~25 call sites. Recording clips grow via the existing RecordingProgress /
MidiRecordingProgress mirror to the doc; finalized clips come from the actions;
both states now live in one place.

Root fix this exposed: get_clip_duration() wrapped every audio clip's duration
as Seconds, but MIDI clips store their duration in BEATS (they share AudioClip
with sampled clips). The snapshot had masked this by always forcing a MIDI
clip's timeline_duration; reading from the doc requires the value to be right,
so get_clip_duration now converts a MIDI clip's beats duration to seconds.

Net -120 lines. Whole workspace compiles; 299 core tests pass. The piano roll
still uses the snapshot for its own MIDI editor (separate pane, out of scope).
2026-07-11 17:07:55 -04:00
Skyler Lehmkuhl 0050c18623 Timeline types stage 2: fix editor seconds↔beats at every clip boundary
Thread Beats/Seconds through the editor now that lightningbeam-core is typed.
The timeline UI is seconds-domain (pixels_per_second, viewport_start_time,
playback_time, drag_offset), while ClipInstance.timeline_start/duration/
loop_before are beats — the compiler flagged every place the two were mixed.

Fixes the reported bugs and the whole class behind them:
- Recording placement (audio + MIDI + webcam): the new clip's timeline_start
  was written from playback_time (seconds) into a beats field, so a second
  recording landed at the wrong time. Now converted via the tempo map.
- Drag/move: introduce snapped_move_offset -> Beats (snap in the seconds/pixel
  domain, express the anchor's movement in beats) and moved_start(); the group
  clamp, live preview, and commit all use one uniform beats offset instead of
  adding a seconds delta to a beats position.
- Trim/loop drag preview + commit: overlap limits come from the timeline
  (beats) but trims are content seconds — converted at the boundary.
- Drop (asset drag, stage + timeline), paste, duplicate, split-at-playhead:
  all convert the seconds drop/playhead position to beats before placing.
- Stage playback gates + clip-local time remap: compared seconds playback_time
  against beats timeline_start; now both in seconds.
- Piano roll / infopanel / effect export: clip_dur is Seconds; effect and
  waveform data converted at use.

Core API refinement (motivated by the above): find_max_trim_extend_left/right
now return Seconds (the content-seconds gap) instead of raw Beats, since every
trim caller wants seconds; loop-extend callers convert to beats. Added a
beats_to_x() timeline helper.

Whole workspace compiles; 299 core tests pass.
2026-07-11 14:10:38 -04:00
Skyler Lehmkuhl 053a77cfa1 Timeline types stage 1: make lightningbeam-core Beats/Seconds-typed
Type ClipInstance.timeline_start/timeline_duration/loop_before as Beats and
thread Beats/Seconds through core so the compiler catches the seconds-vs-beats
mismatches behind the audio-clip placement/drag/trim bugs.

Fixes latent mixups surfaced by the types:
- add/remove/split clip instance: the audio "effective duration" fallback was
  the content-seconds span treated as beats (clips stopped early off 60 BPM);
  now converted via the tempo map at the clip's start.
- trim validation: extend-left/right clamped a content-seconds delta against a
  timeline-beats gap (and moved trim_start + timeline_start by the same raw
  amount, assuming 1:1). Now the gap is converted to content seconds and the
  timeline moves by the beats-equivalent.
- split content-split point mixed beats into a seconds trim value.
- set_keyframe: visibility-start fallback returned beats where seconds expected.
- hit_test: timeline_time (s) compared against beats without conversion.

Typed backend boundaries that were passing beats/seconds as bare f64:
add_audio_clip (start/dur Beats, offset Seconds), move_clip/extend_clip (Beats),
set_offset (Seconds). Command enum transport stays f64.

Serialization is unchanged (Beats/Seconds are #[serde(transparent)]).
lightningbeam-core builds; 299 core tests pass. The editor call sites
(recording/drop/paste/drag placement) are updated in stage 2.
2026-07-10 09:36:07 -04:00
Skyler Lehmkuhl fd582828c2 Add text layers
Introduce editable text layers: a resizable text box with editable text,
font size, color, font family, and alignment.

Core:
- New TextLayer/TextContent (text_layer.rs), wired into AnyLayer/LayerType
  and all the exhaustive match sites; structured so content can be keyframed
  later via content_at().
- fonts.rs: thread-local parley FontContext with three bundled fonts
  (Liberation Sans/Serif/Mono, SIL OFL), system-font enumeration consolidated
  to base families, document-embedded fonts, glyph/caret/selection geometry,
  and a background preloader for the picker fonts.
- Rendering via parley layout + Scene::draw_glyphs (renderer.rs); text
  composites through the vector path.
- Actions: CreateTextClipAction (vector-layer branch, undoable),
  SetTextContentAction, ResizeTextBoxAction.
- .beam font embedding: MediaKind::Font rows (content-hash dedupe) written on
  save and registered on load, with bundled-default fallback.
- VectorClip content bounds include text boxes so text-only clips are
  selectable/draggable.

Editor:
- Text tool: click empty/raster/video to create a top-level text layer, or a
  vector layer to create+enter a clip containing the text; click an existing
  box to edit it.
- Hybrid in-place editing: a hidden egui TextEdit drives input/IME/caret while
  the text and caret/selection render in Vello; empty just-created layers are
  removed on commit.
- Selection outline + 8 resize handles (re-wrap text) with hover cursors;
  factored the corner/edge resize-cursor mapping shared with the Transform tool.
- Info panel: edit text, size, color, alignment, box size, and a font-family
  picker that previews each entry in its own font (fonts preloaded in the
  background to avoid hitches).

Deps: add parley (git, pinned to match vello's peniko); bundle Liberation
fonts under lightningbeam-core/assets/fonts. Gitignore the local
.cargo/config.toml used to select a machine's ffmpeg.
2026-06-27 18:14:47 -04:00
Skyler Lehmkuhl b76eefb404 hdr: per-document Clip vs Highlight-rolloff output mode (Stage A pt 2)
Add a per-document HDR→SDR mapping applied at the final linear→sRGB encode, so
super-white (HDR) video highlights can be recovered instead of hard-clipped:

- core: HdrOutputMode {Clip (default), HighlightRolloff} on Document (serde
  default), with a SetDocumentPropertiesAction variant for undoable edits.
- shaders: a fs_main_rolloff entry point (preview linear_to_srgb.wgsl + the export
  inline shader) applying a C1 highlight knee — identity below 0.8, smooth rolloff
  [0.8,∞)→[0.8,1). SDR below the knee is untouched; Clip stays the historical path.
- preview (stage.rs) and both export encodes (video_exporter.rs) pick the pipeline
  variant from document.hdr_output_mode — one value per frame, so no per-pixel
  uniform; mirrors the existing fs_main_straight pattern.
- UI: an "HDR output" dropdown in the Document section of the info panel.

Default (Clip) is bit-identical to previous behaviour. Completes Stage A:
HDR-correct input (pt 1) + SDR-safe output mapping. HDR export (10-bit P010/PQ)
and HDR display remain Stages B/C.
2026-06-26 03:01:15 -04:00
Skyler Lehmkuhl 4228864259 Phase 3a-1: lazy fault-in of raster keyframe pixels
Raster keyframes are no longer eagerly decoded at load — `raw_pixels` stays empty
and is paged in on demand from the project container, so a big paint project opens
instantly and only touched frames hit RAM.

- core: `read_packed_media_readonly` (fresh read-only connection, can't conflict
  with an in-place save) + `RasterStore` (holds the container path; `load_pixels`
  reads+decodes a keyframe's PNG by id). `load_beam_sqlite` stops eager-decoding and
  instead marks every raster keyframe `needs_fault_in` (recursively, incl. nested);
  a freshly-created keyframe stays false (blank-resident, nothing to page). Added
  `Document::all_layers_mut`.
- editor: the canvas records a fault-in request when it needs a paged-out keyframe
  (empty pixels && needs_fault_in); the App drains the sink at the top of update(),
  pages the pixels in via the store, clears the flag, and repaints. Store path is set
  on load and after save. Export faults in synchronously per frame.

Cold-scrub still shows a 1-frame gap and the page-in is synchronous; the image proxy
(3a-2) and async load (3a-3) remove those next.
2026-06-19 16:59:46 -04:00
Skyler Lehmkuhl f372a84313 Massive tempo refactor - make beats canonical time rep and allow them to be non constant 2026-04-02 10:26:01 -04:00
Skyler Lehmkuhl cfb8e4462b All events now have three time references for seconds, measures/beats, frames 2026-03-30 10:15:55 -04:00
Skyler Lehmkuhl 0d7f15853c Set default timeline mode based on activity 2026-03-20 21:05:00 -04:00
Skyler Lehmkuhl 3bc980d08d Use audio engine as source of truth for audio tracks 2026-03-11 12:37:31 -04:00
Skyler Lehmkuhl 1c7256a12e Add raster layers 2026-03-01 13:16:49 -05:00
Skyler Lehmkuhl b87e4325c2 use group layers instead of linked tracks 2026-03-01 09:00:55 -05:00
Skyler Lehmkuhl eab116c930 Add beat mode 2026-02-22 18:43:17 -05:00
Skyler Lehmkuhl 469849a0d6 Add nested audio tracks 2026-02-21 03:56:07 -05:00
Skyler Lehmkuhl 1892f970c4 Initial work on movie clips 2026-02-21 00:54:38 -05:00
Skyler Lehmkuhl ce40147efa Fix looping bugs 2026-02-20 04:27:20 -05:00
Skyler Lehmkuhl c66487b25e Add script node 2026-02-19 09:29:14 -05:00
Skyler Lehmkuhl 394e369122 Add clip split and duplicate commands 2026-02-15 02:11:57 -05:00
Skyler Lehmkuhl b19f66e648 add folders to asset library 2025-12-30 00:45:19 -05:00
Skyler Lehmkuhl 7eb61ab0a8 start work on compositing, effects 2025-12-08 09:30:22 -05:00
Skyler Lehmkuhl 727d782190 initial audio export 2025-12-03 17:54:15 -05:00
Skyler Lehmkuhl 346baac840 Improve trim and drag of audio/video clips 2025-12-03 06:07:39 -05:00
Skyler Lehmkuhl ef1956e8e3 Prevent video and audio clips from overlapping 2025-12-03 02:28:23 -05:00
Skyler Lehmkuhl ccb29a9e04 Audio from videos 2025-12-03 01:04:09 -05:00
Skyler Lehmkuhl ba9a4ee812 File save/load 2025-12-01 09:18:49 -05:00
Skyler Lehmkuhl 4d1e052ee7 Add asset pane 2025-11-30 06:54:53 -05:00
Skyler Lehmkuhl 8f830b7799 tests 2025-11-29 13:39:31 -05:00
Skyler Lehmkuhl bbeb85b3a3 Clips in timeline 2025-11-28 05:53:11 -05:00
Skyler Lehmkuhl 9204308033 Transform shapes 2025-11-18 05:08:33 -05:00
Skyler Lehmkuhl 67724c944c Select and move shapes 2025-11-18 00:22:28 -05:00
Skyler Lehmkuhl 1324cae7e3 Render shape on stage 2025-11-16 02:40:06 -05:00