Commit Graph

64 Commits

Author SHA1 Message Date
Skyler Lehmkuhl c62164c365 Cycle recording: MIDI separate-takes mode, and append to existing folders
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.
2026-07-14 12:10:08 -04:00
Skyler Lehmkuhl a5cdbfd0fd Type the time domains: no raw f64 in any time-carrying API
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>
2026-07-14 09:31:04 -04:00
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 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 3d7cff9ad0 Stream audio & video from .beam container; waveform LOD pyramid + persistence
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.
2026-06-17 13:52:38 -04:00
Skyler Lehmkuhl 54d5764bd0 Make beats canonical representation rather than seconds 2026-06-02 13:06:36 -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 ae146533d9 Update automation lanes too 2026-04-01 10:17:52 -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 65a550d8f4 Add piano roll note snapping 2026-03-24 19:24:24 -04:00
Skyler Lehmkuhl 6b6ae230a1 Add pitch bend support 2026-03-18 23:11:24 -04:00
Skyler Lehmkuhl 4f3da810d0 Add automation inputs for audio graphs 2026-03-18 11:25:48 -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 7a3f522735 Give metatracks explicit node graphs 2026-03-10 20:20:46 -04:00
Skyler Lehmkuhl 49b822da8c Add final mix VU meters 2026-03-01 15:04:58 -05:00
Skyler Lehmkuhl a6e04ae89b Add VU meters 2026-03-01 14:49:49 -05:00
Skyler Lehmkuhl eab116c930 Add beat mode 2026-02-22 18:43:17 -05:00
Skyler Lehmkuhl 4122fda954 piano roll and clip resizing fixes 2026-02-21 18:45:46 -05:00
Skyler Lehmkuhl 7e3f18c95b Add amp sim 2026-02-21 09:43:03 -05:00
Skyler Lehmkuhl 469849a0d6 Add nested audio tracks 2026-02-21 03:56:07 -05:00
Skyler Lehmkuhl 66c848e218 Multi sample bulk import 2026-02-20 01:59:37 -05:00
Skyler Lehmkuhl c66487b25e Add script node 2026-02-19 09:29:14 -05:00
Skyler Lehmkuhl 21a49235fc sampler improvements, live waveform preview 2026-02-17 10:08:49 -05:00
Skyler Lehmkuhl 2c0d53fb84 Work on sampler nodes, fix slew limiter 2026-02-16 18:45:11 -05:00
Skyler Lehmkuhl 65fa8a3918 Add preset pane 2026-02-16 06:06:03 -05:00
Skyler Lehmkuhl 0bd933fd45 Group nodes 2026-02-16 03:33:32 -05:00
Skyler Lehmkuhl 72f10db64d add voice allocator node 2026-02-15 23:10:00 -05:00
Skyler Lehmkuhl 7387299b52 node graph fixes 2026-02-15 09:11:39 -05:00
Skyler Lehmkuhl 408343094a Stream audio to spectrograph shader too 2026-02-14 23:58:20 -05:00
Skyler Lehmkuhl 068715c0fa Use CQT transform for spectrograph instead of FFT 2026-02-14 21:18:30 -05:00
Skyler Lehmkuhl f924b4c0cd Stream audio instead of loading the whole thing into memory 2026-02-11 19:07:48 -05:00
Skyler Lehmkuhl 8ac5f52f28 Render audio waveforms on gpu 2026-02-11 14:38:58 -05:00
Skyler Lehmkuhl 727d782190 initial audio export 2025-12-03 17:54:15 -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 5379e3bc8c Import .mid files to midi tracks 2025-12-01 05:35:53 -05:00
Skyler Lehmkuhl 8f1934ab59 Add virtual piano 2025-11-30 11:01:07 -05:00
Skyler Lehmkuhl a1ad0b44b1 Add support for audio trimming and looping 2025-11-28 06:53:28 -05:00
Skyler Lehmkuhl cbdd277184 Add audio export 2025-11-24 01:03:31 -05:00
Skyler Lehmkuhl 30aa639460 Add looped instrument samples and auto-detection of loop points 2025-11-12 08:53:08 -05:00
Skyler Lehmkuhl 7ec69ce950 Merge branch 'new_timeline' of github.com:skykooler/Lightningbeam into new_timeline 2025-11-06 23:42:50 -05:00
Skyler Lehmkuhl e51a6b803d Add metronome 2025-11-06 10:59:25 -05:00
Skyler Lehmkuhl 3c5a24e0b6 video backend 2025-11-06 06:04:39 -05:00
Skyler Lehmkuhl 06314dbf57 Add MIDI input 2025-11-03 06:16:17 -05:00
Skyler Lehmkuhl 1ee86af94d File save/load for audio projects 2025-11-03 02:46:43 -05:00
Skyler Lehmkuhl 988bbfd1a9 Add automation and drag nodes into connections in the graph 2025-11-02 01:27:22 -05:00
Skyler Lehmkuhl dc32fc4200 MIDI recording 2025-10-29 01:50:45 -04:00
Skyler Lehmkuhl d7dc423fe3 Remove old SimpleSynth and effect system 2025-10-28 20:19:08 -04:00
Skyler Lehmkuhl 9d6eaa5bba Node graph improvements and fixes 2025-10-28 08:51:53 -04:00