Take management should be per clip instance — deleting a take from one
half of a comped split shouldn't pull it out from under the other half.
That's cleanest if the takes themselves live on the instance rather than
the clip, so they do now.
AudioClipType::TakeFolder is gone entirely. A clip is plain Sampled/Midi
content again, and an instance with a non-empty `takes` list simply
OVERRIDES it with whichever take is active. Splitting already clones the
instance, so each half gets its own take list for free — no index
remapping across instances, no copy-on-write, no shared-state surprise —
and comping still works, because the halves can still each select a
different take. It collapsed machinery too: resolve() moved from the clip
to the instance, and owns_audio_pool_index went back to a one-liner.
Management (DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction):
- Right-click a clip with more than one take: `Delete "<active take>"`
and "Delete Unused Takes". Deletion is named after the take that's
PLAYING rather than being a generic entry, so you pick the victim by
selecting it — one clear act instead of hunting a small trash icon in a
list (which is where this started, and it was fiddly).
- Double-click a take in the dropdown to rename it in place.
- What happens to the selection on delete is the subtle part, and there's
a test per case: deleting a take BELOW the active one shifts the
selection down so you keep hearing the same take; deleting the ACTIVE
take lands on whatever slid into its place (not silently back to take
1); deleting the LAST take steps back one. The only take can't be
deleted at all — the menu item isn't offered.
- Deleted takes' audio stays in the pool: undo has to put it back, and
the other half of a split may still be playing it.
Fixes:
- A recording that stopped before the loop came round wasn't joining an
existing take list — it landed as a separate overlapping clip. Trigger-
on-wrap is right for the FIRST recording, but once takes exist there,
a further run is plainly another take however short. The engine can't
know that (it's document state), so the editor passes `force_takes`
with the start-recording command and the run is cut and padded to the
region even with zero wraps. This forced cycle_loop_len and `wrapped`
apart on the MIDI side: the region length has to be known from the
start, but the clip should only pin to full-region length AFTER a pass
completes, or the bar jumps to full width the moment you hit record.
- Recording a second take left BOTH sounding. append_cycle_takes tore
down the recording's backend clip by looking it up in
clip_instance_to_backend_map — but on the audio path the recording
instance isn't in that map yet; it's only added during promotion, which
the append path skips. The event already carries the engine's clip id,
so it's handed over explicitly now.
- The take badge is hidden when there's only one take — no choice to make.
Completes the cycle-recording spec. Three related pieces:
MIDI separate takes (Preferences > Audio > "Cycle MIDI recording"):
- Each pass becomes its own MIDI clip, folded into a take folder — the same
shape audio always gets — instead of merging into one clip. Merge stays
the default.
- Notes are bucketed by the pass they were played in. The pass counter bumps
BETWEEN close_active_notes and the re-note_on at a wrap, so a key held
across the boundary has its sounding half filed under the pass that's
ending and its re-opened half under the pass that's beginning. Put the bump
on either side of that pair and the whole note lands in one pass; there's a
test named for exactly that.
- A silent INTERIOR pass still yields an empty take, so take N is always pass
N — otherwise the numbering silently shifts and "take 3" stops meaning "the
third time round". A TRAILING empty pass is dropped: that's what hitting
stop shortly after a wrap gives you, a stop artifact rather than a take you
played. (Audio already behaved this way via its short-final-take rule.)
- Still triggers on the wrap: stop inside the first pass and it's an ordinary
single recording, whatever the preference says.
Append to an existing take folder (AppendTakesAction):
- Cycle-recording over a region that already holds a take folder now ADDS to
that folder rather than dropping a second clip on top of it, which stranded
the new takes in an overlapping clip you couldn't audition against the old.
- "Same region" means same start AND same loop length: resize the cycle region
and you get a fresh folder, rather than takes of a different length appended
to an existing one, which would break the uniform-take invariant that
comping-via-split depends on.
- The recording's own clip/instance are throwaway scaffolding here (the takes
already live in the backend pools), so they're discarded; the single
AppendTakesAction is the whole undoable step.
Don't play the region you're recording over:
- While recording into a MIDI track, every clip on that track is silenced
EXCEPT the one being recorded into. A take folder already sitting in the
cycle region was otherwise playing its active take underneath you on every
pass, fighting the part you were trying to record.
- The recording clip itself is exempt, because in merge mode that's precisely
what you want to hear: the overdub you've been building up. Other tracks are
untouched.
In merge mode every pass layers into the same clip, so a later pass has to
PLAY BACK what earlier passes laid down — otherwise you overdub against
silence, which defeats the point of merging (you can't put a hi-hat on a
kick you can't hear).
Two things stood in the way, and they turned out to be the same bug:
- The captured notes only reached the backend's MIDI pool clip at STOP, so
during the session the sequencer had nothing to schedule. The wrap now
folds the notes captured so far into the pool clip. Their offsets drop
straight in: a cycle MIDI recording is anchored at loop_start, so they're
already region-relative.
- The recording-progress block resizes the clip instance every audio buffer
from `playhead - start_time`. The playhead jumps BACKWARDS at a wrap, so
that duration collapsed to zero and grew again on every pass. It reset the
clip bar to zero each pass (visible), and it shrank the clip instance back
to nothing at each wrap (invisible) — so even once the notes were in the
pool, the sequencer saw a zero-length instance and scheduled none of them.
Fixed at the root: once the transport has wrapped, the recording spans the
whole cycle region and STAYS there — it doesn't track the playhead at all.
`cycle_loop_len` (set at the first wrap) pins it, which both holds the clip
bar at full region length after pass one and keeps the instance stretched
across the region so the merged notes get scheduled.
Writing the events reuses the clip's existing Vec, so it's allocation-free
after the first wrap; mutating the pool from the audio thread is what
Command::UpdateMidiClipNotes already does.
Three bugs in a row came from the same root: a time value crossing an API
boundary as a bare f64, with the caller and the callee disagreeing about
whether it meant seconds or beats. Recording landed at the wrong time,
MIDI clips grew too fast, and a 1-second split played back as half a
second. Each was "obviously" one domain at the call site and read as the
other on the far side. This makes the mismatch a compile error.
Backend API — every time-carrying f64 is gone:
- Commands: Seek/SetOffset/SetTrimStart/SetTrimEnd -> Seconds; MoveClip/
ExtendClip/CreateMidiClip/AddMidiNote/AddLoadedMidiClip/
UpdateMidiClipNotes/AddMidiClipSync and all four automation commands ->
Beats; TrimClip -> TrimRange.
- Events/queries: PlaybackPosition, WaveformChunksReady's time range,
AudioFileReady::duration, PoolFileInfo, get_playhead_seconds -> Seconds.
- Serialized: MidiClipData::duration and AutomationKeyframeData::time ->
Beats. Both newtypes are #[serde(transparent)], so the .beam on-disk
format is unchanged.
- Several controller methods ALREADY took Beats and unwrapped it to shove
into the command — the newtype was being discarded at the very boundary
it existed to protect.
TrimRange, for the domain-polymorphic case: a clip's content time is
SECONDS for sampled audio but BEATS for MIDI, so a single newtype can't
express it (there was even a comment in engine.rs saying so, and that
rationalization is what let the bug through). A domain-tagged enum can.
The engine rejects a range whose domain doesn't match the track, and the
range is built from the clip (clip.trim_range()) so callers can't pick
the wrong variant.
ContentTime, for the trim fields: ClipInstance::trim_start/trim_end are
content times, and were the last untyped f64 — the actual root of the
split bug. ContentTime is deliberately a DEAD END: no .to_seconds(), no
.to_beats(), no arithmetic with Seconds or Beats. Content times combine
freely with each other (same clip, same domain — safe), so the ~100
passthrough sites cost nothing; the only exit is resolving against the
clip that knows the domain (AudioClip::resolve_content_time /
Document::resolve_content_time / ClipDuration::same_domain). Mixing
domains no longer compiles.
Two more live bugs the types surfaced:
- ClipInstance::effective_duration_beats took a SECONDS clip duration and
subtracted trim_start from it. For a TRIMMED MIDI clip that subtracted
a beats offset from a seconds duration, so the clip's timeline length
was wrong at any tempo but 60 BPM. Untrimmed clips happened to work,
which is why it hid. It now takes a ClipDuration and resolves in the
clip's own domain: beats content carries over directly (tempo-
invariant), wall-clock content converts at the clip's position.
Regression test asserts a clip trimmed to beats 2..6 is 4 beats long at
60/90/120 BPM.
- Trim validation clamped a content-domain trim against a wall-clock gap.
gap_to_content/content_to_secs now convert at the clip's position.
Also folds two more copies of the backend add-logic into
BackendContext::add_clip_instance (split and remove_clip_instances both
re-add clips), so the trim/duration conversions live in exactly one place
instead of four.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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.
Push Beats/Seconds through the remaining controller methods that took a bare
f64 and let the audio thread wrap it in a newtype, so the caller's domain is
now compiler-checked (the seam that hid the recording bug):
- seek -> Seconds
- set_trim_start/set_trim_end -> Seconds / Option<Seconds> (metatrack, always seconds)
- add_midi_note, add_loaded_midi_clip, update_midi_clip_notes -> Beats
- add_automation_point, remove_automation_point, automation_add_keyframe,
automation_remove_keyframe -> Beats
Command enums stay raw f64 transport; only the public signatures + call sites
change. No behavior change — every caller already passed the right domain, this
just makes it enforced.
Two deliberate exceptions, documented in place:
- trim_clip stays f64: the TrimClip handler interprets it as Seconds for a
sampled-audio clip but Beats for a MIDI clip, so no single newtype fits;
callers pass the clip's own trim value, which matches its content domain.
- The piano-roll MIDI note model stays f64 internally (a beats-only subsystem
with no seconds anywhere); it's converted to Beats at the update_midi_clip_notes
boundary in UpdateMidiNotesAction, same as trim_start f64 -> Seconds at add_audio_clip.
The reported bug (a second recording lands early and overlaps the first)
survived the timeline type refactor: start_recording/create_midi_clip/
start_midi_recording took f64 and wrapped Beats(x) internally, so the type
boundary stopped at the method and the timeline handed them *shared.playback_time
(seconds). At 120 BPM a 5s playhead (=10 beats) was recorded at beat 5 = 2.5s.
Type all three backend methods to take Beats so the caller must convert; the
timeline now converts the seconds playhead once (start_beats) and passes it to
every recording command and the placeholder clip. TUI debug caller updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
The prior audio-tags commit put real FLAC + metadata into export/audio_exporter.rs
— which turned out to be dead code (declared, never called; whole file was
EngineController::start_export_audio → daw-backend's export_audio, which still
routed FLAC to the erroring hound stub — hence "not implemented in daw-backend".
Move the work to where export actually happens:
- daw-backend/src/audio/export.rs: real ffmpeg FLAC (16-bit S16 / 24-bit S32,
skipping the trailing empty flush packet the FLAC muxer rejects); apply_metadata
on MP3/AAC/FLAC output; RIFF LIST/INFO chunk appended to WAV. New metadata field
on the backend ExportSettings, threaded from the UI in run_audio_export. Tests
assert real fLaC magic + round-tripped tags, and a valid WAV INFO chunk.
- Delete the dead export/audio_exporter.rs (removes the duplicate FLAC impl).
Smart tag defaults (filled only when empty, never clobbering edits):
- Year → current civil year, computed from the system clock with i64 math (no
date crate; correct past 2038/2106 — tests cover post-i32/u32 timestamps).
- Artist → last-used value, else the OS username ($USER/%USERNAME%).
- Album → last-used value.
Last-used Artist/Album persist in AppConfig and prefill next export.
- FLAC is now real FLAC via ffmpeg, not WAV bytes in a .flac file. 16-bit uses
S16, 24-bit uses S32 (ffmpeg's flac encoder emits bits_per_raw_sample=24).
The flush emits a trailing empty packet that the FLAC muxer rejects as
"invalid data" — it's skipped.
- Tag metadata (title/artist/album/genre/year/track/comment) written into every
format via each container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis
comments (FLAC) set through ffmpeg's output metadata; RIFF LIST/INFO appended
to the hound-written WAV (with a fixed-up RIFF size). New AudioMetadata type
on AudioExportSettings; dialog gains a Tags section and defaults Title to the
project name.
Tests: FLAC is a real fLaC container with round-tripped tags; WAV keeps a valid
RIFF with a working INFO chunk.
Export correctness:
- Honor the user's color-range (Limited/Full) on the software encode path:
thread full_range through gpu_yuv (new shader range uniform), the CPU
swscale fallback (sws_setColorspaceDetails → BT.709 + range, fixing the
BT.601 hue/level shift on odd-width exports), and the encoder color tags.
- Reject HDR + WebM up front with a clear message and log the forced HEVC
override instead of producing an unplayable file.
- Delete dead render_frame_to_rgba_hdr (hardcoded Stretch; live HDR path
already honors the fit mode).
Decode/playback (video.rs):
- Drain the decoder at EOF (send_eof + flush) so the final B-frame-delayed
frames render instead of erroring; per-frame logic extracted to a helper.
- Missing-PTS frames continue monotonically rather than snapping to ts=0.
- Force exact thumbnail width so sub-128px sources aren't shown stretched.
Resource leaks (gpu-video-encoder):
- dmabuf import_raw: RAII guard frees the duped fd + partial VkImages/memory
on every error path.
- vaapi alloc: free device/frames-ctx/AVFrames on the unexpected-DRM path.
Data model / robustness:
- collapse_boundary_spikes requires a full curve reversal (all control
points) so it no longer deletes a real lens/sliver and drops the fill.
- Export audio spin-wait ignores a stale `finished` flag when a forward
seek is pending (was rendering silence over real audio).
- RasterDiff apply_before/after take current dims and skip on a post-resize
mismatch.
- beam_archive read_media_full caps the preallocation from untrusted total_len.
UI/visual:
- SVG export skips hidden layers/empty groups; import folds fill-opacity into
gradients and surfaces failures as a notification.
- Active raster-layer border uses playback_time + overlay_transform.
- gpu_brush remove_layer_texture also evicts the stale low-res proxy.
- ensure_raster_resident_for_undo registers faulted frames in the LRU so
resident RAM stays bounded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Offline export blocks each streaming source until decoded frames are
available. For a video whose audio track ends before the video (or a
container like WebM/Opus with no exact stream duration), the tail chunks
wait for frames that never arrive — the 10s safety valve fires per chunk,
so the export appears to hang indefinitely.
Add a `finished` flag to ReadAheadBuffer, set when the disk reader hits
EOF (cleared on seek). The export-mode wait now breaks immediately once
the source is finished and the requested frames aren't present, rendering
silence for the missing tail instead of timing out chunk-by-chunk. Only
the export path reads the flag, so playback is unaffected.
Video was the only media type always kept external (VideoClip.file_path),
so a project with video wasn't self-contained. Now video packs into the
SQLite container under the same large-media policy as audio (pack < 2 GB
unless the user chose Reference), and both the frames and the embedded audio
track decode by streaming directly from the blob — no temp files.
- New crate ffmpeg-blob-io: an AVIOContext-over-Read+Seek shim (BlobInput)
that lets ffmpeg demux from an arbitrary byte source. Isolates all the
unsafe FFI + ffmpeg ABI coupling (version-pinned =8.0.0/=8.0.1). Manual
Drop teardown order; AVSEEK_SIZE restores the read position (FFmpeg assumes
a size query doesn't move it — required for MP4 moov-at-end).
- Schema/save/load: VideoClip.media_id; save_beam packs/references video as
MediaKind::Video (keyed by clip id); load resolves packed vs referenced and
reports missing sources. A packed clip points its linked video-audio pool
entry's media_id at the video row so the audio streams from the same blob.
- Frames: video.rs VideoSource{Path,Packed} threaded through new/seek/scan/
probe/thumbnails (a fresh BlobReader per open); editor builds the source
from current_file_path (now set before register_loaded_videos).
- Audio: VideoAudioReader::open_source via BlobInput; the disk_reader
StreamSource block on packed video-audio is removed; the engine's existing
factory activation routes it unchanged.
Tests: ffmpeg-blob-io AVIO unit tests (WAV via Cursor, seek, open/drop loop);
core packed_video_stream (blob->AVIO->Input) and beam_archive video round-trip;
daw-backend open_source test (compiles; links/runs only off-container).
Runtime-verified: a packed video plays frames + audio after the source file
is removed.
Address the code smells flagged in the .beam format spec:
- Write the project's actual sample rate on save instead of a hardcoded 48000
(add AudioProject::sample_rate()).
- Remove the vestigial RasterKeyframe.media_path field (it was only used by the
legacy ZIP loader, which now derives "media/raster/<id>.png" from the keyframe
id) and the dead buffer_path_at_time accessor. Backward-compatible: older files
carrying media_path deserialize fine (the field is ignored).
- Drop the unused SaveSettings fields auto_embed_threshold_bytes / force_embed_all
/ force_link_all; only large_media_mode was ever consulted. Un-prefix the now-used
`settings` parameter.
Update BEAM_FILE_FORMAT.md to match. The remaining notes (reserved MediaKind::Video,
exact-match version check) are design choices, left as-is.
Resolve all compiler warnings across daw-backend, lightningbeam-core, and
lightningbeam-editor:
- Delete dead code: the superseded CPU raster tools in raster_tool.rs
(EffectBrush/Smudge/Gradient/Transform/Warp/Liquify/Selection — replaced by
the GPU path), plus orphaned helpers and never-read struct fields.
- Mechanical fixes: drop unused imports/variables/mut, underscore unused params,
`drop(&x)` -> `let _ = x`, deprecated egui::Rounding -> CornerRadius, snake_case
rename, elided-lifetime Cow<'_, [u8]>.
- Keep the WIP CSS theming system (theme.rs/theme_render.rs) under
#[allow(dead_code)] rather than deleting it.
Editor checks warning-free; 293 core tests pass.
`AudioPool::load_from_serialized` sizes the slot Vec by pool_index and fills gaps
with empty `AudioFile::new(PathBuf::new(), …)` placeholders. Two bugs let a
placeholder reach the next save and abort it with "Is a directory":
- Off-by-one: `entries.max().unwrap_or(0) + 1` made an *empty* pool length 1, so a
project with no audio still got one placeholder. Size by `max(pool_index + 1)`
→ empty entries yield length 0.
- `serialize()` emitted placeholder slots: an empty path round-trips to
`relative_path = Some("")`, which `save_beam` resolves to the project directory
(`join("")`) and tries to read as media. Skip empty-path / no-packed-media slots.
- Defense in `save_beam`: gate referenced-media packing on `full.is_file()` (not
`exists()`), so any blank/dir path falls through to embedded data instead of
reading a directory.
Pre-existing; surfaced by a save → reload → save cycle on a raster-only project.
The lib unit tests had gone stale (time values became newtypes) and no longer
compiled. Updated the test code to the current API and fixed the few real issues
the now-running tests surfaced.
Test-only:
- Wrap raw f64 time literals in Beats(...) where the API now takes Beats
(automation.rs); pass &TempoMap / Beats where signatures changed (clip.rs,
effect_layer.rs).
- shape.rs: assert the documented no-fill default (fill_color None) instead of Some.
- add_clip_instance / trim_clip_instances tests: register a vector clip with the
test's clip_id so the action's get_clip_duration lookup succeeds.
Production fix (delete_folder.rs):
- DeleteFolderAction(MoveToParent) reparented child subfolders to the deleted
folder's parent but never restored them on undo, orphaning them. Track the moved
subfolder ids and restore their parent on rollback.
Result: daw-backend lib 17 passed; lightningbeam-core lib 264 passed.
Surround → stereo downmix:
- render_from_file folds multichannel sources (5.1/7.1/…) down to stereo with
proper coefficients (full level for the matching front channel, 1/√2 for centre
+ each surround, LFE dropped), normalized per row to avoid clipping (matching
ffmpeg's default). Applied uniformly to both the direct-copy and sinc-resample
paths and to every storage type (PCM, compressed, video audio), only when
dst==2 && src>2; unknown layouts fall back to front L/R. Previously it just took
FL/FR, dropping centre dialog + surrounds.
Proper video-audio reload:
- A video's audio track is now stored as a path reference to the video (never
packed/embedded as audio media) and re-probed via FFmpeg on load into a
streaming VideoAudio entry, so multichannel audio survives reload (the old
Symphonia reconstitution collapsed it, breaking the downmix). Driven by a new
AudioPoolEntry.is_video_audio flag across serialize / save_beam / load. Also
removes the decode-whole-video-to-RAM + temp-file path on load.
Fix video scaling:
- Any video with dimensions larger than the stage was being scaled down into the corner incorrectly; we now bake the frame-clip scale into the instance transform.
- VideoManager.frame_cache: unbounded HashMap (grew per distinct frame during
playback) -> LruCache evicted by a 256MB byte budget. Byte-budget rather than
frame count is robust across resolutions (a 4K frame is ~33MB vs ~2MB at
800x600). unload_video pops per-clip keys (LruCache has no retain).
- mux_video_and_audio: stream-merge the two inputs by PTS with one pending
packet per stream (O(1) memory) instead of collecting every packet into Vecs
first (O(duration)). Output is byte-identical.
- export AAC: sanitize the planar-f32 path (non-finite -> 0, finite clamped to
[-1,1]) like the integer paths, with a one-time warning. A stray NaN/Inf
render sample no longer fails the whole export.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the .beam container to SQLite and stream media from it instead of
decoding whole files into RAM on import/load.
Container & large files:
- SQLite .beam container (beam_archive) with in-place transactional saves and an
incremental BlobReader; supports both packed (chunked blobs) and referenced
(external path) media, with a user preference + first-import prompt for files
over the large-media threshold.
Audio streaming:
- Stream packed compressed audio on load via an inversion-of-control blob factory
(AudioBlobSourceFactory): daw-backend defines the trait, core implements it
over BlobReader, so the audio engine stays container-agnostic.
- Bulk-activate disk streaming for all loaded clips after SetProject.
- Sample-accurate compressed seek (SeekMode::Accurate; Coarse mislands on VBR).
Video:
- Video frames decoded/streamed on demand; thumbnails generated asynchronously
on a dedicated decoder so import/load never blocks the UI.
- The video's audio track is streamed on demand via an ffmpeg VideoAudioReader
as a separate editable AudioClip (no /tmp WAV extraction).
Waveform overview:
- Streaming min/max LOD pyramid (waveform_pyramid), bounded memory, configurable
floor B; serialized into the container and restored on load (or generated in
the background from the packed blob when absent), so no re-decode on reload.
- GPU min/max upload path; integer-LOD textureLoad fixes zoom-dependent wobble.