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>
This commit is contained in:
parent
f957b01dcf
commit
16e3d676d6
|
|
@ -543,6 +543,17 @@ impl Engine {
|
||||||
rec.wrap_at_cycle(le_beats, ls_beats);
|
rec.wrap_at_cycle(le_beats, ls_beats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An audio recording in progress just completed a pass. This only decides
|
||||||
|
// *whether* the recording becomes multi-take — the takes themselves are cut
|
||||||
|
// geometrically at stop, since the playhead advances before the capture
|
||||||
|
// block below, so the wrap instant isn't sample-exact against the buffer
|
||||||
|
// that was just captured. The geometry is.
|
||||||
|
if let Some(ref mut rec) = self.recording_state {
|
||||||
|
if let Some(ref mut cycle) = rec.cycle {
|
||||||
|
cycle.wrap_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref mut dr) = self.disk_reader {
|
if let Some(ref mut dr) = self.disk_reader {
|
||||||
dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek {
|
dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek {
|
||||||
frame: self.playhead.max(0) as u64,
|
frame: self.playhead.max(0) as u64,
|
||||||
|
|
@ -944,36 +955,56 @@ impl Engine {
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||||
clip.external_start = Beats(new_start_time);
|
clip.external_start = new_start_time;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) {
|
||||||
instance.external_start = Beats(new_start_time);
|
instance.external_start = new_start_time;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
self.refresh_clip_snapshot();
|
self.refresh_clip_snapshot();
|
||||||
}
|
}
|
||||||
Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => {
|
Command::TrimClip(track_id, clip_id, range) => {
|
||||||
// Trim changes which portion of the source content is used
|
// Trim changes which portion of the source content is used.
|
||||||
// Also updates external_duration to match internal duration (no looping after trim)
|
// Also collapses external_duration to the trimmed content length (no looping after
|
||||||
match self.project.get_track_mut(track_id) {
|
// a trim).
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
let tempo_map = self.tempo_map.clone();
|
||||||
|
match (self.project.get_track_mut(track_id), range) {
|
||||||
|
(
|
||||||
|
Some(crate::audio::track::TrackNode::Audio(track)),
|
||||||
|
crate::command::TrimRange::Seconds { start, end },
|
||||||
|
) => {
|
||||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||||
clip.internal_start = Seconds(new_internal_start);
|
clip.internal_start = start;
|
||||||
clip.internal_end = Seconds(new_internal_end);
|
clip.internal_end = end;
|
||||||
clip.external_duration = Beats(new_internal_end - new_internal_start);
|
// external_duration is BEATS while the trims are SECONDS, so the span
|
||||||
|
// has to be converted at the clip's position on the timeline — NOT
|
||||||
|
// reinterpreted as `Beats(end - start)`, which played a 1-second trim
|
||||||
|
// back as half a second at 120 BPM.
|
||||||
|
clip.external_duration = tempo_map.seconds_to_beats(
|
||||||
|
tempo_map.beats_to_seconds(clip.external_start) + (end - start),
|
||||||
|
) - clip.external_start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
(
|
||||||
|
Some(crate::audio::track::TrackNode::Midi(track)),
|
||||||
|
crate::command::TrimRange::Beats { start, end },
|
||||||
|
) => {
|
||||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
||||||
instance.internal_start = Beats(new_internal_start);
|
instance.internal_start = start;
|
||||||
instance.internal_end = Beats(new_internal_end);
|
instance.internal_end = end;
|
||||||
instance.external_duration = Beats(new_internal_end - new_internal_start);
|
// MIDI content time IS beats, so the span carries over directly.
|
||||||
|
instance.external_duration = end - start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A domain that doesn't match the track kind (seconds at a MIDI track, or vice
|
||||||
|
// versa) is a caller bug, not something to guess at.
|
||||||
|
(Some(_), _) => {
|
||||||
|
debug_assert!(false, "TrimClip domain does not match the track kind");
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
self.refresh_clip_snapshot();
|
self.refresh_clip_snapshot();
|
||||||
|
|
@ -3212,8 +3243,28 @@ impl Engine {
|
||||||
self.recording_state = Some(recording_state);
|
self.recording_state = Some(recording_state);
|
||||||
self.recording_progress_counter = 0; // Reset progress counter
|
self.recording_progress_counter = 0; // Reset progress counter
|
||||||
|
|
||||||
|
// Arm cycle recording. `start_time` is the region start (the editor anchors a
|
||||||
|
// cycle recording there, for punch-in too), while capture actually begins at
|
||||||
|
// the current playhead — the gap between them is the lead pad that take 1 gets
|
||||||
|
// prepended as silence so it still spans the whole region.
|
||||||
|
let cycle_info = if self.loop_enabled {
|
||||||
|
self.loop_region.map(|(ls_beats, le_beats)| {
|
||||||
|
let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats);
|
||||||
|
crate::audio::recording::CycleRecordInfo {
|
||||||
|
loop_start: ls_beats,
|
||||||
|
loop_len_beats: le_beats - ls_beats,
|
||||||
|
loop_len_frames: (le - ls).max(0) as usize,
|
||||||
|
lead_pad_frames: (self.playhead - ls).max(0) as usize,
|
||||||
|
wrap_count: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Set samples to skip (drained incrementally across callbacks)
|
// Set samples to skip (drained incrementally across callbacks)
|
||||||
if let Some(recording) = &mut self.recording_state {
|
if let Some(recording) = &mut self.recording_state {
|
||||||
|
recording.cycle = cycle_info;
|
||||||
recording.samples_to_skip = samples_in_buffer;
|
recording.samples_to_skip = samples_in_buffer;
|
||||||
if self.debug_audio && samples_in_buffer > 0 {
|
if self.debug_audio && samples_in_buffer > 0 {
|
||||||
eprintln!("[AUDIO DEBUG] Will skip {} stale samples from input buffer", samples_in_buffer);
|
eprintln!("[AUDIO DEBUG] Will skip {} stale samples from input buffer", samples_in_buffer);
|
||||||
|
|
@ -3238,6 +3289,40 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write one cycle take to a temp WAV, add it to the audio pool, and return its pool index.
|
||||||
|
///
|
||||||
|
/// Mirrors what the single-recording path does with its own buffer: the pool file is backed by
|
||||||
|
/// the in-memory samples, and the temp WAV is written and then removed (the pool only reads the
|
||||||
|
/// path opportunistically, e.g. to keep original bytes on save).
|
||||||
|
fn write_take_to_pool(
|
||||||
|
&mut self,
|
||||||
|
samples: Vec<f32>,
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u32,
|
||||||
|
clip_id: ClipId,
|
||||||
|
take_index: usize,
|
||||||
|
) -> Result<usize, std::io::Error> {
|
||||||
|
use crate::io::WavWriter;
|
||||||
|
|
||||||
|
let path = std::env::temp_dir()
|
||||||
|
.join(format!("daw_take_{}_{}.wav", clip_id, take_index));
|
||||||
|
|
||||||
|
let mut writer = WavWriter::create(&path, sample_rate, channels)?;
|
||||||
|
writer.write_samples(&samples)?;
|
||||||
|
writer.finalize()?;
|
||||||
|
|
||||||
|
let pool_file = crate::audio::pool::AudioFile::with_format(
|
||||||
|
path.clone(),
|
||||||
|
samples,
|
||||||
|
channels,
|
||||||
|
sample_rate,
|
||||||
|
Some("wav".to_string()),
|
||||||
|
);
|
||||||
|
let pool_index = self.audio_pool.add_file(pool_file);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
Ok(pool_index)
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle stopping a recording
|
/// Handle stopping a recording
|
||||||
fn handle_stop_recording(&mut self) {
|
fn handle_stop_recording(&mut self) {
|
||||||
eprintln!("[STOP_RECORDING] handle_stop_recording called");
|
eprintln!("[STOP_RECORDING] handle_stop_recording called");
|
||||||
|
|
@ -3261,11 +3346,74 @@ impl Engine {
|
||||||
|
|
||||||
eprintln!("[STOP_RECORDING] Stopping recording for clip_id={}, track_id={}", clip_id, track_id);
|
eprintln!("[STOP_RECORDING] Stopping recording for clip_id={}, track_id={}", clip_id, track_id);
|
||||||
|
|
||||||
|
// Slice cycle takes BEFORE finalize consumes the recording. `None` here means the
|
||||||
|
// transport never wrapped, which stays an ordinary single recording on the path below.
|
||||||
|
let cycle = recording.cycle;
|
||||||
|
let frames_per_peak = recording.frames_per_peak;
|
||||||
|
let cycle_takes = recording.slice_takes();
|
||||||
|
|
||||||
// Finalize the recording (flush buffers, close file, get waveform and audio data)
|
// Finalize the recording (flush buffers, close file, get waveform and audio data)
|
||||||
let frames_recorded = recording.frames_written;
|
let frames_recorded = recording.frames_written;
|
||||||
eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded);
|
eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded);
|
||||||
match recording.finalize() {
|
match recording.finalize() {
|
||||||
Ok((temp_file_path, waveform, audio_data)) => {
|
Ok((temp_file_path, waveform, audio_data)) => {
|
||||||
|
// ---- Cycle recording: one take per pass, each spanning the whole region ----
|
||||||
|
if let (Some(takes), Some(cycle)) = (cycle_takes, cycle) {
|
||||||
|
eprintln!(
|
||||||
|
"[STOP_RECORDING] Cycle recording: {} wraps -> {} takes of {} frames",
|
||||||
|
cycle.wrap_count, takes.len(), cycle.loop_len_frames
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&temp_file_path);
|
||||||
|
|
||||||
|
let mut pool_takes: Vec<(usize, Vec<crate::io::WaveformPeak>)> = Vec::new();
|
||||||
|
for (i, take) in takes.into_iter().enumerate() {
|
||||||
|
let peaks = crate::audio::recording::compute_peaks(
|
||||||
|
&take,
|
||||||
|
channels,
|
||||||
|
frames_per_peak,
|
||||||
|
);
|
||||||
|
match self.write_take_to_pool(take, sample_rate, channels, clip_id, i) {
|
||||||
|
Ok(pool_index) => pool_takes.push((pool_index, peaks)),
|
||||||
|
Err(e) => {
|
||||||
|
let _ = self.event_tx.push(AudioEvent::RecordingError(
|
||||||
|
format!("Failed to store take {}: {}", i + 1, e),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point the engine's clip at the take the editor will make active (the last
|
||||||
|
// one, GarageBand-style) and stretch it to cover the whole cycle region —
|
||||||
|
// the clip was created at the punch-in point with zero length.
|
||||||
|
let loop_len_secs =
|
||||||
|
Seconds(cycle.loop_len_frames as f64 / sample_rate as f64);
|
||||||
|
if let Some(&(last_pool_index, _)) = pool_takes.last() {
|
||||||
|
if let Some(crate::audio::track::TrackNode::Audio(track)) =
|
||||||
|
self.project.get_track_mut(track_id)
|
||||||
|
{
|
||||||
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id)
|
||||||
|
{
|
||||||
|
clip.audio_pool_index = last_pool_index;
|
||||||
|
clip.internal_start = Seconds(0.0);
|
||||||
|
clip.internal_end = loop_len_secs;
|
||||||
|
clip.external_start = cycle.loop_start;
|
||||||
|
clip.external_duration = cycle.loop_len_beats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.refresh_clip_snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.event_tx.push(AudioEvent::CycleRecordingStopped {
|
||||||
|
clip_id,
|
||||||
|
takes: pool_takes,
|
||||||
|
loop_start: cycle.loop_start,
|
||||||
|
loop_len_beats: cycle.loop_len_beats,
|
||||||
|
loop_len_seconds: loop_len_secs,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory",
|
eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory",
|
||||||
frames_recorded, temp_file_path, waveform.len(), audio_data.len());
|
frames_recorded, temp_file_path, waveform.len(), audio_data.len());
|
||||||
|
|
||||||
|
|
@ -3357,7 +3505,13 @@ impl Engine {
|
||||||
let track_id = recording.track_id;
|
let track_id = recording.track_id;
|
||||||
let notes = recording.get_notes().to_vec();
|
let notes = recording.get_notes().to_vec();
|
||||||
let note_count = notes.len();
|
let note_count = notes.len();
|
||||||
let recording_duration = end_time - recording.start_time;
|
// A cycle MIDI recording is anchored at the region start and every pass overdubs into
|
||||||
|
// the same clip (MERGE), so the clip is exactly one region long — not however long the
|
||||||
|
// user held the record button, which would run past the loop end.
|
||||||
|
let recording_duration = match recording.cycle_loop_len {
|
||||||
|
Some(loop_len) => loop_len,
|
||||||
|
None => end_time - recording.start_time,
|
||||||
|
};
|
||||||
|
|
||||||
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats",
|
eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats",
|
||||||
clip_id, track_id, note_count, recording_duration.0);
|
clip_id, track_id, note_count, recording_duration.0);
|
||||||
|
|
@ -3522,17 +3676,17 @@ impl EngineController {
|
||||||
|
|
||||||
/// Move a clip to a new timeline position (changes external_start)
|
/// Move a clip to a new timeline position (changes external_start)
|
||||||
pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) {
|
pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) {
|
||||||
let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time.beats_to_f64()));
|
let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trim a clip's internal boundaries (changes which portion of source content is used)
|
/// Trim a clip's internal content bounds — which portion of the source content plays.
|
||||||
/// This also resets external_duration to match internal duration (disables looping)
|
///
|
||||||
/// Trim a clip's internal content bounds. The units are content-domain and depend on the
|
/// Collapses external_duration to the trimmed length (disables looping). The bounds are
|
||||||
/// track type: SECONDS for a sampled-audio clip, BEATS for a MIDI clip (see the TrimClip
|
/// content-domain, which differs by clip kind, so they're passed as a [`TrimRange`] that names
|
||||||
/// handler). Left as raw f64 because a single newtype can't express both; callers pass the
|
/// the domain: `Seconds` for sampled audio, `Beats` for MIDI. The engine rejects a range whose
|
||||||
/// clip's own `trim_start`/`trim_end`, which already match its content domain.
|
/// domain doesn't match the track.
|
||||||
pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_internal_start: f64, new_internal_end: f64) {
|
pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, range: crate::command::TrimRange) {
|
||||||
let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end));
|
let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, range));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extend or shrink a clip's external duration (enables looping if > internal duration)
|
/// Extend or shrink a clip's external duration (enables looping if > internal duration)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,50 @@ use crate::time::{Beats, Seconds};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Cycle-recording bookkeeping attached to a recording that started with a cycle region armed.
|
||||||
|
///
|
||||||
|
/// Takes are sliced **geometrically** at stop, in exact `loop_len_frames` multiples — not at the
|
||||||
|
/// instant the wrap was detected. The playhead advances before the capture block in `process()`, so
|
||||||
|
/// the wrap instant isn't sample-exact against the buffer that was just captured, but the geometry
|
||||||
|
/// is. `wrap_count` therefore only decides *whether* this is a multi-take recording, never where the
|
||||||
|
/// cuts land.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct CycleRecordInfo {
|
||||||
|
/// Where the cycle region starts, in beats. Takes are laid down here, not at the punch-in point.
|
||||||
|
pub loop_start: Beats,
|
||||||
|
/// The cycle region's length in beats — what the take folder records as `recorded_loop_beats`.
|
||||||
|
pub loop_len_beats: Beats,
|
||||||
|
/// One cycle pass, in frames. The take size.
|
||||||
|
pub loop_len_frames: usize,
|
||||||
|
/// Frames between the region start and where capture actually began. Non-zero only for a
|
||||||
|
/// punch-in (record while already rolling); take 1 gets this much silence prepended so it still
|
||||||
|
/// spans the whole region.
|
||||||
|
pub lead_pad_frames: usize,
|
||||||
|
/// How many times the transport wrapped during this recording. Zero means the user stopped
|
||||||
|
/// before completing a pass, which stays an ordinary single recording.
|
||||||
|
pub wrap_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Min/max waveform peaks for a finished buffer of interleaved samples.
|
||||||
|
///
|
||||||
|
/// The live recording path builds its peaks incrementally as samples arrive; cycle takes don't
|
||||||
|
/// exist until the recording is sliced at stop, so they get theirs in one pass here.
|
||||||
|
pub fn compute_peaks(samples: &[f32], channels: u32, frames_per_peak: usize) -> Vec<WaveformPeak> {
|
||||||
|
let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1);
|
||||||
|
samples
|
||||||
|
.chunks(samples_per_peak)
|
||||||
|
.map(|chunk| {
|
||||||
|
let mut min = 0.0f32;
|
||||||
|
let mut max = 0.0f32;
|
||||||
|
for s in chunk {
|
||||||
|
min = min.min(*s);
|
||||||
|
max = max.max(*s);
|
||||||
|
}
|
||||||
|
WaveformPeak { min, max }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// State of an active recording session
|
/// State of an active recording session
|
||||||
pub struct RecordingState {
|
pub struct RecordingState {
|
||||||
/// Track being recorded to
|
/// Track being recorded to
|
||||||
|
|
@ -35,6 +79,8 @@ pub struct RecordingState {
|
||||||
pub frames_per_peak: usize,
|
pub frames_per_peak: usize,
|
||||||
/// All recorded audio data accumulated in memory (written to disk at finalization)
|
/// All recorded audio data accumulated in memory (written to disk at finalization)
|
||||||
pub audio_data: Vec<f32>,
|
pub audio_data: Vec<f32>,
|
||||||
|
/// Cycle-recording bookkeeping, when a cycle region was armed at record start.
|
||||||
|
pub cycle: Option<CycleRecordInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecordingState {
|
impl RecordingState {
|
||||||
|
|
@ -69,9 +115,69 @@ impl RecordingState {
|
||||||
waveform_buffer: Vec::new(),
|
waveform_buffer: Vec::new(),
|
||||||
frames_per_peak,
|
frames_per_peak,
|
||||||
audio_data: Vec::new(),
|
audio_data: Vec::new(),
|
||||||
|
cycle: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slice the recording into cycle takes: one per pass, each spanning the FULL cycle region.
|
||||||
|
///
|
||||||
|
/// Partial passes are padded with silence — the head of take 1 for a punch-in, the tail of the
|
||||||
|
/// last take when the user stops mid-pass — so every take is the same length and aligned to the
|
||||||
|
/// region. That uniformity is what makes comping-via-split work: take 1 on the left half and
|
||||||
|
/// take 3 on the right always line up.
|
||||||
|
///
|
||||||
|
/// Returns `None` if this wasn't a cycle recording or the transport never wrapped (an ordinary
|
||||||
|
/// single recording, which keeps the existing path untouched).
|
||||||
|
pub fn slice_takes(&self) -> Option<Vec<Vec<f32>>> {
|
||||||
|
let cycle = self.cycle?;
|
||||||
|
if cycle.wrap_count == 0 || cycle.loop_len_frames == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ch = self.channels.max(1) as usize;
|
||||||
|
let take_len = cycle.loop_len_frames * ch;
|
||||||
|
let lead = cycle.lead_pad_frames * ch;
|
||||||
|
|
||||||
|
// The recording as positioned *within the region*: silence for the gap between the region
|
||||||
|
// start and the punch-in, then the captured audio. Slicing this at whole-take boundaries is
|
||||||
|
// the whole trick — take 1 comes out short-by-`lead` at the front, already padded.
|
||||||
|
let virtual_len = lead + self.audio_data.len();
|
||||||
|
let take_count = virtual_len.div_ceil(take_len);
|
||||||
|
|
||||||
|
let mut takes: Vec<Vec<f32>> = Vec::with_capacity(take_count);
|
||||||
|
for i in 0..take_count {
|
||||||
|
let mut take = vec![0.0f32; take_len];
|
||||||
|
let take_begin = i * take_len;
|
||||||
|
for slot in 0..take_len {
|
||||||
|
// Position in the virtual (lead-padded) buffer.
|
||||||
|
let v = take_begin + slot;
|
||||||
|
if v < lead {
|
||||||
|
continue; // still in the punch-in silence
|
||||||
|
}
|
||||||
|
match self.audio_data.get(v - lead) {
|
||||||
|
Some(s) => take[slot] = *s,
|
||||||
|
None => break, // past the end of capture; the rest stays silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
takes.push(take);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A final take holding only a sliver of real audio is a stop artifact (the user hit stop a
|
||||||
|
// moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL
|
||||||
|
// pass, and never the only take. A pass that filled the region is a real take no matter how
|
||||||
|
// short the region is.
|
||||||
|
const MIN_TAKE_SECONDS: f64 = 0.05;
|
||||||
|
if takes.len() > 1 {
|
||||||
|
let last_real_samples = virtual_len - (takes.len() - 1) * take_len;
|
||||||
|
let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64;
|
||||||
|
if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS {
|
||||||
|
takes.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(takes)
|
||||||
|
}
|
||||||
|
|
||||||
/// Add samples to the accumulation buffer
|
/// Add samples to the accumulation buffer
|
||||||
/// Returns true if a flush occurred
|
/// Returns true if a flush occurred
|
||||||
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
|
pub fn add_samples(&mut self, samples: &[f32]) -> Result<bool, std::io::Error> {
|
||||||
|
|
@ -189,6 +295,13 @@ pub struct MidiRecordingState {
|
||||||
active_notes: HashMap<u8, ActiveMidiNote>,
|
active_notes: HashMap<u8, ActiveMidiNote>,
|
||||||
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
|
/// Completed notes: (time_offset, note, velocity, duration) — all times in beats
|
||||||
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
|
pub completed_notes: Vec<(Beats, u8, u8, Beats)>,
|
||||||
|
/// The cycle region's length in beats, when recording into a cycle.
|
||||||
|
///
|
||||||
|
/// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is
|
||||||
|
/// what makes MERGE fall out for free: the transport always wraps back into the region, so every
|
||||||
|
/// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each
|
||||||
|
/// other with no folding needed. Set only if the transport actually wrapped.
|
||||||
|
pub cycle_loop_len: Option<Beats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MidiRecordingState {
|
impl MidiRecordingState {
|
||||||
|
|
@ -199,6 +312,7 @@ impl MidiRecordingState {
|
||||||
start_time,
|
start_time,
|
||||||
active_notes: HashMap::new(),
|
active_notes: HashMap::new(),
|
||||||
completed_notes: Vec::new(),
|
completed_notes: Vec::new(),
|
||||||
|
cycle_loop_len: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,5 +397,108 @@ impl MidiRecordingState {
|
||||||
for (note, velocity) in held {
|
for (note, velocity) in held {
|
||||||
self.note_on(note, velocity, region_start);
|
self.note_on(note, velocity, region_start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The transport wrapped, so this is a cycle recording: the clip spans the whole region
|
||||||
|
// rather than however long the user happened to hold the record button.
|
||||||
|
self.cycle_loop_len = Some(region_end - region_start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod cycle_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame
|
||||||
|
/// is a sample and 5 frames is 50 ms (exactly the min-take threshold).
|
||||||
|
fn rec(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
|
||||||
|
let mut r = RecordingState::new(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
PathBuf::from("/dev/null"),
|
||||||
|
WavWriter::create(&PathBuf::from("/dev/null"), 100, 1).expect("wav writer"),
|
||||||
|
100,
|
||||||
|
1,
|
||||||
|
Beats(0.0),
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
r.audio_data = audio;
|
||||||
|
r.cycle = Some(CycleRecordInfo {
|
||||||
|
loop_start: Beats(0.0),
|
||||||
|
loop_len_beats: Beats(4.0),
|
||||||
|
loop_len_frames,
|
||||||
|
lead_pad_frames,
|
||||||
|
wrap_count: wraps,
|
||||||
|
});
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_wrap_is_not_a_cycle_recording() {
|
||||||
|
// Stopping before the transport ever wraps stays an ordinary single recording — the whole
|
||||||
|
// point of triggering on the wrap rather than on the cycle region merely existing.
|
||||||
|
let r = rec(vec![1.0; 10], 4, 0, 0);
|
||||||
|
assert!(r.slice_takes().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_are_cut_at_exact_loop_multiples() {
|
||||||
|
// 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes.
|
||||||
|
let audio: Vec<f32> = (1..=12).map(|i| i as f32).collect();
|
||||||
|
let takes = rec(audio, 4, 0, 2).slice_takes().expect("cycle takes");
|
||||||
|
assert_eq!(takes.len(), 3);
|
||||||
|
assert_eq!(takes[0], vec![1.0, 2.0, 3.0, 4.0]);
|
||||||
|
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
|
||||||
|
assert_eq!(takes[2], vec![9.0, 10.0, 11.0, 12.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn punch_in_pads_the_head_of_take_one() {
|
||||||
|
// Punched in 2 frames into the region: take 1 gets 2 frames of silence at the FRONT so it
|
||||||
|
// still spans the whole region and lines up with every other take.
|
||||||
|
let audio: Vec<f32> = (1..=10).map(|i| i as f32).collect();
|
||||||
|
let takes = rec(audio, 4, 2, 2).slice_takes().expect("cycle takes");
|
||||||
|
assert_eq!(takes.len(), 3);
|
||||||
|
assert_eq!(takes[0], vec![0.0, 0.0, 1.0, 2.0]);
|
||||||
|
assert_eq!(takes[1], vec![3.0, 4.0, 5.0, 6.0]);
|
||||||
|
assert_eq!(takes[2], vec![7.0, 8.0, 9.0, 10.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopping_mid_pass_pads_the_tail_of_the_last_take() {
|
||||||
|
// 13 frames, 8-frame loop => the second take holds 5 real frames (50 ms at 100 Hz, right at
|
||||||
|
// the keep threshold) and 3 of silence.
|
||||||
|
let audio: Vec<f32> = (1..=13).map(|i| i as f32).collect();
|
||||||
|
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
|
||||||
|
assert_eq!(takes.len(), 2);
|
||||||
|
assert_eq!(takes[1], vec![9.0, 10.0, 11.0, 12.0, 13.0, 0.0, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_take_is_the_same_length() {
|
||||||
|
// Uniform length is the invariant comping-via-split depends on.
|
||||||
|
let audio: Vec<f32> = (1..=23).map(|i| i as f32).collect();
|
||||||
|
let takes = rec(audio, 8, 3, 3).slice_takes().expect("cycle takes");
|
||||||
|
assert!(takes.iter().all(|t| t.len() == 8), "takes must be uniform");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sliver_of_a_final_take_is_dropped() {
|
||||||
|
// Stopped 1 frame (10 ms at 100 Hz) after the wrap — below the 50 ms floor, so that stub of
|
||||||
|
// a take is a stop artifact and goes.
|
||||||
|
let audio: Vec<f32> = (1..=9).map(|i| i as f32).collect();
|
||||||
|
let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes");
|
||||||
|
assert_eq!(takes.len(), 1, "a 10ms tail take should be dropped");
|
||||||
|
assert_eq!(takes[0].len(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_final_take_is_never_dropped() {
|
||||||
|
// Regression: the sliver rule must only fire on a PARTIAL pass. A pass that filled the
|
||||||
|
// region is a real take however short the region is — an earlier version compared a full
|
||||||
|
// take's duration to the floor and silently ate it.
|
||||||
|
let audio: Vec<f32> = (1..=8).map(|i| i as f32).collect();
|
||||||
|
let takes = rec(audio, 4, 0, 1).slice_takes().expect("cycle takes");
|
||||||
|
assert_eq!(takes.len(), 2, "both passes filled the region");
|
||||||
|
assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse};
|
pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse, TrimRange};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,21 @@ use crate::audio::node_graph::nodes::LoopMode;
|
||||||
use crate::io::WaveformPeak;
|
use crate::io::WaveformPeak;
|
||||||
use crate::time::{Beats, Seconds};
|
use crate::time::{Beats, Seconds};
|
||||||
|
|
||||||
|
/// A clip's internal (content) boundaries, tagged with the domain they're measured in.
|
||||||
|
///
|
||||||
|
/// A clip's content time is SECONDS for sampled audio but BEATS for MIDI — the same polymorphism
|
||||||
|
/// `ClipInstance::trim_start`/`trim_end` carry. Passing these as bare `f64`s meant the caller and
|
||||||
|
/// the engine could disagree about the unit with nothing to catch it: an audio trim of "1.0" was
|
||||||
|
/// once stored as `Beats(1.0)` for the clip's external duration, so a 1-second split played back as
|
||||||
|
/// half a second at 120 BPM. Tagging the domain makes that a type error instead of a bug report.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum TrimRange {
|
||||||
|
/// Sampled-audio content time.
|
||||||
|
Seconds { start: Seconds, end: Seconds },
|
||||||
|
/// MIDI content time.
|
||||||
|
Beats { start: Beats, end: Beats },
|
||||||
|
}
|
||||||
|
|
||||||
/// Commands sent from UI/control thread to audio thread
|
/// Commands sent from UI/control thread to audio thread
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
|
|
@ -31,10 +46,9 @@ pub enum Command {
|
||||||
|
|
||||||
// Clip management commands
|
// Clip management commands
|
||||||
/// Move a clip to a new timeline position (track_id, clip_id, new_external_start)
|
/// Move a clip to a new timeline position (track_id, clip_id, new_external_start)
|
||||||
MoveClip(TrackId, ClipId, f64),
|
MoveClip(TrackId, ClipId, Beats),
|
||||||
/// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end)
|
/// Trim a clip's internal boundaries — which portion of the source content is used.
|
||||||
/// This changes which portion of the source content is used
|
TrimClip(TrackId, ClipId, TrimRange),
|
||||||
TrimClip(TrackId, ClipId, f64, f64),
|
|
||||||
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
|
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
|
||||||
/// If duration > internal duration, the clip will loop
|
/// If duration > internal duration, the clip will loop
|
||||||
ExtendClip(TrackId, ClipId, f64),
|
ExtendClip(TrackId, ClipId, f64),
|
||||||
|
|
@ -299,6 +313,22 @@ pub enum AudioEvent {
|
||||||
RecordingProgress(ClipId, Seconds),
|
RecordingProgress(ClipId, Seconds),
|
||||||
/// Recording stopped (clip_id, pool_index, waveform)
|
/// Recording stopped (clip_id, pool_index, waveform)
|
||||||
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
|
RecordingStopped(ClipId, usize, Vec<WaveformPeak>),
|
||||||
|
/// A recording that wrapped the cycle region at least once, and so became multi-take.
|
||||||
|
///
|
||||||
|
/// Each take spans the full region and they're all the same length (partial passes are padded
|
||||||
|
/// with silence), so the editor can promote the recording clip straight to a take folder.
|
||||||
|
CycleRecordingStopped {
|
||||||
|
clip_id: ClipId,
|
||||||
|
/// One entry per pass: (audio pool index, waveform peaks), in recording order.
|
||||||
|
takes: Vec<(usize, Vec<WaveformPeak>)>,
|
||||||
|
/// Where the takes sit on the timeline — the cycle region's start, not the punch-in point.
|
||||||
|
loop_start: Beats,
|
||||||
|
/// The region's length in beats (what the take folder stores as `recorded_loop_beats`).
|
||||||
|
loop_len_beats: Beats,
|
||||||
|
/// The same length in seconds — the take folder's content duration, which is seconds-domain
|
||||||
|
/// for audio.
|
||||||
|
loop_len_seconds: Seconds,
|
||||||
|
},
|
||||||
/// Recording error (error_message)
|
/// Recording error (error_message)
|
||||||
RecordingError(String),
|
RecordingError(String),
|
||||||
/// MIDI recording stopped (track_id, clip_id, note_count)
|
/// MIDI recording stopped (track_id, clip_id, note_count)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,123 @@ pub struct BackendContext<'a> {
|
||||||
// Future: pub video_controller: Option<&'a mut VideoController>,
|
// Future: pub video_controller: Option<&'a mut VideoController>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl BackendContext<'_> {
|
||||||
|
/// Hand a clip instance to the audio engine and record it in the instance→backend map.
|
||||||
|
///
|
||||||
|
/// Take folders are resolved through the instance's `active_take`, so the backend gets whichever
|
||||||
|
/// take is selected. Returns the backend track and instance IDs, or `None` when there's nothing
|
||||||
|
/// to sync yet (a recording in progress, or an empty take folder).
|
||||||
|
///
|
||||||
|
/// Lives here rather than in any one action because more than one action needs it: adding an
|
||||||
|
/// instance, and switching a take folder's active take (which is a remove + re-add, there being
|
||||||
|
/// no in-place pool-swap command). Keeping one implementation keeps the trim/duration
|
||||||
|
/// conversions — the easy thing to get subtly wrong, since `trim_*` is SECONDS while
|
||||||
|
/// `timeline_*` is BEATS — from drifting between copies.
|
||||||
|
pub fn add_clip_instance(
|
||||||
|
&mut self,
|
||||||
|
document: &Document,
|
||||||
|
layer_id: &Uuid,
|
||||||
|
instance: &crate::clip::ClipInstance,
|
||||||
|
) -> Result<Option<(daw_backend::TrackId, BackendClipInstanceId)>, String> {
|
||||||
|
use crate::clip::ResolvedContent;
|
||||||
|
|
||||||
|
let clip = document
|
||||||
|
.get_audio_clip(&instance.clip_id)
|
||||||
|
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
|
let track_id = *self
|
||||||
|
.layer_to_track_map
|
||||||
|
.get(layer_id)
|
||||||
|
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
|
||||||
|
|
||||||
|
let resolved = clip.resolve(instance.active_take);
|
||||||
|
let content_duration = clip.content_duration().native();
|
||||||
|
let internal_start = instance.trim_start;
|
||||||
|
let internal_end = instance.trim_end.unwrap_or(content_duration);
|
||||||
|
let start_time = instance.timeline_start;
|
||||||
|
|
||||||
|
let controller = self
|
||||||
|
.audio_controller
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||||
|
|
||||||
|
let backend_id = match resolved {
|
||||||
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
|
use daw_backend::command::{Query, QueryResponse};
|
||||||
|
|
||||||
|
// MIDI trims are in the BEATS domain, so the fallback span is beats too.
|
||||||
|
let external_duration = instance
|
||||||
|
.timeline_duration
|
||||||
|
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||||
|
|
||||||
|
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||||
|
0, // assigned by the backend
|
||||||
|
midi_clip_id,
|
||||||
|
daw_backend::Beats(internal_start),
|
||||||
|
daw_backend::Beats(internal_end),
|
||||||
|
start_time,
|
||||||
|
external_duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
match controller
|
||||||
|
.send_query(Query::AddMidiClipInstanceSync(track_id, midi_instance))?
|
||||||
|
{
|
||||||
|
QueryResponse::MidiClipInstanceAdded(Ok(id)) => BackendClipInstanceId::Midi(id),
|
||||||
|
QueryResponse::MidiClipInstanceAdded(Err(e)) => return Err(e),
|
||||||
|
_ => return Err("Unexpected query response".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResolvedContent::Audio { audio_pool_index } => {
|
||||||
|
// `trim_*` and the clip's content duration are SECONDS (audio content time); the
|
||||||
|
// backend's start/duration are BEATS.
|
||||||
|
//
|
||||||
|
// When `timeline_duration` is set it's already beats; otherwise the clip occupies
|
||||||
|
// its natural content length, so convert that seconds-span to beats *at the clip's
|
||||||
|
// start* (NOT `internal_end - internal_start`, which is seconds — that was the
|
||||||
|
// seconds-as-beats bug that made clips stop early at anything but 60 BPM).
|
||||||
|
let effective_duration = instance.timeline_duration.unwrap_or_else(|| {
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
||||||
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
||||||
|
- start_time
|
||||||
|
});
|
||||||
|
|
||||||
|
let id = controller.add_audio_clip(
|
||||||
|
track_id,
|
||||||
|
audio_pool_index,
|
||||||
|
start_time,
|
||||||
|
effective_duration,
|
||||||
|
daw_backend::Seconds(internal_start),
|
||||||
|
);
|
||||||
|
BackendClipInstanceId::Audio(id)
|
||||||
|
}
|
||||||
|
// Nothing to sync until it has content.
|
||||||
|
ResolvedContent::Recording => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.clip_instance_to_backend_map
|
||||||
|
.insert(instance.id, backend_id);
|
||||||
|
|
||||||
|
Ok(Some((track_id, backend_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a clip instance's backend clip and drop it from the instance→backend map.
|
||||||
|
pub fn remove_clip_instance(
|
||||||
|
&mut self,
|
||||||
|
track_id: daw_backend::TrackId,
|
||||||
|
backend_id: BackendClipInstanceId,
|
||||||
|
instance_id: Uuid,
|
||||||
|
) {
|
||||||
|
if let Some(controller) = self.audio_controller.as_mut() {
|
||||||
|
match backend_id {
|
||||||
|
BackendClipInstanceId::Midi(id) => controller.remove_midi_clip(track_id, id),
|
||||||
|
BackendClipInstanceId::Audio(id) => controller.remove_audio_clip(track_id, id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.clip_instance_to_backend_map.remove(&instance_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Action trait for undo/redo operations
|
/// Action trait for undo/redo operations
|
||||||
///
|
///
|
||||||
/// Each action must be able to execute (apply changes) and rollback (undo changes).
|
/// Each action must be able to execute (apply changes) and rollback (undo changes).
|
||||||
|
|
|
||||||
|
|
@ -196,113 +196,23 @@ impl Action for AddClipInstanceAction {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the clip from the document
|
// Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so
|
||||||
let clip = document
|
// the trim/duration conversions (and take-folder resolution) live in exactly one place.
|
||||||
.get_audio_clip(&self.clip_instance.clip_id)
|
if let Some((track_id, backend_id)) =
|
||||||
.ok_or_else(|| "Audio clip not found".to_string())?;
|
backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)?
|
||||||
|
{
|
||||||
// Look up backend track ID from layer mapping
|
self.backend_track_id = Some(track_id);
|
||||||
let backend_track_id = backend
|
match backend_id {
|
||||||
.layer_to_track_map
|
crate::action::BackendClipInstanceId::Midi(id) => {
|
||||||
.get(&self.layer_id)
|
self.backend_midi_instance_id = Some(id)
|
||||||
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
|
}
|
||||||
|
crate::action::BackendClipInstanceId::Audio(id) => {
|
||||||
// Get audio controller
|
self.backend_audio_instance_id = Some(id)
|
||||||
let controller = backend
|
|
||||||
.audio_controller
|
|
||||||
.as_mut()
|
|
||||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
|
||||||
|
|
||||||
// Handle different clip types
|
|
||||||
use crate::clip::AudioClipType;
|
|
||||||
match &clip.clip_type {
|
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
|
||||||
// Create a MIDI clip instance referencing the existing clip in the backend pool
|
|
||||||
// No need to add to pool again - it was added during MIDI import
|
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
|
||||||
|
|
||||||
// Calculate internal start/end from trim parameters
|
|
||||||
let internal_start = self.clip_instance.trim_start;
|
|
||||||
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
let external_start = self.clip_instance.timeline_start;
|
|
||||||
|
|
||||||
// Calculate external duration (for looping if timeline_duration is set).
|
|
||||||
// MIDI trims are beats-domain, so the fallback span is beats too.
|
|
||||||
let external_duration = self.clip_instance.timeline_duration
|
|
||||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
|
||||||
|
|
||||||
// Create MidiClipInstance
|
|
||||||
let instance = daw_backend::MidiClipInstance::new(
|
|
||||||
0, // Instance ID will be assigned by backend
|
|
||||||
*midi_clip_id,
|
|
||||||
daw_backend::Beats(internal_start),
|
|
||||||
daw_backend::Beats(internal_end),
|
|
||||||
external_start,
|
|
||||||
external_duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send query to add instance and get instance ID
|
|
||||||
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
|
||||||
|
|
||||||
match controller.send_query(query)? {
|
|
||||||
QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => {
|
|
||||||
self.backend_track_id = Some(*backend_track_id);
|
|
||||||
self.backend_midi_instance_id = Some(instance_id);
|
|
||||||
|
|
||||||
// Add to global clip instance mapping
|
|
||||||
backend.clip_instance_to_backend_map.insert(
|
|
||||||
self.clip_instance.id,
|
|
||||||
crate::action::BackendClipInstanceId::Midi(instance_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
|
|
||||||
_ => Err("Unexpected query response".to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { audio_pool_index } => {
|
|
||||||
// `trim_*` / `clip.duration` are in SECONDS (audio content time),
|
|
||||||
// while `timeline_*` and the backend's `duration` are in BEATS.
|
|
||||||
let internal_start = self.clip_instance.trim_start;
|
|
||||||
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
let start_time = self.clip_instance.timeline_start;
|
|
||||||
// `effective_duration` is in BEATS. When `timeline_duration` is set
|
|
||||||
// it already is; otherwise the clip occupies its natural content
|
|
||||||
// length, so convert that seconds-span to beats at the clip's start
|
|
||||||
// (NOT `internal_end - internal_start`, which is seconds — that was
|
|
||||||
// the seconds-as-beats bug that made clips stop early off 60 BPM).
|
|
||||||
let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| {
|
|
||||||
let tempo_map = document.tempo_map();
|
|
||||||
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
|
||||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
|
||||||
- start_time
|
|
||||||
});
|
|
||||||
|
|
||||||
let instance_id = controller.add_audio_clip(
|
|
||||||
*backend_track_id,
|
|
||||||
*audio_pool_index,
|
|
||||||
start_time,
|
|
||||||
effective_duration,
|
|
||||||
daw_backend::Seconds(internal_start),
|
|
||||||
);
|
|
||||||
|
|
||||||
self.backend_track_id = Some(*backend_track_id);
|
|
||||||
self.backend_audio_instance_id = Some(instance_id);
|
|
||||||
|
|
||||||
// Add to global clip instance mapping
|
|
||||||
backend.clip_instance_to_backend_map.insert(
|
|
||||||
self.clip_instance.id,
|
|
||||||
crate::action::BackendClipInstanceId::Audio(instance_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
AudioClipType::Recording => {
|
|
||||||
// Recording clips are not synced to backend until finalized
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
|
fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ impl Action for LoopClipInstancesAction {
|
||||||
|
|
||||||
impl LoopClipInstancesAction {
|
impl LoopClipInstancesAction {
|
||||||
fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> {
|
fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> {
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
|
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
|
|
@ -145,9 +145,9 @@ impl LoopClipInstancesAction {
|
||||||
let external_start = instance.timeline_start - left_duration;
|
let external_start = instance.timeline_start - left_duration;
|
||||||
|
|
||||||
let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> {
|
let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> {
|
||||||
match &clip.clip_type {
|
match &clip.resolve(instance.active_take) {
|
||||||
AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id),
|
ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id),
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
let backend_id = backend.clip_instance_to_backend_map.get(inst_id)
|
let backend_id = backend.clip_instance_to_backend_map.get(inst_id)
|
||||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?;
|
.ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?;
|
||||||
match backend_id {
|
match backend_id {
|
||||||
|
|
@ -155,7 +155,7 @@ impl LoopClipInstancesAction {
|
||||||
_ => Err("Expected audio instance ID for sampled clip".to_string()),
|
_ => Err("Expected audio instance ID for sampled clip".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => Err("Cannot sync recording clip".to_string()),
|
ResolvedContent::Recording => Err("Cannot sync recording clip".to_string()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ pub mod reorder_clip_instances;
|
||||||
pub mod paint_bucket;
|
pub mod paint_bucket;
|
||||||
pub mod remove_effect;
|
pub mod remove_effect;
|
||||||
pub mod set_cycle_region;
|
pub mod set_cycle_region;
|
||||||
|
pub mod set_active_take;
|
||||||
pub mod set_document_properties;
|
pub mod set_document_properties;
|
||||||
pub mod set_instance_properties;
|
pub mod set_instance_properties;
|
||||||
pub mod set_layer_properties;
|
pub mod set_layer_properties;
|
||||||
|
|
@ -52,6 +53,7 @@ pub mod resize_text_box;
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use set_cycle_region::SetCycleRegionAction;
|
pub use set_cycle_region::SetCycleRegionAction;
|
||||||
|
pub use set_active_take::SetActiveTakeAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
pub use add_layer::AddLayerAction;
|
pub use add_layer::AddLayerAction;
|
||||||
pub use add_shape::AddShapeAction;
|
pub use add_shape::AddShapeAction;
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
|
|
||||||
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
|
|
||||||
// Get audio controller
|
// Get audio controller
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
|
@ -246,12 +246,12 @@ impl Action for MoveClipInstancesAction {
|
||||||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
// Handle move based on clip type
|
// Handle move based on clip type
|
||||||
match &clip.clip_type {
|
match &clip.resolve(instance.active_take) {
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
// For MIDI: move_clip expects the pool clip ID
|
// For MIDI: move_clip expects the pool clip ID
|
||||||
controller.move_clip(*track_id, *midi_clip_id, *new_start);
|
controller.move_clip(*track_id, *midi_clip_id, *new_start);
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
// For sampled audio: move_clip expects the instance ID
|
// For sampled audio: move_clip expects the instance ID
|
||||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||||
|
|
@ -263,7 +263,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Recording clips cannot be moved - skip
|
// Recording clips cannot be moved - skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,7 +275,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
|
|
||||||
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
|
|
||||||
// Get audio controller
|
// Get audio controller
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
|
@ -330,12 +330,12 @@ impl Action for MoveClipInstancesAction {
|
||||||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
// Handle move based on clip type (restore old position)
|
// Handle move based on clip type (restore old position)
|
||||||
match &clip.clip_type {
|
match &clip.resolve(instance.active_take) {
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
// For MIDI: move_clip expects the pool clip ID
|
// For MIDI: move_clip expects the pool clip ID
|
||||||
controller.move_clip(*track_id, *midi_clip_id, *old_start);
|
controller.move_clip(*track_id, *midi_clip_id, *old_start);
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
// For sampled audio: move_clip expects the instance ID
|
// For sampled audio: move_clip expects the instance ID
|
||||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||||
|
|
@ -347,7 +347,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Recording clips cannot be moved - skip
|
// Recording clips cannot be moved - skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ impl Action for RemoveClipInstancesAction {
|
||||||
backend: &mut BackendContext,
|
backend: &mut BackendContext,
|
||||||
document: &Document,
|
document: &Document,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
|
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
|
|
@ -165,8 +165,8 @@ impl Action for RemoveClipInstancesAction {
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
match &clip.clip_type {
|
match &clip.resolve(instance.active_take) {
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
use daw_backend::command::{Query, QueryResponse};
|
||||||
|
|
||||||
let internal_start = instance.trim_start;
|
let internal_start = instance.trim_start;
|
||||||
|
|
@ -196,7 +196,7 @@ impl Action for RemoveClipInstancesAction {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { audio_pool_index } => {
|
ResolvedContent::Audio { audio_pool_index } => {
|
||||||
let internal_start = instance.trim_start;
|
let internal_start = instance.trim_start;
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||||
let start_time = instance.timeline_start;
|
let start_time = instance.timeline_start;
|
||||||
|
|
@ -221,7 +221,7 @@ impl Action for RemoveClipInstancesAction {
|
||||||
BackendClipInstanceId::Audio(new_id),
|
BackendClipInstanceId::Audio(new_id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {}
|
ResolvedContent::Recording => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
//! Choose which take of a take-folder clip an instance plays.
|
||||||
|
//!
|
||||||
|
//! The take list lives on the *clip* but the selection lives on the *instance*, so two instances of
|
||||||
|
//! the same folder can play different takes. Splitting a take-folder instance clones it, which is
|
||||||
|
//! what makes comping work: set take 1 on the left half and take 3 on the right, and you've comped.
|
||||||
|
//!
|
||||||
|
//! There's no in-place pool-swap command in the backend, so switching a take means removing the
|
||||||
|
//! instance's backend clip and re-adding it against the new take's audio/MIDI resource. Both halves
|
||||||
|
//! of that go through `BackendContext`, which is also what `AddClipInstanceAction` uses.
|
||||||
|
|
||||||
|
use crate::action::{Action, BackendClipInstanceId, BackendContext};
|
||||||
|
use crate::document::Document;
|
||||||
|
use crate::layer::AnyLayer;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Action that points a clip instance at a different take of its take folder.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SetActiveTakeAction {
|
||||||
|
layer_id: Uuid,
|
||||||
|
instance_id: Uuid,
|
||||||
|
new_take: Option<usize>,
|
||||||
|
old_take: Option<usize>,
|
||||||
|
/// The backend track/clip the instance was on before we swapped, so rollback can undo it.
|
||||||
|
backend_track_id: Option<daw_backend::TrackId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SetActiveTakeAction {
|
||||||
|
pub fn new(layer_id: Uuid, instance_id: Uuid, new_take: usize, old_take: Option<usize>) -> Self {
|
||||||
|
Self {
|
||||||
|
layer_id,
|
||||||
|
instance_id,
|
||||||
|
new_take: Some(new_take),
|
||||||
|
old_take,
|
||||||
|
backend_track_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the instance at `take`, mutating the document. Shared by execute and rollback.
|
||||||
|
fn apply(&self, document: &mut Document, take: Option<usize>) -> Result<(), String> {
|
||||||
|
let layer = document
|
||||||
|
.get_layer_mut(&self.layer_id)
|
||||||
|
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||||
|
let AnyLayer::Audio(audio_layer) = layer else {
|
||||||
|
return Err("Take folders only exist on audio layers".to_string());
|
||||||
|
};
|
||||||
|
let instance = audio_layer
|
||||||
|
.clip_instances
|
||||||
|
.iter_mut()
|
||||||
|
.find(|ci| ci.id == self.instance_id)
|
||||||
|
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||||
|
instance.active_take = take;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap the instance's backend clip to whatever take the document now says is active.
|
||||||
|
///
|
||||||
|
/// Called after the document has already been mutated, so re-adding just re-resolves the
|
||||||
|
/// instance — `BackendContext::add_clip_instance` reads `active_take` itself.
|
||||||
|
fn resync(&mut self, backend: &mut BackendContext, document: &Document) -> Result<(), String> {
|
||||||
|
let instance = document
|
||||||
|
.get_layer(&self.layer_id)
|
||||||
|
.and_then(|l| match l {
|
||||||
|
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||||
|
|
||||||
|
// Drop the old backend clip first. Its track comes from the map we're about to overwrite,
|
||||||
|
// so read it before add_clip_instance replaces the entry.
|
||||||
|
let existing: Option<BackendClipInstanceId> = backend
|
||||||
|
.clip_instance_to_backend_map
|
||||||
|
.get(&self.instance_id)
|
||||||
|
.copied();
|
||||||
|
let track_id = backend.layer_to_track_map.get(&self.layer_id).copied();
|
||||||
|
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
|
||||||
|
backend.remove_clip_instance(track_id, backend_id, self.instance_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = backend.add_clip_instance(document, &self.layer_id, &instance)?;
|
||||||
|
self.backend_track_id = added.map(|(track_id, _)| track_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for SetActiveTakeAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
|
self.apply(document, self.new_take)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
|
self.apply(document, self.old_take)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
match self.new_take {
|
||||||
|
Some(i) => format!("Select take {}", i + 1),
|
||||||
|
None => "Select take".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_backend(
|
||||||
|
&mut self,
|
||||||
|
backend: &mut BackendContext,
|
||||||
|
document: &Document,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.resync(backend, document)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback_backend(
|
||||||
|
&mut self,
|
||||||
|
backend: &mut BackendContext,
|
||||||
|
document: &Document,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.resync(backend, document)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -146,27 +146,37 @@ impl Action for SplitClipInstanceAction {
|
||||||
self.original_trim_end = instance.trim_end;
|
self.original_trim_end = instance.trim_end;
|
||||||
self.original_timeline_duration = instance.timeline_duration;
|
self.original_timeline_duration = instance.timeline_duration;
|
||||||
|
|
||||||
// Check if this is a looping clip. `content_duration` is a trim-domain
|
// The clip's content duration in the SAME domain as its trims — seconds for audio/video/
|
||||||
// span (seconds), so `clip_duration` must be unwrapped as seconds.
|
// vector, beats for MIDI. All the content math below is trim-domain, so it has to be done
|
||||||
|
// in whichever domain this clip uses; `clip_duration` above is always seconds and would
|
||||||
|
// silently add a seconds delta to a MIDI clip's beats trim.
|
||||||
|
let trim_duration = document
|
||||||
|
.clip_trim_duration(&instance.clip_id)
|
||||||
|
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
let is_looping = instance.timeline_duration.is_some();
|
let is_looping = instance.timeline_duration.is_some();
|
||||||
let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start;
|
let content_duration = instance.trim_end.unwrap_or(trim_duration.native()) - instance.trim_start;
|
||||||
|
|
||||||
// Timeline split point (beats).
|
// Timeline split point (beats).
|
||||||
let time_into_clip = self.split_time - instance.timeline_start;
|
let time_into_clip = self.split_time - instance.timeline_start;
|
||||||
let left_duration = time_into_clip;
|
let left_duration = time_into_clip;
|
||||||
let right_duration = effective_duration - left_duration;
|
let right_duration = effective_duration - left_duration;
|
||||||
|
|
||||||
// How far the split lands into the clip's *content* (seconds, trim domain).
|
// How far the split lands into the clip's *content*, expressed in the trim domain.
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time)
|
let time_into_content = match trim_duration {
|
||||||
- tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64();
|
crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(),
|
||||||
|
crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time)
|
||||||
|
- tempo_map.beats_to_seconds(instance.timeline_start))
|
||||||
|
.seconds_to_f64(),
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate content split time (seconds)
|
// Calculate the content split point (trim domain).
|
||||||
let content_split_time = if is_looping {
|
let content_split_time = if is_looping && content_duration > 0.0 {
|
||||||
// For looping clips, wrap around content
|
// For looping clips, wrap around content
|
||||||
instance.trim_start + (time_into_clip_secs % content_duration)
|
instance.trim_start + (time_into_content % content_duration)
|
||||||
} else {
|
} else {
|
||||||
instance.trim_start + time_into_clip_secs
|
instance.trim_start + time_into_content
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clone the instance for the right side
|
// Clone the instance for the right side
|
||||||
|
|
@ -370,9 +380,9 @@ impl Action for SplitClipInstanceAction {
|
||||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||||
|
|
||||||
// Handle different clip types
|
// Handle different clip types
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
match &clip.clip_type {
|
match &clip.resolve(original_instance.active_take) {
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
use daw_backend::command::{Query, QueryResponse};
|
||||||
|
|
||||||
// 1. Trim the original (left) instance
|
// 1. Trim the original (left) instance
|
||||||
|
|
@ -383,7 +393,7 @@ impl Action for SplitClipInstanceAction {
|
||||||
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
|
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
|
||||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||||
{
|
{
|
||||||
controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Add the new (right) instance
|
// 2. Add the new (right) instance
|
||||||
|
|
@ -422,7 +432,7 @@ impl Action for SplitClipInstanceAction {
|
||||||
_ => Err("Unexpected query response".to_string()),
|
_ => Err("Unexpected query response".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { audio_pool_index } => {
|
ResolvedContent::Audio { audio_pool_index } => {
|
||||||
// 1. Trim the original (left) instance
|
// 1. Trim the original (left) instance
|
||||||
let orig_internal_start = original_instance.trim_start;
|
let orig_internal_start = original_instance.trim_start;
|
||||||
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
|
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||||
|
|
@ -431,7 +441,7 @@ impl Action for SplitClipInstanceAction {
|
||||||
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
|
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
|
||||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||||
{
|
{
|
||||||
controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Add the new (right) instance
|
// 2. Add the new (right) instance
|
||||||
|
|
@ -465,7 +475,7 @@ impl Action for SplitClipInstanceAction {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Recording clips cannot be split
|
// Recording clips cannot be split
|
||||||
Err("Cannot split a clip that is currently recording".to_string())
|
Err("Cannot split a clip that is currently recording".to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -502,23 +512,23 @@ impl Action for SplitClipInstanceAction {
|
||||||
let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native());
|
let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native());
|
||||||
|
|
||||||
// Restore based on clip type
|
// Restore based on clip type
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
match &clip.clip_type {
|
match &clip.resolve(instance.active_take) {
|
||||||
AudioClipType::Midi { .. } => {
|
ResolvedContent::Midi { .. } => {
|
||||||
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
|
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
|
||||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||||
{
|
{
|
||||||
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
|
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
|
||||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
||||||
{
|
{
|
||||||
controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end);
|
controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Recording clips - nothing to rollback
|
// Recording clips - nothing to rollback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -575,4 +585,42 @@ mod tests {
|
||||||
let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0));
|
let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0));
|
||||||
assert_eq!(action.description(), "Split clip instance");
|
assert_eq!(action.description(), "Split clip instance");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitting_a_midi_clip_stays_in_the_beats_domain() {
|
||||||
|
// Regression: `trim_start`/`trim_end` are domain-polymorphic — SECONDS for audio/video/
|
||||||
|
// vector, but BEATS for MIDI (the backend takes MIDI trims as `Beats`). Split used to map
|
||||||
|
// the split point into the clip's content in seconds unconditionally, so on a MIDI clip it
|
||||||
|
// added a seconds delta to a beats offset. At anything but 60 BPM the right half started at
|
||||||
|
// the wrong place in the content.
|
||||||
|
//
|
||||||
|
// At 120 BPM, beat 4 is 2 SECONDS in. The right half must trim to beat 4, not "4 seconds"
|
||||||
|
// (= beat 8) and not 2 (the seconds value).
|
||||||
|
let mut document = Document::new("Test");
|
||||||
|
document.set_bpm(120.0);
|
||||||
|
|
||||||
|
// 8-beat MIDI clip at the timeline origin.
|
||||||
|
let clip = crate::clip::AudioClip::new_midi("Midi", 1, daw_backend::Beats(8.0));
|
||||||
|
let clip_id = document.add_audio_clip(clip);
|
||||||
|
|
||||||
|
let mut audio_layer = crate::layer::AudioLayer::new("Layer 1");
|
||||||
|
let mut instance = ClipInstance::new(clip_id);
|
||||||
|
instance.timeline_start = daw_backend::Beats::ZERO;
|
||||||
|
instance.trim_start = 0.0;
|
||||||
|
instance.trim_end = Some(8.0); // beats
|
||||||
|
let instance_id = instance.id;
|
||||||
|
audio_layer.clip_instances.push(instance);
|
||||||
|
let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer));
|
||||||
|
|
||||||
|
let mut action = SplitClipInstanceAction::new(layer_id, instance_id, daw_backend::Beats(4.0));
|
||||||
|
action.execute(&mut document).expect("split");
|
||||||
|
let new_id = action.new_instance_id().expect("right instance");
|
||||||
|
|
||||||
|
let AnyLayer::Audio(al) = document.get_layer(&layer_id).unwrap() else { panic!() };
|
||||||
|
let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap();
|
||||||
|
let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(right.trim_start, 4.0, "right half must start 4 BEATS into the content");
|
||||||
|
assert_eq!(left.trim_end, Some(4.0), "left half must end 4 BEATS into the content");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ impl Action for TrimClipInstancesAction {
|
||||||
|
|
||||||
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
|
|
||||||
// Get audio controller
|
// Get audio controller
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
|
@ -427,24 +427,24 @@ impl Action for TrimClipInstancesAction {
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||||
|
|
||||||
// Handle trim based on clip type
|
// Handle trim based on clip type
|
||||||
match &clip.clip_type {
|
match &clip.resolve(instance.active_take) {
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
// For MIDI: trim_clip expects the pool clip ID
|
// For MIDI: trim_clip expects the pool clip ID
|
||||||
controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end);
|
controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
// For sampled audio: trim_clip expects the instance ID
|
// For sampled audio: trim_clip expects the instance ID
|
||||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||||
|
|
||||||
match backend_instance_id {
|
match backend_instance_id {
|
||||||
crate::action::BackendClipInstanceId::Audio(audio_id) => {
|
crate::action::BackendClipInstanceId::Audio(audio_id) => {
|
||||||
controller.trim_clip(*track_id, *audio_id, internal_start, internal_end);
|
controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end));
|
||||||
}
|
}
|
||||||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Recording clips cannot be trimmed - skip
|
// Recording clips cannot be trimmed - skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -456,7 +456,7 @@ impl Action for TrimClipInstancesAction {
|
||||||
|
|
||||||
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::clip::AudioClipType;
|
use crate::clip::ResolvedContent;
|
||||||
|
|
||||||
// Get audio controller
|
// Get audio controller
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
|
@ -522,24 +522,24 @@ impl Action for TrimClipInstancesAction {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle trim based on clip type
|
// Handle trim based on clip type
|
||||||
match &clip.clip_type {
|
match &clip.resolve(instance.active_take) {
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
// For MIDI: trim_clip expects the pool clip ID
|
// For MIDI: trim_clip expects the pool clip ID
|
||||||
controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end);
|
controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));
|
||||||
}
|
}
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
// For sampled audio: trim_clip expects the instance ID
|
// For sampled audio: trim_clip expects the instance ID
|
||||||
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id)
|
||||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?;
|
||||||
|
|
||||||
match backend_instance_id {
|
match backend_instance_id {
|
||||||
crate::action::BackendClipInstanceId::Audio(audio_id) => {
|
crate::action::BackendClipInstanceId::Audio(audio_id) => {
|
||||||
controller.trim_clip(*track_id, *audio_id, internal_start, internal_end);
|
controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end));
|
||||||
}
|
}
|
||||||
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Recording clips cannot be trimmed - skip
|
// Recording clips cannot be trimmed - skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -466,6 +466,44 @@ pub enum AudioClipType {
|
||||||
/// Placeholder for a clip that is currently being recorded.
|
/// Placeholder for a clip that is currently being recorded.
|
||||||
/// The audio_pool_index will be assigned when recording stops.
|
/// The audio_pool_index will be assigned when recording stops.
|
||||||
Recording,
|
Recording,
|
||||||
|
/// A folder of alternate takes, produced by cycle recording.
|
||||||
|
///
|
||||||
|
/// Each pass of the transport around the cycle region becomes one take. Every take spans the
|
||||||
|
/// **full** cycle region (partial passes are padded with silence at capture time), so all takes
|
||||||
|
/// are the same length and share this clip's `duration` — which in turn means switching takes
|
||||||
|
/// never changes the clip's geometry, and splitting a take-folder instance yields two halves
|
||||||
|
/// whose takes still line up. Which take actually sounds is per-*instance*
|
||||||
|
/// ([`ClipInstance::active_take`]), not per-clip, so a split can play take 1 on the left and
|
||||||
|
/// take 3 on the right. That's comping.
|
||||||
|
TakeFolder {
|
||||||
|
/// The takes, in the order they were recorded. Never empty in practice.
|
||||||
|
takes: Vec<AudioTake>,
|
||||||
|
/// The cycle region's length in beats at the time of recording.
|
||||||
|
///
|
||||||
|
/// Audio takes are segmented geometrically (by sample count), so they're only meaningful
|
||||||
|
/// against the tempo they were cut at. Keeping the recorded length lets a future
|
||||||
|
/// time-stretch/conform feature reconcile the takes if the tempo changes underneath them.
|
||||||
|
recorded_loop_beats: Beats,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One take in a [`AudioClipType::TakeFolder`].
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AudioTake {
|
||||||
|
/// Display name, e.g. "Take 1".
|
||||||
|
pub name: String,
|
||||||
|
/// The recorded content this take points at.
|
||||||
|
pub content: TakeContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a take actually holds. A folder's takes are all the same kind — one cycle-record session
|
||||||
|
/// captures either audio or MIDI, never a mix.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum TakeContent {
|
||||||
|
/// Sampled audio: index into the audio pool.
|
||||||
|
Audio { audio_pool_index: usize },
|
||||||
|
/// MIDI: backend MIDI clip ID.
|
||||||
|
Midi { midi_clip_id: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A clip's content duration, tagged by its native unit.
|
/// A clip's content duration, tagged by its native unit.
|
||||||
|
|
@ -532,11 +570,10 @@ impl AudioClip {
|
||||||
/// The clip's content duration, tagged with its native domain (seconds for sampled/recording,
|
/// The clip's content duration, tagged with its native domain (seconds for sampled/recording,
|
||||||
/// beats for MIDI). This is the only sanctioned way to read the raw `duration` field.
|
/// beats for MIDI). This is the only sanctioned way to read the raw `duration` field.
|
||||||
pub fn content_duration(&self) -> ClipDuration {
|
pub fn content_duration(&self) -> ClipDuration {
|
||||||
match self.clip_type {
|
if self.is_midi_domain() {
|
||||||
AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)),
|
ClipDuration::Beats(Beats(self.duration))
|
||||||
AudioClipType::Sampled { .. } | AudioClipType::Recording => {
|
} else {
|
||||||
ClipDuration::Seconds(Seconds(self.duration))
|
ClipDuration::Seconds(Seconds(self.duration))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,15 +582,29 @@ impl AudioClip {
|
||||||
pub fn set_content_duration(&mut self, duration: ClipDuration) {
|
pub fn set_content_duration(&mut self, duration: ClipDuration) {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
matches!(
|
matches!(
|
||||||
(&self.clip_type, duration),
|
(self.is_midi_domain(), duration),
|
||||||
(AudioClipType::Midi { .. }, ClipDuration::Beats(_))
|
(true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_))
|
||||||
| (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_))
|
|
||||||
),
|
),
|
||||||
"clip duration domain must match clip type",
|
"clip duration domain must match clip type",
|
||||||
);
|
);
|
||||||
self.duration = duration.native();
|
self.duration = duration.native();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds.
|
||||||
|
///
|
||||||
|
/// A take folder inherits the domain of its takes, which are all the same kind — one
|
||||||
|
/// cycle-record session captures either audio or MIDI, never a mix. An empty folder can't
|
||||||
|
/// happen in practice; call it seconds so the fallback is the common case.
|
||||||
|
fn is_midi_domain(&self) -> bool {
|
||||||
|
match &self.clip_type {
|
||||||
|
AudioClipType::Midi { .. } => true,
|
||||||
|
AudioClipType::Sampled { .. } | AudioClipType::Recording => false,
|
||||||
|
AudioClipType::TakeFolder { takes, .. } => {
|
||||||
|
matches!(takes.first().map(|t| &t.content), Some(TakeContent::Midi { .. }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new sampled audio clip
|
/// Create a new sampled audio clip
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|
@ -637,6 +688,125 @@ impl AudioClip {
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The clip's takes, if it's a take folder.
|
||||||
|
pub fn takes(&self) -> Option<&[AudioTake]> {
|
||||||
|
match &self.clip_type {
|
||||||
|
AudioClipType::TakeFolder { takes, .. } => Some(takes),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The take an instance's `active_take` actually selects.
|
||||||
|
///
|
||||||
|
/// `None` means take 0, which is also what an out-of-range index falls back to — an index can
|
||||||
|
/// go stale (an old `.beam`, an undo that shrank the folder), and silently playing the first
|
||||||
|
/// take beats refusing to play anything.
|
||||||
|
fn take_for(&self, active_take: Option<usize>) -> Option<&AudioTake> {
|
||||||
|
let takes = self.takes()?;
|
||||||
|
takes
|
||||||
|
.get(active_take.unwrap_or(0))
|
||||||
|
.or_else(|| takes.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this clip plays *for a given instance*, with take folders collapsed to the instance's
|
||||||
|
/// active take.
|
||||||
|
///
|
||||||
|
/// This is the sanctioned way to ask "what content do I hand the backend for this instance?".
|
||||||
|
/// Matching on `clip_type` directly will see a `TakeFolder` and have to handle it separately;
|
||||||
|
/// matching on this won't, because a folder is never a distinct case here — it's just an audio
|
||||||
|
/// or MIDI clip whose identity depends on which take is active.
|
||||||
|
pub fn resolve(&self, active_take: Option<usize>) -> ResolvedContent {
|
||||||
|
match &self.clip_type {
|
||||||
|
AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio {
|
||||||
|
audio_pool_index: *audio_pool_index,
|
||||||
|
},
|
||||||
|
AudioClipType::Midi { midi_clip_id } => ResolvedContent::Midi {
|
||||||
|
midi_clip_id: *midi_clip_id,
|
||||||
|
},
|
||||||
|
AudioClipType::Recording => ResolvedContent::Recording,
|
||||||
|
AudioClipType::TakeFolder { .. } => match self.take_for(active_take).map(|t| &t.content) {
|
||||||
|
Some(TakeContent::Audio { audio_pool_index }) => ResolvedContent::Audio {
|
||||||
|
audio_pool_index: *audio_pool_index,
|
||||||
|
},
|
||||||
|
Some(TakeContent::Midi { midi_clip_id }) => ResolvedContent::Midi {
|
||||||
|
midi_clip_id: *midi_clip_id,
|
||||||
|
},
|
||||||
|
// An empty folder has nothing to play. Treat it like a recording placeholder:
|
||||||
|
// the backend gets nothing, rather than a bogus pool index.
|
||||||
|
None => ResolvedContent::Recording,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tag a pair of raw trim bounds with this clip's content domain, ready for the backend.
|
||||||
|
///
|
||||||
|
/// `ClipInstance::trim_start`/`trim_end` are bare `f64`s whose unit depends on the clip —
|
||||||
|
/// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a
|
||||||
|
/// caller can't reach for the wrong variant: the clip is the one thing that knows.
|
||||||
|
pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange {
|
||||||
|
if self.is_midi_domain() {
|
||||||
|
daw_backend::command::TrimRange::Beats {
|
||||||
|
start: Beats(start),
|
||||||
|
end: Beats(end),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
daw_backend::command::TrimRange::Seconds {
|
||||||
|
start: Seconds(start),
|
||||||
|
end: Seconds(end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this clip owns the given audio pool index — either as a plain sampled clip, or as
|
||||||
|
/// *any* take of a take folder. Reverse lookups (backend resource → document clip) must use
|
||||||
|
/// this: a folder owns one pool file per take, not just the active one.
|
||||||
|
pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool {
|
||||||
|
match &self.clip_type {
|
||||||
|
AudioClipType::Sampled { audio_pool_index } => *audio_pool_index == pool_index,
|
||||||
|
AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| {
|
||||||
|
matches!(t.content, TakeContent::Audio { audio_pool_index } if audio_pool_index == pool_index)
|
||||||
|
}),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this clip owns the given backend MIDI clip ID. See [`Self::owns_audio_pool_index`].
|
||||||
|
pub fn owns_midi_clip_id(&self, id: u32) -> bool {
|
||||||
|
match &self.clip_type {
|
||||||
|
AudioClipType::Midi { midi_clip_id } => *midi_clip_id == id,
|
||||||
|
AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| {
|
||||||
|
matches!(t.content, TakeContent::Midi { midi_clip_id } if midi_clip_id == id)
|
||||||
|
}),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio pool index this *instance* should play. See [`Self::resolve`].
|
||||||
|
pub fn resolved_audio_pool_index(&self, active_take: Option<usize>) -> Option<usize> {
|
||||||
|
match self.resolve(active_take) {
|
||||||
|
ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backend MIDI clip ID this *instance* should play. See [`Self::resolve`].
|
||||||
|
pub fn resolved_midi_clip_id(&self, active_take: Option<usize>) -> Option<u32> {
|
||||||
|
match self.resolve(active_take) {
|
||||||
|
ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a clip instance actually plays, once take folders are resolved to their active take.
|
||||||
|
/// Produced by [`AudioClip::resolve`].
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum ResolvedContent {
|
||||||
|
Audio { audio_pool_index: usize },
|
||||||
|
Midi { midi_clip_id: u32 },
|
||||||
|
/// A recording in progress (or an empty take folder) — no backend content yet.
|
||||||
|
Recording,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unified clip enum for polymorphic handling
|
/// Unified clip enum for polymorphic handling
|
||||||
|
|
@ -741,6 +911,15 @@ pub struct ClipInstance {
|
||||||
/// Default: None (no pre-loop)
|
/// Default: None (no pre-loop)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub loop_before: Option<Beats>,
|
pub loop_before: Option<Beats>,
|
||||||
|
|
||||||
|
/// Which take of a [`AudioClipType::TakeFolder`] clip this instance plays.
|
||||||
|
///
|
||||||
|
/// Per-instance rather than per-clip so two instances of the same folder — e.g. the two halves
|
||||||
|
/// of a split — can play different takes. That's how comping works. `None` means take 0;
|
||||||
|
/// meaningless (and ignored) on non-folder clips.
|
||||||
|
/// Default: None
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub active_take: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
|
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID.
|
||||||
|
|
@ -799,6 +978,7 @@ impl ClipInstance {
|
||||||
playback_speed: 1.0,
|
playback_speed: 1.0,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
loop_before: None,
|
loop_before: None,
|
||||||
|
active_take: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -817,6 +997,7 @@ impl ClipInstance {
|
||||||
playback_speed: 1.0,
|
playback_speed: 1.0,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
loop_before: None,
|
loop_before: None,
|
||||||
|
active_take: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1070,4 +1251,99 @@ mod tests {
|
||||||
assert_eq!(instance.playback_speed, 2.0);
|
assert_eq!(instance.playback_speed, 2.0);
|
||||||
assert_eq!(instance.gain, 0.8);
|
assert_eq!(instance.gain, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a take folder of `n` audio takes with the given pool indices.
|
||||||
|
fn take_folder(pool_indices: &[usize]) -> AudioClip {
|
||||||
|
let mut clip = AudioClip::new_sampled("Cycle rec", 0, 2.0);
|
||||||
|
clip.clip_type = AudioClipType::TakeFolder {
|
||||||
|
takes: pool_indices
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &audio_pool_index)| AudioTake {
|
||||||
|
name: format!("Take {}", i + 1),
|
||||||
|
content: TakeContent::Audio { audio_pool_index },
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
recorded_loop_beats: Beats(8.0),
|
||||||
|
};
|
||||||
|
clip
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_take_selects_the_pool_file() {
|
||||||
|
let clip = take_folder(&[10, 11, 12]);
|
||||||
|
assert_eq!(clip.resolved_audio_pool_index(Some(0)), Some(10));
|
||||||
|
assert_eq!(clip.resolved_audio_pool_index(Some(2)), Some(12));
|
||||||
|
// None means take 0.
|
||||||
|
assert_eq!(clip.resolved_audio_pool_index(None), Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_take_falls_back_to_the_first() {
|
||||||
|
// An index can go stale (an old .beam, an undo that shrank the folder). Playing the first
|
||||||
|
// take beats playing nothing.
|
||||||
|
let clip = take_folder(&[10, 11]);
|
||||||
|
assert_eq!(clip.resolved_audio_pool_index(Some(99)), Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_folder_owns_every_takes_pool_file() {
|
||||||
|
// Reverse lookups (backend resource -> document clip) must find the folder via ANY take,
|
||||||
|
// not just the active one.
|
||||||
|
let clip = take_folder(&[10, 11, 12]);
|
||||||
|
assert!(clip.owns_audio_pool_index(10));
|
||||||
|
assert!(clip.owns_audio_pool_index(12));
|
||||||
|
assert!(!clip.owns_audio_pool_index(13));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn midi_take_folder_measures_duration_in_beats() {
|
||||||
|
// A folder inherits its takes' domain: MIDI takes mean the duration is beats, not seconds.
|
||||||
|
let mut clip = AudioClip::new_sampled("Cycle rec", 0, 4.0);
|
||||||
|
clip.clip_type = AudioClipType::TakeFolder {
|
||||||
|
takes: vec![AudioTake {
|
||||||
|
name: "Take 1".into(),
|
||||||
|
content: TakeContent::Midi { midi_clip_id: 7 },
|
||||||
|
}],
|
||||||
|
recorded_loop_beats: Beats(4.0),
|
||||||
|
};
|
||||||
|
assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0)));
|
||||||
|
assert_eq!(clip.resolved_midi_clip_id(Some(0)), Some(7));
|
||||||
|
assert_eq!(clip.resolved_audio_pool_index(Some(0)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn takes_are_per_instance_so_a_split_can_comp() {
|
||||||
|
// The whole point of putting active_take on the instance: two instances of the same folder
|
||||||
|
// (which is what a split produces) can play different takes.
|
||||||
|
let clip = take_folder(&[10, 11, 12]);
|
||||||
|
let mut left = ClipInstance::new(clip.id);
|
||||||
|
let mut right = left.clone();
|
||||||
|
right.id = Uuid::new_v4();
|
||||||
|
left.active_take = Some(0);
|
||||||
|
right.active_take = Some(2);
|
||||||
|
|
||||||
|
assert_eq!(clip.resolved_audio_pool_index(left.active_take), Some(10));
|
||||||
|
assert_eq!(clip.resolved_audio_pool_index(right.active_take), Some(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_instance_without_active_take_deserializes() {
|
||||||
|
// Back-compat: .beam files written before take folders have no `active_take` field.
|
||||||
|
let json = r#"{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"clip_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"transform": {"x": 0.0, "y": 0.0, "rotation": 0.0, "scale_x": 1.0, "scale_y": 1.0, "skew_x": 0.0, "skew_y": 0.0},
|
||||||
|
"opacity": 1.0,
|
||||||
|
"name": null,
|
||||||
|
"timeline_start": 0.0,
|
||||||
|
"timeline_duration": null,
|
||||||
|
"trim_start": 0.0,
|
||||||
|
"trim_end": null,
|
||||||
|
"playback_speed": 1.0,
|
||||||
|
"gain": 1.0
|
||||||
|
}"#;
|
||||||
|
let instance: ClipInstance = serde_json::from_str(json).expect("old instances must load");
|
||||||
|
assert_eq!(instance.active_take, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -780,16 +780,18 @@ impl Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the document audio clip (UUID + ref) that owns the given backend pool index.
|
/// Find the document audio clip (UUID + ref) that owns the given backend pool index.
|
||||||
|
/// A take folder owns one pool file per take, so any of them maps back to the folder.
|
||||||
pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> {
|
pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> {
|
||||||
self.audio_clips.iter()
|
self.audio_clips.iter()
|
||||||
.find(|(_, c)| c.audio_pool_index() == Some(pool_index))
|
.find(|(_, c)| c.owns_audio_pool_index(pool_index))
|
||||||
.map(|(&id, c)| (id, c))
|
.map(|(&id, c)| (id, c))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID.
|
/// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID.
|
||||||
|
/// As above, a take folder owns one MIDI clip per take.
|
||||||
pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> {
|
pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> {
|
||||||
self.audio_clips.iter()
|
self.audio_clips.iter()
|
||||||
.find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id))
|
.find(|(_, c)| c.owns_midi_clip_id(midi_clip_id))
|
||||||
.map(|(&id, c)| (id, c))
|
.map(|(&id, c)| (id, c))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -911,6 +913,23 @@ impl Document {
|
||||||
/// Searches through all clip libraries to find the clip and return its duration.
|
/// Searches through all clip libraries to find the clip and return its duration.
|
||||||
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
|
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
|
||||||
/// have infinite internal duration.
|
/// have infinite internal duration.
|
||||||
|
/// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**.
|
||||||
|
///
|
||||||
|
/// `ClipInstance::trim_*` is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for
|
||||||
|
/// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as
|
||||||
|
/// `Beats`). Anything doing arithmetic against a trim value — mapping a timeline position into
|
||||||
|
/// the clip's content, say — has to work in that same domain, and [`Self::get_clip_duration`]
|
||||||
|
/// can't tell it which: that one always converts to seconds.
|
||||||
|
///
|
||||||
|
/// Returns `None` for unknown clips.
|
||||||
|
pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option<crate::clip::ClipDuration> {
|
||||||
|
if let Some(clip) = self.audio_clips.get(clip_id) {
|
||||||
|
return Some(clip.content_duration());
|
||||||
|
}
|
||||||
|
// Everything else measures its content in wall-clock seconds.
|
||||||
|
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
|
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
|
||||||
if let Some(clip) = self.vector_clips.get(clip_id) {
|
if let Some(clip) = self.vector_clips.get(clip_id) {
|
||||||
if clip.is_group {
|
if clip.is_group {
|
||||||
|
|
|
||||||
|
|
@ -6490,6 +6490,25 @@ impl eframe::App for EditorApp {
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// While cycling, the recording keeps running but the clip only ever
|
||||||
|
// occupies ONE region — each further pass is a new take layered on
|
||||||
|
// the same span, not more length. Cap the preview there so the bar
|
||||||
|
// doesn't grow off past the loop end while the playhead wraps.
|
||||||
|
let cycle_cap = {
|
||||||
|
let doc = self.action_executor.document();
|
||||||
|
match (doc.cycle_enabled, doc.cycle_region) {
|
||||||
|
(true, Some((ls, le))) if le > ls => {
|
||||||
|
let tm = doc.tempo_map();
|
||||||
|
Some(tm.beats_to_seconds(le) - tm.beats_to_seconds(ls))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let duration = match cycle_cap {
|
||||||
|
Some(cap) if duration > cap => cap,
|
||||||
|
_ => duration,
|
||||||
|
};
|
||||||
|
|
||||||
// Then update the clip duration (mutable borrow)
|
// Then update the clip duration (mutable borrow)
|
||||||
if let Some(doc_clip_id) = doc_clip_id {
|
if let Some(doc_clip_id) = doc_clip_id {
|
||||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||||
|
|
@ -6501,6 +6520,116 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
AudioEvent::CycleRecordingStopped { clip_id: backend_clip_id, takes, loop_start, loop_len_beats, loop_len_seconds } => {
|
||||||
|
eprintln!("[STOP] CycleRecordingStopped: {} takes", takes.len());
|
||||||
|
|
||||||
|
// Clean up the live-recording waveform cache (keyed usize::MAX).
|
||||||
|
self.raw_audio_cache.remove(&usize::MAX);
|
||||||
|
self.waveform_gpu_dirty.remove(&usize::MAX);
|
||||||
|
|
||||||
|
// Pull every take's samples in for waveform rendering — the user can
|
||||||
|
// switch to any of them, not just the active one.
|
||||||
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
for &(pool_index, _) in &takes {
|
||||||
|
match controller.get_pool_audio_samples(pool_index) {
|
||||||
|
Ok((samples, sr, ch)) => {
|
||||||
|
self.raw_audio_cache.insert(pool_index, (Arc::new(samples), sr, ch));
|
||||||
|
self.waveform_gpu_dirty.insert(pool_index);
|
||||||
|
self.audio_pools_with_new_waveforms.insert(pool_index);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Failed to fetch take audio: {}", e),
|
||||||
|
}
|
||||||
|
self.audio_duration_cache.insert(pool_index, loop_len_seconds.seconds_to_f64());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let recording_layer = self.recording_clips.iter()
|
||||||
|
.find(|(_, &cid)| cid == backend_clip_id)
|
||||||
|
.map(|(&lid, _)| lid);
|
||||||
|
|
||||||
|
if let (Some(layer_id), false) = (recording_layer, takes.is_empty()) {
|
||||||
|
let (clip_id, instance_id) = {
|
||||||
|
let document = self.action_executor.document();
|
||||||
|
document.get_layer(&layer_id)
|
||||||
|
.and_then(|layer| {
|
||||||
|
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
|
||||||
|
audio_layer.clip_instances.last().map(|i| (i.clip_id, i.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil()))
|
||||||
|
};
|
||||||
|
|
||||||
|
if !clip_id.is_nil() {
|
||||||
|
self.autosave.pending_event = true;
|
||||||
|
let last_take = takes.len() - 1;
|
||||||
|
|
||||||
|
// Promote the in-progress recording clip to a take folder.
|
||||||
|
{
|
||||||
|
let doc = self.action_executor.document_mut();
|
||||||
|
if let Some(clip) = doc.audio_clips.get_mut(&clip_id) {
|
||||||
|
clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder {
|
||||||
|
takes: takes.iter().enumerate().map(|(i, &(pool_index, _))| {
|
||||||
|
lightningbeam_core::clip::AudioTake {
|
||||||
|
name: format!("Take {}", i + 1),
|
||||||
|
content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index },
|
||||||
|
}
|
||||||
|
}).collect(),
|
||||||
|
recorded_loop_beats: loop_len_beats,
|
||||||
|
};
|
||||||
|
// Audio takes are seconds-domain, and every take is
|
||||||
|
// exactly one cycle region long.
|
||||||
|
clip.set_content_duration(ClipDuration::Seconds(loop_len_seconds));
|
||||||
|
clip.name = format!("Cycle recording ({} takes)", takes.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor the instance to the region and select the most
|
||||||
|
// recent take, GarageBand-style.
|
||||||
|
//
|
||||||
|
// timeline_duration stays None on purpose: pinning it would
|
||||||
|
// make a later tempo change loop/repeat the take's content
|
||||||
|
// to fill the span instead of letting it drift naturally.
|
||||||
|
if let Some(lightningbeam_core::layer::AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) {
|
||||||
|
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) {
|
||||||
|
inst.timeline_start = loop_start;
|
||||||
|
inst.timeline_duration = None;
|
||||||
|
inst.trim_start = 0.0;
|
||||||
|
inst.trim_end = Some(loop_len_seconds.seconds_to_f64());
|
||||||
|
inst.active_take = Some(last_take);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backend already has a clip for the active take (the engine
|
||||||
|
// pointed it at the last take on stop), so map to it rather than
|
||||||
|
// adding a duplicate.
|
||||||
|
let backend_id = lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id);
|
||||||
|
self.clip_instance_to_backend_map.insert(instance_id, backend_id);
|
||||||
|
|
||||||
|
// Commit the whole cycle-record session as ONE undoable action.
|
||||||
|
let clip_instance = self.layer_to_track_map.get(&layer_id).copied().and_then(|track_id| {
|
||||||
|
self.action_executor.document()
|
||||||
|
.get_layer(&layer_id)
|
||||||
|
.and_then(|l| if let AnyLayer::Audio(al) = l {
|
||||||
|
al.clip_instances.iter().find(|ci| ci.id == instance_id).cloned()
|
||||||
|
} else { None })
|
||||||
|
.map(|ci| (track_id, ci))
|
||||||
|
});
|
||||||
|
if let Some((track_id, clip_instance)) = clip_instance {
|
||||||
|
let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied(
|
||||||
|
layer_id, clip_instance, track_id, backend_id,
|
||||||
|
);
|
||||||
|
self.action_executor.push_applied(Box::new(action));
|
||||||
|
} else {
|
||||||
|
self.media_modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id);
|
||||||
|
}
|
||||||
AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => {
|
AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => {
|
||||||
eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index);
|
eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,17 @@
|
||||||
//! - Image Assets (static images)
|
//! - Image Assets (static images)
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::clip::{AudioClipType, VectorClip};
|
use lightningbeam_core::clip::{AudioClip, ResolvedContent, VectorClip};
|
||||||
|
|
||||||
|
/// Library label for an audio clip: a take folder advertises how many takes it holds, anything else
|
||||||
|
/// just names its kind. The library lists *clips*, not placements, so there's no active take here —
|
||||||
|
/// a folder is previewed by its first take.
|
||||||
|
fn take_label(clip: &AudioClip, kind: &str) -> String {
|
||||||
|
match clip.takes() {
|
||||||
|
Some(takes) => format!("{} ({} takes)", kind, takes.len()),
|
||||||
|
None => kind.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
use lightningbeam_core::document::Document;
|
use lightningbeam_core::document::Document;
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
@ -918,11 +928,11 @@ impl AssetLibraryPane {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (extra_info, drag_clip_type) = match &clip.clip_type {
|
let (extra_info, drag_clip_type) = match &clip.resolve(None) {
|
||||||
AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled),
|
ResolvedContent::Audio { .. } => (take_label(clip, "Sampled"), DragClipType::AudioSampled),
|
||||||
AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi),
|
ResolvedContent::Midi { .. } => (take_label(clip, "MIDI"), DragClipType::AudioMidi),
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Skip recording-in-progress clips from asset library
|
// Skip recording-in-progress clips (and empty take folders) from asset library
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1118,15 +1128,15 @@ impl AssetLibraryPane {
|
||||||
|
|
||||||
for (id, clip) in &document.audio_clips {
|
for (id, clip) in &document.audio_clips {
|
||||||
if !linked_audio_ids.contains(id) && clip.folder_id == current_folder {
|
if !linked_audio_ids.contains(id) && clip.folder_id == current_folder {
|
||||||
let (extra_info, drag_clip_type) = match &clip.clip_type {
|
let (extra_info, drag_clip_type) = match &clip.resolve(None) {
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
("Sampled".to_string(), DragClipType::AudioSampled)
|
(take_label(clip, "Sampled"), DragClipType::AudioSampled)
|
||||||
}
|
}
|
||||||
AudioClipType::Midi { .. } => {
|
ResolvedContent::Midi { .. } => {
|
||||||
("MIDI".to_string(), DragClipType::AudioMidi)
|
(take_label(clip, "MIDI"), DragClipType::AudioMidi)
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Skip recording-in-progress clips
|
// Skip recording-in-progress clips (and empty take folders)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1765,7 +1775,7 @@ impl AssetLibraryPane {
|
||||||
let prefetched_waveform: Option<Vec<(f32, f32)>> =
|
let prefetched_waveform: Option<Vec<(f32, f32)>> =
|
||||||
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
|
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
|
||||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||||
if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() {
|
||||||
shared.raw_audio_cache.get(audio_pool_index)
|
shared.raw_audio_cache.get(audio_pool_index)
|
||||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize))
|
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1790,8 +1800,8 @@ impl AssetLibraryPane {
|
||||||
AssetCategory::Audio => {
|
AssetCategory::Audio => {
|
||||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||||
match &clip.clip_type {
|
match &clip.resolve(None) {
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
if let Some(ref peaks) = prefetched_waveform {
|
if let Some(ref peaks) = prefetched_waveform {
|
||||||
Some(generate_waveform_thumbnail(peaks, bg_color, wave_color))
|
Some(generate_waveform_thumbnail(peaks, bg_color, wave_color))
|
||||||
|
|
@ -1799,7 +1809,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
||||||
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
||||||
|
|
@ -1807,7 +1817,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
// Recording in progress - show placeholder
|
// Recording in progress - show placeholder
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
|
|
@ -2344,8 +2354,8 @@ impl AssetLibraryPane {
|
||||||
AssetCategory::Audio => {
|
AssetCategory::Audio => {
|
||||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||||
match &clip.clip_type {
|
match &clip.resolve(None) {
|
||||||
AudioClipType::Sampled { audio_pool_index } => {
|
ResolvedContent::Audio { audio_pool_index } => {
|
||||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
||||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
||||||
|
|
@ -2355,7 +2365,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
||||||
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
||||||
|
|
@ -2363,7 +2373,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2481,8 +2491,8 @@ impl AssetLibraryPane {
|
||||||
AssetCategory::Audio => {
|
AssetCategory::Audio => {
|
||||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||||
match &clip.clip_type {
|
match &clip.resolve(None) {
|
||||||
AudioClipType::Sampled { audio_pool_index } => {
|
ResolvedContent::Audio { audio_pool_index } => {
|
||||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
||||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
||||||
|
|
@ -2492,7 +2502,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
if let Some(events) = shared.midi_event_cache.get(midi_clip_id) {
|
||||||
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color))
|
||||||
|
|
@ -2500,7 +2510,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2802,7 +2812,7 @@ impl AssetLibraryPane {
|
||||||
let prefetched_waveform: Option<Vec<(f32, f32)>> =
|
let prefetched_waveform: Option<Vec<(f32, f32)>> =
|
||||||
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
|
if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) {
|
||||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||||
if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() {
|
||||||
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
let waveform: Option<Vec<(f32, f32)>> = shared.raw_audio_cache.get(audio_pool_index)
|
||||||
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
.map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize));
|
||||||
if waveform.is_some() {
|
if waveform.is_some() {
|
||||||
|
|
@ -2842,8 +2852,8 @@ impl AssetLibraryPane {
|
||||||
// Check if it's sampled or MIDI
|
// Check if it's sampled or MIDI
|
||||||
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
if let Some(clip) = document.audio_clips.get(&asset_id) {
|
||||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||||
match &clip.clip_type {
|
match &clip.resolve(None) {
|
||||||
AudioClipType::Sampled { .. } => {
|
ResolvedContent::Audio { .. } => {
|
||||||
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
let wave_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
if let Some(ref peaks) = prefetched_waveform {
|
if let Some(ref peaks) = prefetched_waveform {
|
||||||
println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id);
|
println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id);
|
||||||
|
|
@ -2853,7 +2863,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200);
|
||||||
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
let note_color = egui::Color32::from_rgb(100, 200, 100);
|
||||||
|
|
||||||
|
|
@ -2863,7 +2873,7 @@ impl AssetLibraryPane {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioClipType::Recording => {
|
ResolvedContent::Recording => {
|
||||||
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3187,7 +3197,7 @@ impl PaneRenderer for AssetLibraryPane {
|
||||||
println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms);
|
println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms);
|
||||||
let mut invalidated_any = false;
|
let mut invalidated_any = false;
|
||||||
for (asset_id, clip) in &document_arc.audio_clips {
|
for (asset_id, clip) in &document_arc.audio_clips {
|
||||||
if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type {
|
if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() {
|
||||||
if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) {
|
if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) {
|
||||||
println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index);
|
println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index);
|
||||||
self.thumbnail_cache.invalidate(asset_id);
|
self.thumbnail_cache.invalidate(asset_id);
|
||||||
|
|
|
||||||
|
|
@ -1797,10 +1797,16 @@ impl InfopanelPane {
|
||||||
ui.label("Name:");
|
ui.label("Name:");
|
||||||
ui.label(&clip.name);
|
ui.label(&clip.name);
|
||||||
});
|
});
|
||||||
|
let take_count = clip.takes().map(|t| t.len()).unwrap_or(0);
|
||||||
|
let take_folder_label;
|
||||||
let type_name = match &clip.clip_type {
|
let type_name = match &clip.clip_type {
|
||||||
lightningbeam_core::clip::AudioClipType::Sampled { .. } => "Audio (Sampled)",
|
lightningbeam_core::clip::AudioClipType::Sampled { .. } => "Audio (Sampled)",
|
||||||
lightningbeam_core::clip::AudioClipType::Midi { .. } => "Audio (MIDI)",
|
lightningbeam_core::clip::AudioClipType::Midi { .. } => "Audio (MIDI)",
|
||||||
lightningbeam_core::clip::AudioClipType::Recording => "Audio (Recording)",
|
lightningbeam_core::clip::AudioClipType::Recording => "Audio (Recording)",
|
||||||
|
lightningbeam_core::clip::AudioClipType::TakeFolder { .. } => {
|
||||||
|
take_folder_label = format!("Audio ({} takes)", take_count);
|
||||||
|
&take_folder_label
|
||||||
|
}
|
||||||
};
|
};
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Type:");
|
ui.label("Type:");
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,15 @@ pub struct TimelinePane {
|
||||||
/// during the last `render_layers`. Used by `handle_input` (next frame) to snap the
|
/// during the last `render_layers`. Used by `handle_input` (next frame) to snap the
|
||||||
/// playhead exactly to a keyframe when its diamond is clicked.
|
/// playhead exactly to a keyframe when its diamond is clicked.
|
||||||
keyframe_diamond_hits: Vec<(egui::Rect, f64)>,
|
keyframe_diamond_hits: Vec<(egui::Rect, f64)>,
|
||||||
|
/// Take-badge click targets recorded during render: (badge rect, layer, instance, active take,
|
||||||
|
/// take count). Collected while painting, dispatched after — the usual two-phase pattern.
|
||||||
|
take_badge_hits: Vec<(egui::Rect, uuid::Uuid, uuid::Uuid, usize, usize)>,
|
||||||
|
/// The take-folder instance whose take menu is open, if any.
|
||||||
|
open_take_menu: Option<(uuid::Uuid, uuid::Uuid)>,
|
||||||
|
/// Seconds between the cycle region's start and where the current recording actually began.
|
||||||
|
/// Zero unless the user punched in mid-region. Used to line the live waveform preview up with
|
||||||
|
/// the region on each pass.
|
||||||
|
cycle_record_lead_secs: f64,
|
||||||
|
|
||||||
/// Total duration of the animation
|
/// Total duration of the animation
|
||||||
duration: f64,
|
duration: f64,
|
||||||
|
|
@ -723,6 +732,9 @@ impl TimelinePane {
|
||||||
viewport_start_time: 0.0,
|
viewport_start_time: 0.0,
|
||||||
viewport_scroll_y: 0.0,
|
viewport_scroll_y: 0.0,
|
||||||
keyframe_diamond_hits: Vec::new(),
|
keyframe_diamond_hits: Vec::new(),
|
||||||
|
take_badge_hits: Vec::new(),
|
||||||
|
open_take_menu: None,
|
||||||
|
cycle_record_lead_secs: 0.0,
|
||||||
duration: 10.0, // Default 10 seconds
|
duration: 10.0, // Default 10 seconds
|
||||||
is_scrubbing: false,
|
is_scrubbing: false,
|
||||||
cycle_drag: None,
|
cycle_drag: None,
|
||||||
|
|
@ -1049,7 +1061,41 @@ impl TimelinePane {
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
|
|
||||||
let start_time = *shared.playback_time;
|
let mut start_time = *shared.playback_time;
|
||||||
|
|
||||||
|
// With a cycle region armed, a recording is anchored at the REGION start rather than the
|
||||||
|
// playhead: every take spans the whole region, so the clip has to as well.
|
||||||
|
//
|
||||||
|
// Two ways in. From stopped, we also move the playhead to the region start, so recording
|
||||||
|
// begins with the loop (the count-in below then rolls in from a measure before it). Punching
|
||||||
|
// in while already rolling leaves the playhead where it is — the backend prepends silence to
|
||||||
|
// take 1's head to fill the gap back to the region start.
|
||||||
|
let cycle_start_secs = {
|
||||||
|
let doc = shared.action_executor.document();
|
||||||
|
match (doc.cycle_enabled, doc.cycle_region) {
|
||||||
|
(true, Some((ls, le))) if le > ls => {
|
||||||
|
Some(doc.tempo_map().beats_to_seconds(ls).seconds_to_f64())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(ls_secs) = cycle_start_secs {
|
||||||
|
// How far into the region we punched in (zero when starting from stopped, since we jump
|
||||||
|
// the playhead to the region start below). The live waveform preview needs this to know
|
||||||
|
// where each pass begins inside the recording buffer.
|
||||||
|
self.cycle_record_lead_secs = if *shared.is_playing {
|
||||||
|
(*shared.playback_time - ls_secs).max(0.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
start_time = ls_secs;
|
||||||
|
if !*shared.is_playing {
|
||||||
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
|
controller_arc.lock().unwrap().seek(Seconds(ls_secs));
|
||||||
|
}
|
||||||
|
*shared.playback_time = ls_secs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Count-in: seek back N beats, start transport + metronome, defer ALL recording commands.
|
// Count-in: seek back N beats, start transport + metronome, defer ALL recording commands.
|
||||||
// Must happen before Step 4 so no clips or backend recordings are created yet.
|
// Must happen before Step 4 so no clips or backend recordings are created yet.
|
||||||
|
|
@ -1594,6 +1640,101 @@ impl TimelinePane {
|
||||||
painter.rect_filled(band, 2.0, fill);
|
painter.rect_filled(band, 2.0, fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Click handling + dropdown for the take badge painted on take-folder clips.
|
||||||
|
///
|
||||||
|
/// Runs after rendering, off the hit rects collected during it: clicking a badge opens (or
|
||||||
|
/// closes) a list of the clip's takes, and picking one dispatches `SetActiveTakeAction`.
|
||||||
|
/// Selection is per-*instance*, so doing this to one half of a split clip and something else to
|
||||||
|
/// the other half is exactly how you comp.
|
||||||
|
fn render_take_menu(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
|
) {
|
||||||
|
let click = ui.input(|i| {
|
||||||
|
i.pointer
|
||||||
|
.primary_pressed()
|
||||||
|
.then(|| i.pointer.interact_pos())
|
||||||
|
.flatten()
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(pos) = click {
|
||||||
|
if let Some((_, layer_id, instance_id, _, _)) =
|
||||||
|
self.take_badge_hits.iter().find(|(r, ..)| r.contains(pos))
|
||||||
|
{
|
||||||
|
let key = (*layer_id, *instance_id);
|
||||||
|
// Clicking the badge of the open menu closes it again.
|
||||||
|
self.open_take_menu = (self.open_take_menu != Some(key)).then_some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((layer_id, instance_id)) = self.open_take_menu else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// The badge is only in the hit list while it's on screen; if the clip scrolled away, the
|
||||||
|
// menu has nothing to hang off, so drop it.
|
||||||
|
let Some((badge, _, _, active, count)) = self
|
||||||
|
.take_badge_hits
|
||||||
|
.iter()
|
||||||
|
.find(|(_, l, i, _, _)| *l == layer_id && *i == instance_id)
|
||||||
|
.copied()
|
||||||
|
else {
|
||||||
|
self.open_take_menu = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The instance's *stored* selection, which is what rollback must restore — not `active`,
|
||||||
|
// which is that value clamped for display.
|
||||||
|
let old_take = document
|
||||||
|
.get_layer(&layer_id)
|
||||||
|
.and_then(|l| match l {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(al) => {
|
||||||
|
al.clip_instances.iter().find(|ci| ci.id == instance_id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.and_then(|ci| ci.active_take);
|
||||||
|
|
||||||
|
let mut close = false;
|
||||||
|
let area = egui::Area::new(ui.id().with(("take_menu", instance_id)))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.fixed_pos(egui::pos2(badge.min.x, badge.max.y + 2.0))
|
||||||
|
.show(ui.ctx(), |ui| {
|
||||||
|
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||||
|
for i in 0..count {
|
||||||
|
let is_active = i == active;
|
||||||
|
if ui
|
||||||
|
.selectable_label(is_active, format!("Take {}", i + 1))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
if !is_active {
|
||||||
|
pending_actions.push(Box::new(
|
||||||
|
lightningbeam_core::actions::SetActiveTakeAction::new(
|
||||||
|
layer_id,
|
||||||
|
instance_id,
|
||||||
|
i,
|
||||||
|
old_take,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A press anywhere outside the menu (and outside the badge, which toggles) dismisses it.
|
||||||
|
if let Some(pos) = click {
|
||||||
|
if !badge.contains(pos) && !area.response.rect.contains(pos) {
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if close {
|
||||||
|
self.open_take_menu = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert time (seconds) to pixel x-coordinate
|
/// Convert time (seconds) to pixel x-coordinate
|
||||||
fn time_to_x(&self, time: f64) -> f32 {
|
fn time_to_x(&self, time: f64) -> f32 {
|
||||||
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
|
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
|
||||||
|
|
@ -2896,6 +3037,7 @@ impl TimelinePane {
|
||||||
let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new();
|
let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new();
|
||||||
// Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek.
|
// Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek.
|
||||||
self.keyframe_diamond_hits.clear();
|
self.keyframe_diamond_hits.clear();
|
||||||
|
self.take_badge_hits.clear();
|
||||||
|
|
||||||
// Collect video clip rects for hover detection (to avoid borrow conflicts)
|
// Collect video clip rects for hover detection (to avoid borrow conflicts)
|
||||||
let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new();
|
let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new();
|
||||||
|
|
@ -3801,9 +3943,11 @@ impl TimelinePane {
|
||||||
// AUDIO VISUALIZATION: Draw piano roll or waveform overlay
|
// AUDIO VISUALIZATION: Draw piano roll or waveform overlay
|
||||||
if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer {
|
if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer {
|
||||||
if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) {
|
if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) {
|
||||||
match &clip.clip_type {
|
// Resolve through the instance's active take, so a take folder draws
|
||||||
|
// whichever take it actually plays.
|
||||||
|
match &clip.resolve(clip_instance.active_take) {
|
||||||
// MIDI: Draw piano roll (with loop iterations)
|
// MIDI: Draw piano roll (with loop iterations)
|
||||||
lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => {
|
lightningbeam_core::clip::ResolvedContent::Midi { midi_clip_id } => {
|
||||||
if let Some(events) = midi_event_cache.get(midi_clip_id) {
|
if let Some(events) = midi_event_cache.get(midi_clip_id) {
|
||||||
// Calculate content window for loop detection
|
// Calculate content window for loop detection
|
||||||
// preview_clip_duration accounts for TrimLeft/TrimRight drag previews
|
// preview_clip_duration accounts for TrimLeft/TrimRight drag previews
|
||||||
|
|
@ -3862,7 +4006,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sampled Audio: Draw waveform via GPU
|
// Sampled Audio: Draw waveform via GPU
|
||||||
lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } => {
|
lightningbeam_core::clip::ResolvedContent::Audio { audio_pool_index } => {
|
||||||
if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) {
|
if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) {
|
||||||
// Min/max overview pools: 4 f32/texel at rate sr/B.
|
// Min/max overview pools: 4 f32/texel at rate sr/B.
|
||||||
let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied();
|
let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied();
|
||||||
|
|
@ -3991,7 +4135,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Recording in progress: show live waveform
|
// Recording in progress: show live waveform
|
||||||
lightningbeam_core::clip::AudioClipType::Recording => {
|
lightningbeam_core::clip::ResolvedContent::Recording => {
|
||||||
let rec_pool_idx = usize::MAX;
|
let rec_pool_idx = usize::MAX;
|
||||||
if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) {
|
if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) {
|
||||||
let total_frames = samples.len() / (*ch).max(1) as usize;
|
let total_frames = samples.len() / (*ch).max(1) as usize;
|
||||||
|
|
@ -4026,6 +4170,36 @@ impl TimelinePane {
|
||||||
egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y),
|
egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cycle recording: the clip covers ONE region, but the
|
||||||
|
// recorded buffer keeps growing across passes. Show the
|
||||||
|
// *current* pass by offsetting into the buffer to where
|
||||||
|
// that pass began, so the waveform restarts at the region
|
||||||
|
// start on each wrap and fills in behind the playhead —
|
||||||
|
// rather than running on past the clip's end.
|
||||||
|
//
|
||||||
|
// The playhead gives the position within the region
|
||||||
|
// directly, so a punch-in (whose first pass starts partway
|
||||||
|
// in) needs no extra bookkeeping here.
|
||||||
|
let mut rec_trim_start = preview_trim_start;
|
||||||
|
if let (true, Some((ls, le))) = (document.cycle_enabled, document.cycle_region) {
|
||||||
|
let tm = document.tempo_map();
|
||||||
|
let loop_len = (tm.beats_to_seconds(le)
|
||||||
|
- tm.beats_to_seconds(ls))
|
||||||
|
.seconds_to_f64();
|
||||||
|
if loop_len > 0.0 {
|
||||||
|
// Which pass we're on, straight from how much
|
||||||
|
// audio has been captured. Deriving this from
|
||||||
|
// the playhead instead would jitter: the
|
||||||
|
// playhead and the recording buffer advance on
|
||||||
|
// different clocks, so their difference wobbles
|
||||||
|
// frame to frame and the waveform slides
|
||||||
|
// horizontally.
|
||||||
|
let lead = self.cycle_record_lead_secs;
|
||||||
|
let pass = ((lead + audio_file_duration) / loop_len).floor();
|
||||||
|
rec_trim_start = (pass * loop_len - lead).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
|
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
|
||||||
let instance_id = clip_instance.id.as_u128() as u64;
|
let instance_id = clip_instance.id.as_u128() as u64;
|
||||||
let callback = crate::waveform_gpu::WaveformCallback {
|
let callback = crate::waveform_gpu::WaveformCallback {
|
||||||
|
|
@ -4038,7 +4212,7 @@ impl TimelinePane {
|
||||||
audio_duration: audio_file_duration as f32,
|
audio_duration: audio_file_duration as f32,
|
||||||
sample_rate: *sr as f32,
|
sample_rate: *sr as f32,
|
||||||
clip_start_time: clip_screen_start,
|
clip_start_time: clip_screen_start,
|
||||||
trim_start: preview_trim_start as f32,
|
trim_start: rec_trim_start as f32,
|
||||||
tex_width: crate::waveform_gpu::tex_width() as f32,
|
tex_width: crate::waveform_gpu::tex_width() as f32,
|
||||||
total_frames: total_frames as f32,
|
total_frames: total_frames as f32,
|
||||||
segment_start_frame: 0.0,
|
segment_start_frame: 0.0,
|
||||||
|
|
@ -4152,6 +4326,68 @@ impl TimelinePane {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Take badge — "Take 2/4" in the clip's bottom-left, on take-folder clips
|
||||||
|
// only. Records a hit rect so the click that opens the take menu can be
|
||||||
|
// dispatched after rendering (the usual two-phase pattern), rather than
|
||||||
|
// mutating the document mid-paint.
|
||||||
|
if let Some(take_count) = document
|
||||||
|
.get_audio_clip(&clip_instance.clip_id)
|
||||||
|
.and_then(|c| c.takes().map(|t| t.len()))
|
||||||
|
.filter(|n| *n > 0)
|
||||||
|
{
|
||||||
|
let active = clip_instance.active_take.unwrap_or(0).min(take_count - 1);
|
||||||
|
let label = format!("Take {}/{}", active + 1, take_count);
|
||||||
|
let text_color = theme.text_color(
|
||||||
|
&["#timeline", ".take-badge"],
|
||||||
|
ui.ctx(),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
);
|
||||||
|
let galley = painter.layout_no_wrap(
|
||||||
|
label,
|
||||||
|
egui::FontId::proportional(10.0),
|
||||||
|
text_color,
|
||||||
|
);
|
||||||
|
let pad = egui::vec2(4.0, 2.0);
|
||||||
|
let size = galley.size() + pad * 2.0;
|
||||||
|
// Bottom-left of the clip, but only when the clip is wide enough that
|
||||||
|
// the badge wouldn't swamp it.
|
||||||
|
if clip_rect.width() > size.x + 10.0 && clip_rect.height() > size.y + 4.0 {
|
||||||
|
let badge = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(
|
||||||
|
clip_rect.min.x + 4.0,
|
||||||
|
clip_rect.max.y - size.y - 3.0,
|
||||||
|
),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
let hovered = ui
|
||||||
|
.ctx()
|
||||||
|
.pointer_hover_pos()
|
||||||
|
.is_some_and(|p| badge.contains(p));
|
||||||
|
let bg = if hovered {
|
||||||
|
theme.bg_color(
|
||||||
|
&["#timeline", ".take-badge:hover"],
|
||||||
|
ui.ctx(),
|
||||||
|
egui::Color32::from_black_alpha(210),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
theme.bg_color(
|
||||||
|
&["#timeline", ".take-badge"],
|
||||||
|
ui.ctx(),
|
||||||
|
egui::Color32::from_black_alpha(150),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
painter.rect_filled(badge, 3.0, bg);
|
||||||
|
painter.galley(badge.min + pad, galley, text_color);
|
||||||
|
self.take_badge_hits.push((
|
||||||
|
badge,
|
||||||
|
layer.id(),
|
||||||
|
clip_instance.id,
|
||||||
|
active,
|
||||||
|
take_count,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4631,7 +4867,12 @@ impl TimelinePane {
|
||||||
if !alt_held && !self.is_scrubbing && !self.is_panning {
|
if !alt_held && !self.is_scrubbing && !self.is_panning {
|
||||||
if response.drag_started() {
|
if response.drag_started() {
|
||||||
// Use cached mousedown position for edge detection
|
// Use cached mousedown position for edge detection
|
||||||
if let Some(mousedown_pos) = self.mousedown_pos {
|
if let Some(mousedown_pos) = self
|
||||||
|
.mousedown_pos
|
||||||
|
// A press that landed on a take badge is opening the take menu, not grabbing
|
||||||
|
// the clip it sits on.
|
||||||
|
.filter(|p| !self.take_badge_hits.iter().any(|(r, ..)| r.contains(*p)))
|
||||||
|
{
|
||||||
if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer(
|
if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer(
|
||||||
mousedown_pos,
|
mousedown_pos,
|
||||||
document,
|
document,
|
||||||
|
|
@ -6051,6 +6292,8 @@ impl PaneRenderer for TimelinePane {
|
||||||
editing_clip_id.as_ref(),
|
editing_clip_id.as_ref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
self.render_take_menu(ui, document, shared.pending_actions);
|
||||||
|
|
||||||
// Render automation lanes AFTER handle_input so our ui.interact registers last and wins
|
// Render automation lanes AFTER handle_input so our ui.interact registers last and wins
|
||||||
// egui's interaction priority over handle_input's full-content-area allocation.
|
// egui's interaction priority over handle_input's full-content-area allocation.
|
||||||
// All automation lanes use beats as the x-axis; convert via the tempo map.
|
// All automation lanes use beats as the x-axis; convert via the tempo map.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue