Take management: move takes onto the clip instance

Take management should be per clip instance — deleting a take from one
half of a comped split shouldn't pull it out from under the other half.
That's cleanest if the takes themselves live on the instance rather than
the clip, so they do now.

AudioClipType::TakeFolder is gone entirely. A clip is plain Sampled/Midi
content again, and an instance with a non-empty `takes` list simply
OVERRIDES it with whichever take is active. Splitting already clones the
instance, so each half gets its own take list for free — no index
remapping across instances, no copy-on-write, no shared-state surprise —
and comping still works, because the halves can still each select a
different take. It collapsed machinery too: resolve() moved from the clip
to the instance, and owns_audio_pool_index went back to a one-liner.

Management (DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction):
- Right-click a clip with more than one take: `Delete "<active take>"`
  and "Delete Unused Takes". Deletion is named after the take that's
  PLAYING rather than being a generic entry, so you pick the victim by
  selecting it — one clear act instead of hunting a small trash icon in a
  list (which is where this started, and it was fiddly).
- Double-click a take in the dropdown to rename it in place.
- What happens to the selection on delete is the subtle part, and there's
  a test per case: deleting a take BELOW the active one shifts the
  selection down so you keep hearing the same take; deleting the ACTIVE
  take lands on whatever slid into its place (not silently back to take
  1); deleting the LAST take steps back one. The only take can't be
  deleted at all — the menu item isn't offered.
- Deleted takes' audio stays in the pool: undo has to put it back, and
  the other half of a split may still be playing it.

Fixes:
- A recording that stopped before the loop came round wasn't joining an
  existing take list — it landed as a separate overlapping clip. Trigger-
  on-wrap is right for the FIRST recording, but once takes exist there,
  a further run is plainly another take however short. The engine can't
  know that (it's document state), so the editor passes `force_takes`
  with the start-recording command and the run is cut and padded to the
  region even with zero wraps. This forced cycle_loop_len and `wrapped`
  apart on the MIDI side: the region length has to be known from the
  start, but the clip should only pin to full-region length AFTER a pass
  completes, or the bar jumps to full width the moment you hit record.
- Recording a second take left BOTH sounding. append_cycle_takes tore
  down the recording's backend clip by looking it up in
  clip_instance_to_backend_map — but on the audio path the recording
  instance isn't in that map yet; it's only added during promotion, which
  the append path skips. The event already carries the engine's clip id,
  so it's handed over explicitly now.
- The take badge is hidden when there's only one take — no choice to make.
This commit is contained in:
Skyler Lehmkuhl 2026-07-14 12:54:23 -04:00
parent c62164c365
commit 6924fc0ffe
19 changed files with 905 additions and 337 deletions

View File

@ -645,8 +645,10 @@ impl Engine {
// every pass and — because this block also resizes the backend clip instance // every pass and — because this block also resizes the backend clip instance
// below — shrank the instance back to nothing, so the notes just merged into it // below — shrank the instance back to nothing, so the notes just merged into it
// were never scheduled and you overdubbed against silence. // were never scheduled and you overdubbed against silence.
// Pinned only once a pass has actually completed — until then the bar still grows.
let duration = recording let duration = recording
.cycle_loop_len .cycle_loop_len
.filter(|_| recording.wrapped)
.unwrap_or(current_time - recording.start_time); .unwrap_or(current_time - recording.start_time);
// In separate-takes mode the preview shows only the pass being played now — the // In separate-takes mode the preview shows only the pass being played now — the
// earlier passes are alternative takes, not layers, so drawing them all on top of // earlier passes are alternative takes, not layers, so drawing them all on top of
@ -1490,9 +1492,9 @@ impl Engine {
None => {} None => {}
} }
} }
Command::StartRecording(track_id, start_time) => { Command::StartRecording(track_id, start_time, force_takes) => {
// Start recording on the specified track // Start recording on the specified track
self.handle_start_recording(track_id, start_time); self.handle_start_recording(track_id, start_time, force_takes);
} }
Command::StopRecording => { Command::StopRecording => {
// Stop the current recording // Stop the current recording
@ -1510,9 +1512,9 @@ impl Engine {
recording.resume(); recording.resume();
} }
} }
Command::StartMidiRecording(track_id, clip_id, start_time) => { Command::StartMidiRecording(track_id, clip_id, start_time, force_takes) => {
// Start MIDI recording on the specified track // Start MIDI recording on the specified track
self.handle_start_midi_recording(track_id, clip_id, start_time); self.handle_start_midi_recording(track_id, clip_id, start_time, force_takes);
} }
Command::StopMidiRecording => { Command::StopMidiRecording => {
eprintln!("[ENGINE] Received StopMidiRecording command"); eprintln!("[ENGINE] Received StopMidiRecording command");
@ -3264,7 +3266,7 @@ impl Engine {
} }
/// Handle starting a recording /// Handle starting a recording
fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) { fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) {
use crate::io::WavWriter; use crate::io::WavWriter;
use std::env; use std::env;
@ -3348,6 +3350,7 @@ impl Engine {
loop_len_frames: (le - ls).max(0) as usize, loop_len_frames: (le - ls).max(0) as usize,
lead_pad_frames: (self.playhead - ls).max(0) as usize, lead_pad_frames: (self.playhead - ls).max(0) as usize,
wrap_count: 0, wrap_count: 0,
force_takes,
} }
}) })
} else { } else {
@ -3552,12 +3555,23 @@ impl Engine {
} }
/// Handle starting MIDI recording /// Handle starting MIDI recording
fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) {
// Check if track exists and is a MIDI track // Check if track exists and is a MIDI track
if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) {
// Create MIDI recording state // Create MIDI recording state
let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time); let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time);
// Note the cycle region up front. `wrapped` stays false until a pass actually completes,
// so the clip bar still grows with the playhead through the first pass.
if self.loop_enabled {
if let Some((ls, le)) = self.loop_region {
if le > ls {
recording_state.cycle_loop_len = Some(le - ls);
recording_state.force_takes = force_takes;
}
}
}
// Inject any notes currently held on this track (pressed during count-in pre-roll) // Inject any notes currently held on this track (pressed during count-in pre-roll)
// so they start at t=0 of the recording rather than being lost // so they start at t=0 of the recording rather than being lost
if let Some(held) = self.midi_held_notes.get(&track_id) { if let Some(held) = self.midi_held_notes.get(&track_id) {
@ -3598,12 +3612,13 @@ impl Engine {
// ---- Separate takes: one pool clip per cycle pass ---- // ---- Separate takes: one pool clip per cycle pass ----
// //
// Only when the transport actually wrapped; a recording that stopped inside the first // Normally only when the transport actually wrapped: a recording that stopped inside the
// pass is an ordinary single recording and falls through to the merge path below, just // first pass is an ordinary single recording and falls through to the merge path below,
// as it does for audio. // just as it does for audio. `force_takes` overrides that, because the region already
if let (true, Some(loop_len)) = // holds takes and this is another one however short it ran.
(self.cycle_midi_separate_takes, recording.cycle_loop_len) let takes_mode = self.cycle_midi_separate_takes
{ && (recording.wrapped || recording.force_takes);
if let (true, Some(loop_len)) = (takes_mode, recording.cycle_loop_len) {
let loop_start = recording.start_time; // a cycle recording is anchored at the region let loop_start = recording.start_time; // a cycle recording is anchored at the region
let passes = recording.pass_count(); let passes = recording.pass_count();
let buckets = recording.notes_by_pass(passes); let buckets = recording.notes_by_pass(passes);
@ -3667,10 +3682,11 @@ impl Engine {
let notes = recording.get_notes().to_vec(); let notes = recording.get_notes().to_vec();
let note_count = notes.len(); let note_count = notes.len();
// A cycle MIDI recording is anchored at the region start and every pass overdubs into // A cycle MIDI recording that came round is anchored at the region start and every pass
// the same clip (MERGE), so the clip is exactly one region long — not however long the // overdubs into the same clip (MERGE), so the clip is exactly one region long — not
// user held the record button, which would run past the loop end. // however long the user held the record button, which would run past the loop end. One
let recording_duration = match recording.cycle_loop_len { // that stopped inside the first pass is just an ordinary recording.
let recording_duration = match recording.cycle_loop_len.filter(|_| recording.wrapped) {
Some(loop_len) => loop_len, Some(loop_len) => loop_len,
None => end_time - recording.start_time, None => end_time - recording.start_time,
}; };
@ -4206,8 +4222,11 @@ impl EngineController {
} }
/// Start recording on a track /// Start recording on a track
pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats) { /// `force_takes`: cut takes even if the transport never wraps, because the cycle region already
let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); /// holds takes and this recording is another one. Whether that's so is document state, so only
/// the editor can answer it.
pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) {
let _ = self.command_tx.push(Command::StartRecording(track_id, start_time, force_takes));
} }
/// Stop the current recording /// Stop the current recording
@ -4226,8 +4245,9 @@ impl EngineController {
} }
/// Start MIDI recording on a track /// Start MIDI recording on a track
pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { /// `force_takes`: see [`EngineController::start_recording`].
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time)); pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) {
let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time, force_takes));
} }
/// Stop the current MIDI recording /// Stop the current MIDI recording

View File

@ -24,9 +24,20 @@ pub struct CycleRecordInfo {
/// punch-in (record while already rolling); take 1 gets this much silence prepended so it still /// punch-in (record while already rolling); take 1 gets this much silence prepended so it still
/// spans the whole region. /// spans the whole region.
pub lead_pad_frames: usize, pub lead_pad_frames: usize,
/// How many times the transport wrapped during this recording. Zero means the user stopped /// How many times the transport wrapped during this recording. Zero normally means the user
/// before completing a pass, which stays an ordinary single recording. /// stopped before completing a pass, which stays an ordinary single recording — unless
/// `force_takes` says otherwise.
pub wrap_count: usize, pub wrap_count: usize,
/// Cut takes even if the transport never wrapped.
///
/// Set when the region already holds takes: a further recording there is another take, however
/// short, and it gets padded out to the region like any partial pass. Without this a run that
/// stopped before the loop came round would land as a separate overlapping clip instead of
/// joining the take list.
///
/// The editor decides this at record start, because whether takes already exist is document
/// state the engine can't see.
pub force_takes: bool,
} }
/// Min/max waveform peaks for a finished buffer of interleaved samples. /// Min/max waveform peaks for a finished buffer of interleaved samples.
@ -130,7 +141,7 @@ impl RecordingState {
/// single recording, which keeps the existing path untouched). /// single recording, which keeps the existing path untouched).
pub fn slice_takes(&self) -> Option<Vec<Vec<f32>>> { pub fn slice_takes(&self) -> Option<Vec<Vec<f32>>> {
let cycle = self.cycle?; let cycle = self.cycle?;
if cycle.wrap_count == 0 || cycle.loop_len_frames == 0 { if (cycle.wrap_count == 0 && !cycle.force_takes) || cycle.loop_len_frames == 0 {
return None; return None;
} }
@ -295,13 +306,19 @@ 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. /// The cycle region's length in beats, if one was armed at record start.
/// ///
/// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is /// 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 /// 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 /// 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. /// other with no folding needed.
pub cycle_loop_len: Option<Beats>, pub cycle_loop_len: Option<Beats>,
/// Whether the transport actually came round. Distinct from `cycle_loop_len`, which only says a
/// region was armed: the clip only pins to the full region once a pass has completed, so until
/// then the bar still grows with the playhead.
pub wrapped: bool,
/// Cut takes even if the transport never wrapped — see [`CycleRecordInfo::force_takes`].
pub force_takes: bool,
/// Which cycle pass is currently being recorded (0-based). Bumped at each wrap. /// Which cycle pass is currently being recorded (0-based). Bumped at each wrap.
current_pass: usize, current_pass: usize,
/// The pass each completed note belongs to, parallel to `completed_notes`. /// The pass each completed note belongs to, parallel to `completed_notes`.
@ -320,6 +337,8 @@ impl MidiRecordingState {
active_notes: HashMap::new(), active_notes: HashMap::new(),
completed_notes: Vec::new(), completed_notes: Vec::new(),
cycle_loop_len: None, cycle_loop_len: None,
wrapped: false,
force_takes: false,
current_pass: 0, current_pass: 0,
note_pass: Vec::new(), note_pass: Vec::new(),
} }
@ -450,9 +469,10 @@ impl MidiRecordingState {
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 // A pass has completed, so from here the clip spans the whole region rather than however long
// rather than however long the user happened to hold the record button. // the user happens to hold the record button.
self.cycle_loop_len = Some(region_end - region_start); self.cycle_loop_len = Some(region_end - region_start);
self.wrapped = true;
} }
} }
@ -462,6 +482,14 @@ mod cycle_tests {
/// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame /// 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). /// is a sample and 5 frames is 50 ms (exactly the min-take threshold).
fn rec_forced(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
let mut r = rec(audio, loop_len_frames, lead_pad_frames, wraps);
if let Some(c) = r.cycle.as_mut() {
c.force_takes = true;
}
r
}
fn rec(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState { fn rec(audio: Vec<f32>, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState {
let mut r = RecordingState::new( let mut r = RecordingState::new(
0, 0,
@ -480,6 +508,7 @@ mod cycle_tests {
loop_len_frames, loop_len_frames,
lead_pad_frames, lead_pad_frames,
wrap_count: wraps, wrap_count: wraps,
force_takes: false,
}); });
r r
} }
@ -492,6 +521,18 @@ mod cycle_tests {
assert!(r.slice_takes().is_none()); assert!(r.slice_takes().is_none());
} }
#[test]
fn force_takes_makes_a_partial_pass_a_take() {
// Recording over a region that already holds takes: this run is another take however short
// it ran, so it's cut and padded like any partial pass rather than landing as a separate
// overlapping clip. Two real frames of an 8-frame region -> one take, silence for the rest.
let takes = rec_forced(vec![1.0, 2.0], 8, 0, 0)
.slice_takes()
.expect("forced takes");
assert_eq!(takes.len(), 1);
assert_eq!(takes[0], vec![1.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
}
#[test] #[test]
fn takes_are_cut_at_exact_loop_multiples() { fn takes_are_cut_at_exact_loop_multiples() {
// 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes. // 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes.

View File

@ -142,7 +142,9 @@ pub enum Command {
// Recording commands // Recording commands
/// Start recording on a track (track_id, start_time) /// Start recording on a track (track_id, start_time)
StartRecording(TrackId, Beats), /// (track, start_time, force_takes — cut takes even if the transport never wraps, because the
/// region already holds takes and this is another one)
StartRecording(TrackId, Beats, bool),
/// Stop the current recording /// Stop the current recording
StopRecording, StopRecording,
/// Pause the current recording /// Pause the current recording
@ -152,7 +154,8 @@ pub enum Command {
// MIDI Recording commands // MIDI Recording commands
/// Start MIDI recording on a track (track_id, clip_id, start_time) /// Start MIDI recording on a track (track_id, clip_id, start_time)
StartMidiRecording(TrackId, MidiClipId, Beats), /// (track, clip, start_time, force_takes — see [`Command::StartRecording`])
StartMidiRecording(TrackId, MidiClipId, Beats, bool),
/// Stop the current MIDI recording /// Stop the current MIDI recording
StopMidiRecording, StopMidiRecording,

View File

@ -76,7 +76,7 @@ impl BackendContext<'_> {
.get(layer_id) .get(layer_id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
let resolved = clip.resolve(instance.active_take); let resolved = instance.resolve(clip);
let content = clip.content_duration(); let content = clip.content_duration();
let internal_start = instance.trim_start; let internal_start = instance.trim_start;
let internal_end = instance let internal_end = instance

View File

@ -9,17 +9,16 @@
//! plays, which has to be repointed at the newly-active take. //! plays, which has to be repointed at the newly-active take.
use crate::action::{Action, BackendClipInstanceId, BackendContext}; use crate::action::{Action, BackendClipInstanceId, BackendContext};
use crate::clip::{AudioClipType, AudioTake}; use crate::clip::AudioTake;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use uuid::Uuid; use uuid::Uuid;
/// Action that appends takes to a take-folder clip and selects the last of them. /// Action that appends takes to an instance's take list and selects the last of them.
pub struct AppendTakesAction { pub struct AppendTakesAction {
layer_id: Uuid, layer_id: Uuid,
/// The instance whose folder is being extended (and whose active take changes). /// The instance whose take list is being extended (and whose active take changes).
instance_id: Uuid, instance_id: Uuid,
clip_id: Uuid,
/// The takes to add, in recording order. /// The takes to add, in recording order.
new_takes: Vec<AudioTake>, new_takes: Vec<AudioTake>,
@ -30,11 +29,10 @@ pub struct AppendTakesAction {
} }
impl AppendTakesAction { impl AppendTakesAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, clip_id: Uuid, new_takes: Vec<AudioTake>) -> Self { pub fn new(layer_id: Uuid, instance_id: Uuid, new_takes: Vec<AudioTake>) -> Self {
Self { Self {
layer_id, layer_id,
instance_id, instance_id,
clip_id,
new_takes, new_takes,
old_take_count: 0, old_take_count: 0,
old_active_take: None, old_active_take: None,
@ -71,32 +69,11 @@ impl AppendTakesAction {
impl Action for AppendTakesAction { impl Action for AppendTakesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let clip = document
.audio_clips
.get_mut(&self.clip_id)
.ok_or_else(|| format!("Audio clip {} not found", self.clip_id))?;
let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type else {
return Err("Can only append takes to a take folder".to_string());
};
// Only record the pre-state on the first execute; a redo must not overwrite it with the
// post-state left behind by the previous run.
if !self.executed {
self.old_take_count = takes.len();
}
takes.extend(self.new_takes.iter().cloned());
// Renumber so the names stay in step with the take indices the badge shows.
for (i, take) in takes.iter_mut().enumerate() {
take.name = format!("Take {}", i + 1);
}
let new_active = takes.len() - 1;
let layer = document let layer = document
.get_layer_mut(&self.layer_id) .get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?; .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let AnyLayer::Audio(audio_layer) = layer else { let AnyLayer::Audio(audio_layer) = layer else {
return Err("Take folders only exist on audio layers".to_string()); return Err("Takes only exist on audio layers".to_string());
}; };
let instance = audio_layer let instance = audio_layer
.clip_instances .clip_instances
@ -104,31 +81,38 @@ impl Action for AppendTakesAction {
.find(|ci| ci.id == self.instance_id) .find(|ci| ci.id == self.instance_id)
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
// Only record the pre-state on the first execute; a redo must not overwrite it with the
// post-state left behind by the previous run.
if !self.executed { if !self.executed {
self.old_take_count = instance.takes.len();
self.old_active_take = instance.active_take; self.old_active_take = instance.active_take;
} }
// Number the new takes on from what's already there. Existing names are left alone — the
// user may well have renamed them, and renumbering would clobber that.
let base = instance.takes.len();
for (i, take) in self.new_takes.iter().enumerate() {
let mut take = take.clone();
if take.name.is_empty() {
take.name = format!("Take {}", base + i + 1);
}
instance.takes.push(take);
}
// Land on the take just recorded, GarageBand-style. // Land on the take just recorded, GarageBand-style.
instance.active_take = Some(new_active); instance.active_take = Some(instance.takes.len() - 1);
self.executed = true; self.executed = true;
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(clip) = document.audio_clips.get_mut(&self.clip_id) {
if let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type {
takes.truncate(self.old_take_count);
for (i, take) in takes.iter_mut().enumerate() {
take.name = format!("Take {}", i + 1);
}
}
}
if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer_mut(&self.layer_id) { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer_mut(&self.layer_id) {
if let Some(instance) = audio_layer if let Some(instance) = audio_layer
.clip_instances .clip_instances
.iter_mut() .iter_mut()
.find(|ci| ci.id == self.instance_id) .find(|ci| ci.id == self.instance_id)
{ {
instance.takes.truncate(self.old_take_count);
instance.active_take = self.old_active_take; instance.active_take = self.old_active_take;
} }
} }

View File

@ -141,7 +141,7 @@ 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.resolve(instance.active_take) { match &instance.resolve(clip) {
ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id), ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id),
ResolvedContent::Audio { .. } => { 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)

View File

@ -0,0 +1,419 @@
//! Take management: delete and rename the takes on a clip instance.
//!
//! Takes live on the INSTANCE, so both of these are naturally scoped to the one the user clicked —
//! deleting a take from one half of a comped split leaves the other half's list alone.
use crate::action::{Action, BackendClipInstanceId, BackendContext};
use crate::clip::{AudioTake, ClipInstance};
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// The instance a take action targets, looked up mutably.
fn instance_mut<'a>(
document: &'a mut Document,
layer_id: &Uuid,
instance_id: &Uuid,
) -> Result<&'a mut ClipInstance, String> {
let layer = document
.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
let AnyLayer::Audio(audio_layer) = layer else {
return Err("Takes only exist on audio layers".to_string());
};
audio_layer
.clip_instances
.iter_mut()
.find(|ci| ci.id == *instance_id)
.ok_or_else(|| format!("Clip instance {} not found", instance_id))
}
/// Swap an instance's backend clip to whatever take the document now says is active.
///
/// The same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command.
fn resync(
backend: &mut BackendContext,
document: &Document,
layer_id: &Uuid,
instance_id: &Uuid,
) -> Result<(), String> {
let instance = document
.get_layer(layer_id)
.and_then(|l| match l {
AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == *instance_id),
_ => None,
})
.cloned()
.ok_or_else(|| format!("Clip instance {} not found", instance_id))?;
let existing: Option<BackendClipInstanceId> = backend
.clip_instance_to_backend_map
.get(instance_id)
.copied();
let track_id = backend.layer_to_track_map.get(layer_id).copied();
if let (Some(backend_id), Some(track_id)) = (existing, track_id) {
backend.remove_clip_instance(track_id, backend_id, *instance_id);
}
backend.add_clip_instance(document, layer_id, &instance)?;
Ok(())
}
/// Remove a take from an instance's take list.
///
/// The take's recorded audio/MIDI stays in the backend pool — undo has to be able to put it back,
/// and other instances (the other half of a split, say) may still be playing it.
pub struct DeleteTakeAction {
layer_id: Uuid,
instance_id: Uuid,
take_index: usize,
// Stored during execute for rollback.
removed: Option<AudioTake>,
old_active_take: Option<usize>,
}
impl DeleteTakeAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize) -> Self {
Self {
layer_id,
instance_id,
take_index,
removed: None,
old_active_take: None,
}
}
}
impl Action for DeleteTakeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
if self.take_index >= instance.takes.len() {
return Err(format!("Take {} does not exist", self.take_index + 1));
}
// An instance with no takes at all would fall back to the clip's own content, which for a
// cycle recording is a take we may just have deleted. Refuse rather than strand it.
if instance.takes.len() == 1 {
return Err("Can't delete the only take".to_string());
}
self.old_active_take = instance.active_take;
self.removed = Some(instance.takes.remove(self.take_index));
// Everything above the removed take shifts down one, so the selection has to move with it.
// Deleting the *active* take lands on the one that took its place (or the new last take, if
// it was at the end) — that keeps the clip sounding rather than silently picking take 1.
let active = instance.active_take.unwrap_or(0);
instance.active_take = Some(if active > self.take_index {
active - 1
} else if active == self.take_index {
self.take_index.min(instance.takes.len() - 1)
} else {
active
});
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some(take) = self.removed.take() else {
return Ok(());
};
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
let at = self.take_index.min(instance.takes.len());
instance.takes.insert(at, take);
instance.active_take = self.old_active_take;
Ok(())
}
fn description(&self) -> String {
format!("Delete take {}", self.take_index + 1)
}
fn execute_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
resync(backend, document, &self.layer_id, &self.instance_id)
}
fn rollback_backend(
&mut self,
backend: &mut BackendContext,
document: &Document,
) -> Result<(), String> {
resync(backend, document, &self.layer_id, &self.instance_id)
}
}
/// Throw away every take except the one that's playing.
///
/// The tidy-up once you've picked your keeper. Scoped to this instance, so on a comped split it
/// prunes the half you clicked and leaves the other half's alternatives intact.
pub struct DeleteUnusedTakesAction {
layer_id: Uuid,
instance_id: Uuid,
// Stored during execute for rollback.
old_takes: Vec<AudioTake>,
old_active_take: Option<usize>,
}
impl DeleteUnusedTakesAction {
pub fn new(layer_id: Uuid, instance_id: Uuid) -> Self {
Self {
layer_id,
instance_id,
old_takes: Vec::new(),
old_active_take: None,
}
}
}
impl Action for DeleteUnusedTakesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
if instance.takes.len() < 2 {
return Err("Nothing to delete".to_string());
}
let keep = instance.active_take_index();
self.old_takes = instance.takes.clone();
self.old_active_take = instance.active_take;
let kept = instance.takes.remove(keep);
instance.takes.clear();
instance.takes.push(kept);
instance.active_take = Some(0);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
instance.takes = std::mem::take(&mut self.old_takes);
instance.active_take = self.old_active_take;
Ok(())
}
fn description(&self) -> String {
"Delete unused takes".to_string()
}
// The take that plays doesn't change, so the backend clip is already correct.
}
/// Rename a take. Document-only — which take *plays* doesn't change, so the backend is untouched.
pub struct RenameTakeAction {
layer_id: Uuid,
instance_id: Uuid,
take_index: usize,
new_name: String,
old_name: String,
}
impl RenameTakeAction {
pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize, new_name: String) -> Self {
Self {
layer_id,
instance_id,
take_index,
new_name,
old_name: String::new(),
}
}
}
impl Action for RenameTakeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
let take = instance
.takes
.get_mut(self.take_index)
.ok_or_else(|| format!("Take {} does not exist", self.take_index + 1))?;
self.old_name = std::mem::replace(&mut take.name, self.new_name.clone());
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let instance = instance_mut(document, &self.layer_id, &self.instance_id)?;
if let Some(take) = instance.takes.get_mut(self.take_index) {
take.name = self.old_name.clone();
}
Ok(())
}
fn description(&self) -> String {
format!("Rename take to \"{}\"", self.new_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clip::TakeContent;
use crate::layer::AudioLayer;
/// A document with one audio layer holding one instance with 4 takes (pools 10..13).
fn doc_with_takes() -> (Document, Uuid, Uuid) {
let mut document = Document::new("Test");
let clip = crate::clip::AudioClip::new_sampled("Cycle rec", 10, 2.0);
let clip_id = document.add_audio_clip(clip);
let mut instance = ClipInstance::new(clip_id);
instance.takes = (10..14)
.map(|pool| AudioTake {
name: format!("Take {}", pool - 9),
content: TakeContent::Audio { audio_pool_index: pool },
})
.collect();
let instance_id = instance.id;
let mut layer = AudioLayer::new("Layer");
layer.clip_instances.push(instance);
let layer_id = document.root.add_child(AnyLayer::Audio(layer));
(document, layer_id, instance_id)
}
fn takes_of(document: &Document, layer_id: &Uuid, instance_id: &Uuid) -> ClipInstance {
let AnyLayer::Audio(al) = document.get_layer(layer_id).unwrap() else { panic!() };
al.clip_instances.iter().find(|ci| ci.id == *instance_id).unwrap().clone()
}
#[test]
fn deleting_a_take_below_the_active_one_shifts_the_selection_down() {
// Everything above the removed take shifts down one, so a selection above it has to move
// with it — otherwise the instance silently starts playing a different take.
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(3); // playing pool 13
}
DeleteTakeAction::new(layer_id, instance_id, 1)
.execute(&mut document)
.expect("delete");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 3);
assert_eq!(inst.active_take, Some(2), "index shifted down with the take");
assert_eq!(
inst.takes[inst.active_take_index()].content,
TakeContent::Audio { audio_pool_index: 13 },
"still playing the same take it was",
);
}
#[test]
fn deleting_the_active_take_lands_on_its_replacement() {
// Deleting what you're listening to should hand you the take that took its place, not
// silently jump you back to take 1.
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(1); // playing pool 11
}
DeleteTakeAction::new(layer_id, instance_id, 1)
.execute(&mut document)
.expect("delete");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.active_take, Some(1));
assert_eq!(
inst.takes[1].content,
TakeContent::Audio { audio_pool_index: 12 },
"the take that slid into the deleted one's place",
);
}
#[test]
fn deleting_the_last_take_in_the_list_steps_back() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(3);
}
DeleteTakeAction::new(layer_id, instance_id, 3)
.execute(&mut document)
.expect("delete");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.active_take, Some(2), "there is no take 4 to land on");
}
#[test]
fn the_only_take_cannot_be_deleted() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].takes.truncate(1);
}
assert!(DeleteTakeAction::new(layer_id, instance_id, 0)
.execute(&mut document)
.is_err());
}
#[test]
fn undoing_a_delete_puts_the_take_back_where_it_was() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(2);
}
let mut action = DeleteTakeAction::new(layer_id, instance_id, 1);
action.execute(&mut document).expect("delete");
action.rollback(&mut document).expect("undo");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 4);
assert_eq!(
inst.takes[1].content,
TakeContent::Audio { audio_pool_index: 11 },
"restored at its original index",
);
assert_eq!(inst.active_take, Some(2), "and the selection with it");
}
#[test]
fn deleting_unused_takes_keeps_the_one_thats_playing() {
let (mut document, layer_id, instance_id) = doc_with_takes();
{
let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() };
al.clip_instances[0].active_take = Some(2); // pool 12 — the keeper
}
let mut action = DeleteUnusedTakesAction::new(layer_id, instance_id);
action.execute(&mut document).expect("prune");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 1);
assert_eq!(inst.active_take, Some(0));
assert_eq!(
inst.takes[0].content,
TakeContent::Audio { audio_pool_index: 12 },
"the take that was playing survives, and nothing else",
);
action.rollback(&mut document).expect("undo");
let inst = takes_of(&document, &layer_id, &instance_id);
assert_eq!(inst.takes.len(), 4);
assert_eq!(inst.active_take, Some(2), "back to what was playing before");
}
#[test]
fn renaming_a_take_round_trips() {
let (mut document, layer_id, instance_id) = doc_with_takes();
let mut action =
RenameTakeAction::new(layer_id, instance_id, 2, "The good one".to_string());
action.execute(&mut document).expect("rename");
assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "The good one");
action.rollback(&mut document).expect("undo");
assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "Take 3");
}
}

View File

@ -16,6 +16,7 @@ pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_cycle_region; pub mod set_cycle_region;
pub mod append_takes; pub mod append_takes;
pub mod manage_takes;
pub mod set_active_take; 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;
@ -55,6 +56,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 append_takes::AppendTakesAction; pub use append_takes::AppendTakesAction;
pub use manage_takes::{DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction};
pub use set_active_take::SetActiveTakeAction; 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;

View File

@ -247,7 +247,7 @@ 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.resolve(instance.active_take) { match &instance.resolve(clip) {
ResolvedContent::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);
@ -332,7 +332,7 @@ 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.resolve(instance.active_take) { match &instance.resolve(clip) {
ResolvedContent::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);

View File

@ -364,7 +364,7 @@ impl Action for SplitClipInstanceAction {
.ok_or_else(|| "Audio clip not found".to_string())?; .ok_or_else(|| "Audio clip not found".to_string())?;
use crate::clip::ResolvedContent; use crate::clip::ResolvedContent;
if matches!(clip.resolve(original_instance.active_take), ResolvedContent::Recording) { if matches!(original_instance.resolve(clip), ResolvedContent::Recording) {
return Err("Cannot split a clip that is currently recording".to_string()); return Err("Cannot split a clip that is currently recording".to_string());
} }
@ -455,7 +455,7 @@ impl Action for SplitClipInstanceAction {
// Restore based on clip type // Restore based on clip type
use crate::clip::ResolvedContent; use crate::clip::ResolvedContent;
match &clip.resolve(instance.active_take) { match &instance.resolve(clip) {
ResolvedContent::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)

View File

@ -487,7 +487,7 @@ impl Action for TrimClipInstancesAction {
.unwrap_or(ContentTime(clip.content_duration().native())); .unwrap_or(ContentTime(clip.content_duration().native()));
// Handle trim based on clip type // Handle trim based on clip type
match &clip.resolve(instance.active_take) { match &instance.resolve(clip) {
ResolvedContent::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, clip.trim_range(internal_start, internal_end)); controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));
@ -584,7 +584,7 @@ impl Action for TrimClipInstancesAction {
}; };
// Handle trim based on clip type // Handle trim based on clip type
match &clip.resolve(instance.active_take) { match &instance.resolve(clip) {
ResolvedContent::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, clip.trim_range(internal_start, internal_end)); controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end));

View File

@ -472,39 +472,20 @@ 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`]. /// One take of a cycle recording — see [`ClipInstance::takes`].
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AudioTake { pub struct AudioTake {
/// Display name, e.g. "Take 1". /// Display name, e.g. "Take 1". User-editable.
pub name: String, pub name: String,
/// The recorded content this take points at. /// The recorded content this take points at.
pub content: TakeContent, pub content: TakeContent,
} }
/// What a take actually holds. A folder's takes are all the same kind — one cycle-record session /// What a take actually holds. An instance's takes are all the same kind — one cycle-record session
/// captures either audio or MIDI, never a mix. /// captures either audio or MIDI, never a mix.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum TakeContent { pub enum TakeContent {
/// Sampled audio: index into the audio pool. /// Sampled audio: index into the audio pool.
Audio { audio_pool_index: usize }, Audio { audio_pool_index: usize },
@ -608,18 +589,8 @@ impl AudioClip {
} }
/// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds. /// 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 { fn is_midi_domain(&self) -> bool {
match &self.clip_type { matches!(self.clip_type, AudioClipType::Midi { .. })
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
@ -706,34 +677,11 @@ impl AudioClip {
} }
} }
/// The clip's takes, if it's a take folder. /// The clip's own content, ignoring takes.
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 /// Callers that are handing content to the backend want [`ClipInstance::resolve`] instead — an
/// go stale (an old `.beam`, an undo that shrank the folder), and silently playing the first /// instance with takes overrides the clip's content with whichever take is active.
/// take beats refusing to play anything. pub fn resolve(&self) -> ResolvedContent {
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 { match &self.clip_type {
AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio { AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio {
audio_pool_index: *audio_pool_index, audio_pool_index: *audio_pool_index,
@ -742,17 +690,6 @@ impl AudioClip {
midi_clip_id: *midi_clip_id, midi_clip_id: *midi_clip_id,
}, },
AudioClipType::Recording => ResolvedContent::Recording, 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,
},
} }
} }
@ -787,49 +724,19 @@ impl AudioClip {
} }
} }
/// Whether this clip owns the given audio pool index — either as a plain sampled clip, or as /// Whether this clip's own content is the given audio pool index.
/// *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 { pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool {
match &self.clip_type { self.audio_pool_index() == Some(pool_index)
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`]. /// Whether this clip's own content is the given backend MIDI clip ID.
pub fn owns_midi_clip_id(&self, id: u32) -> bool { pub fn owns_midi_clip_id(&self, id: u32) -> bool {
match &self.clip_type { self.midi_clip_id() == Some(id)
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. /// What a clip instance actually plays, once takes are resolved to the active one.
/// Produced by [`AudioClip::resolve`]. /// Produced by [`ClipInstance::resolve`].
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub enum ResolvedContent { pub enum ResolvedContent {
Audio { audio_pool_index: usize }, Audio { audio_pool_index: usize },
@ -942,14 +849,34 @@ pub struct ClipInstance {
#[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. /// Alternate takes from cycle recording. Empty = an ordinary instance with no takes.
/// ///
/// Per-instance rather than per-clip so two instances of the same folder — e.g. the two halves /// The takes live on the INSTANCE, not the clip, so managing them is per-instance: deleting or
/// of a split — can play different takes. That's how comping works. `None` means take 0; /// renaming a take on one instance leaves every other instance alone. Splitting clones the list
/// meaningless (and ignored) on non-folder clips. /// along with the rest of the instance, so the two halves get independent take lists — and,
/// since they can each select a different take, comping still falls out for free.
///
/// Every take spans the full cycle region (partial passes are padded with silence at capture
/// time), so they're all the same length as the clip's own content. That uniformity is what lets
/// a take switch leave the instance's geometry untouched.
///
/// When non-empty, these OVERRIDE the clip's own content — see [`Self::resolve`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub takes: Vec<AudioTake>,
/// Which of [`Self::takes`] plays. `None` (or a stale index) means take 0.
/// Default: None /// Default: None
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub active_take: Option<usize>, pub active_take: Option<usize>,
/// The cycle region's length in beats when these takes were recorded.
///
/// Audio takes are cut geometrically (by sample count), so they're only meaningful against the
/// tempo they were recorded at. Keeping the recorded length lets a future time-stretch/conform
/// feature reconcile them if the tempo moves underneath, and lets a new recording tell whether
/// it belongs in this take list or a fresh one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recorded_loop_beats: Option<Beats>,
} }
/// 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.
@ -1008,7 +935,9 @@ impl ClipInstance {
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
takes: Vec::new(),
active_take: None, active_take: None,
recorded_loop_beats: None,
} }
} }
@ -1027,7 +956,9 @@ impl ClipInstance {
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None, loop_before: None,
takes: Vec::new(),
active_take: None, active_take: None,
recorded_loop_beats: None,
} }
} }
@ -1087,6 +1018,55 @@ impl ClipInstance {
self self
} }
/// The take this instance plays, if it has any.
///
/// A `None`/stale `active_take` falls back to take 0 — an index can go stale (an undo that
/// shrank the list, an old `.beam`), and silently playing the first take beats playing nothing.
pub fn active_take(&self) -> Option<&AudioTake> {
self.takes
.get(self.active_take.unwrap_or(0))
.or_else(|| self.takes.first())
}
/// The index [`Self::active_take`] actually resolves to, clamped into range.
pub fn active_take_index(&self) -> usize {
let i = self.active_take.unwrap_or(0);
if i < self.takes.len() { i } else { 0 }
}
/// What this instance plays: its active take if it has takes, otherwise the clip's own content.
///
/// This is the sanctioned way to ask "what content do I hand the backend for this instance?".
/// Takes are never a distinct *case* at the call site — an instance with takes is just an audio
/// or MIDI instance whose identity depends on which take is live.
pub fn resolve(&self, clip: &AudioClip) -> ResolvedContent {
match self.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,
},
None => clip.resolve(),
}
}
/// The audio pool index this instance plays. See [`Self::resolve`].
pub fn resolved_audio_pool_index(&self, clip: &AudioClip) -> Option<usize> {
match self.resolve(clip) {
ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index),
_ => None,
}
}
/// The backend MIDI clip ID this instance plays. See [`Self::resolve`].
pub fn resolved_midi_clip_id(&self, clip: &AudioClip) -> Option<u32> {
match self.resolve(clip) {
ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id),
_ => None,
}
}
/// Content window (`trim_end - trim_start`) in the clip's own content domain. /// Content window (`trim_end - trim_start`) in the clip's own content domain.
/// Used for internal looping calculations. /// Used for internal looping calculations.
pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration { pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
@ -1328,79 +1308,89 @@ mod tests {
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. /// A clip whose own content is pool 10, plus an instance carrying takes over the given pools.
fn take_folder(pool_indices: &[usize]) -> AudioClip { fn with_takes(pool_indices: &[usize]) -> (AudioClip, ClipInstance) {
let mut clip = AudioClip::new_sampled("Cycle rec", 0, 2.0); let clip = AudioClip::new_sampled("Cycle rec", pool_indices[0], 2.0);
clip.clip_type = AudioClipType::TakeFolder { let mut instance = ClipInstance::new(clip.id);
takes: pool_indices instance.takes = pool_indices
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, &audio_pool_index)| AudioTake { .map(|(i, &audio_pool_index)| AudioTake {
name: format!("Take {}", i + 1), name: format!("Take {}", i + 1),
content: TakeContent::Audio { audio_pool_index }, content: TakeContent::Audio { audio_pool_index },
}) })
.collect(), .collect();
recorded_loop_beats: Beats(8.0), instance.recorded_loop_beats = Some(Beats(8.0));
}; (clip, instance)
clip
} }
#[test] #[test]
fn active_take_selects_the_pool_file() { fn active_take_selects_the_pool_file() {
let clip = take_folder(&[10, 11, 12]); let (clip, mut instance) = with_takes(&[10, 11, 12]);
assert_eq!(clip.resolved_audio_pool_index(Some(0)), Some(10)); instance.active_take = Some(0);
assert_eq!(clip.resolved_audio_pool_index(Some(2)), Some(12)); assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
instance.active_take = Some(2);
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(12));
// None means take 0. // None means take 0.
assert_eq!(clip.resolved_audio_pool_index(None), Some(10)); instance.active_take = None;
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
} }
#[test] #[test]
fn out_of_range_take_falls_back_to_the_first() { 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 // An index can go stale (an undo that shrank the list, an old .beam). Playing the first take
// take beats playing nothing. // beats playing nothing.
let clip = take_folder(&[10, 11]); let (clip, mut instance) = with_takes(&[10, 11]);
assert_eq!(clip.resolved_audio_pool_index(Some(99)), Some(10)); instance.active_take = Some(99);
assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10));
assert_eq!(instance.active_take_index(), 0);
} }
#[test] #[test]
fn take_folder_owns_every_takes_pool_file() { fn an_instance_without_takes_plays_the_clips_own_content() {
// Reverse lookups (backend resource -> document clip) must find the folder via ANY take, let clip = AudioClip::new_sampled("Plain", 42, 2.0);
// not just the active one. let instance = ClipInstance::new(clip.id);
let clip = take_folder(&[10, 11, 12]); assert!(instance.takes.is_empty());
assert!(clip.owns_audio_pool_index(10)); assert_eq!(instance.resolved_audio_pool_index(&clip), Some(42));
assert!(clip.owns_audio_pool_index(12));
assert!(!clip.owns_audio_pool_index(13));
} }
#[test] #[test]
fn midi_take_folder_measures_duration_in_beats() { fn midi_takes_resolve_to_midi_content() {
// A folder inherits its takes' domain: MIDI takes mean the duration is beats, not seconds. let clip = AudioClip::new_midi("Cycle rec", 7, Beats(4.0));
let mut clip = AudioClip::new_sampled("Cycle rec", 0, 4.0); let mut instance = ClipInstance::new(clip.id);
clip.clip_type = AudioClipType::TakeFolder { instance.takes = vec![AudioTake {
takes: vec![AudioTake { name: "Take 1".into(),
name: "Take 1".into(), content: TakeContent::Midi { midi_clip_id: 7 },
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.content_duration(), ClipDuration::Beats(Beats(4.0)));
assert_eq!(clip.resolved_midi_clip_id(Some(0)), Some(7)); assert_eq!(instance.resolved_midi_clip_id(&clip), Some(7));
assert_eq!(clip.resolved_audio_pool_index(Some(0)), None); assert_eq!(instance.resolved_audio_pool_index(&clip), None);
} }
#[test] #[test]
fn takes_are_per_instance_so_a_split_can_comp() { fn splitting_gives_each_half_an_independent_take_list() {
// The whole point of putting active_take on the instance: two instances of the same folder // Takes live on the INSTANCE, so a split (which clones the instance) hands each half its own
// (which is what a split produces) can play different takes. // list. Two consequences, both wanted: the halves can select different takes (comping), and
let clip = take_folder(&[10, 11, 12]); // deleting a take from one leaves the other alone.
let mut left = ClipInstance::new(clip.id); let (clip, left_src) = with_takes(&[10, 11, 12]);
let mut right = left.clone(); let mut left = left_src.clone();
let mut right = left_src.clone();
right.id = Uuid::new_v4(); right.id = Uuid::new_v4();
left.active_take = Some(0); left.active_take = Some(0);
right.active_take = Some(2); right.active_take = Some(2);
assert_eq!(left.resolved_audio_pool_index(&clip), Some(10));
assert_eq!(right.resolved_audio_pool_index(&clip), Some(12));
assert_eq!(clip.resolved_audio_pool_index(left.active_take), Some(10)); // Delete take 2 (pool 11) from the left half only.
assert_eq!(clip.resolved_audio_pool_index(right.active_take), Some(12)); left.takes.remove(1);
assert_eq!(left.takes.len(), 2);
assert_eq!(right.takes.len(), 3, "the other half keeps its own takes");
assert_eq!(
right.resolved_audio_pool_index(&clip),
Some(12),
"and its selection still points where it did",
);
} }
#[test] #[test]

View File

@ -944,31 +944,27 @@ impl Document {
/// would break the uniform-take invariant comping depends on. /// would break the uniform-take invariant comping depends on.
/// ///
/// `exclude` is the in-progress recording's own instance, which is on the layer but isn't a /// `exclude` is the in-progress recording's own instance, which is on the layer but isn't a
/// candidate. Returns (instance id, clip id). /// candidate. Returns the instance id.
pub fn take_folder_at( pub fn take_folder_at(
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
loop_start: Beats, loop_start: Beats,
loop_len: Beats, loop_len: Beats,
exclude: &Uuid, exclude: &Uuid,
) -> Option<(Uuid, Uuid)> { ) -> Option<Uuid> {
let Some(AnyLayer::Audio(audio_layer)) = self.get_layer(layer_id) else { let Some(AnyLayer::Audio(audio_layer)) = self.get_layer(layer_id) else {
return None; return None;
}; };
const EPS: f64 = 1e-6; const EPS: f64 = 1e-6;
audio_layer.clip_instances.iter().find_map(|ci| { audio_layer.clip_instances.iter().find_map(|ci| {
if ci.id == *exclude || (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS { if ci.id == *exclude
|| ci.takes.is_empty()
|| (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS
{
return None; return None;
} }
let clip = self.audio_clips.get(&ci.clip_id)?; let recorded = ci.recorded_loop_beats?;
match clip.clip_type { ((recorded - loop_len).beats_to_f64().abs() < EPS).then_some(ci.id)
crate::clip::AudioClipType::TakeFolder { recorded_loop_beats, .. }
if (recorded_loop_beats - loop_len).beats_to_f64().abs() < EPS =>
{
Some((ci.id, ci.clip_id))
}
_ => None,
}
}) })
} }

View File

@ -1112,6 +1112,11 @@ struct PendingTakeAppend {
/// The throwaway clip + instance the recording itself was captured into. /// The throwaway clip + instance the recording itself was captured into.
recording_instance_id: Uuid, recording_instance_id: Uuid,
recording_clip_id: Uuid, recording_clip_id: Uuid,
/// The recording's backend clip, which has to be torn down or it keeps playing alongside the
/// take folder we're appending to. Carried explicitly rather than looked up in
/// `clip_instance_to_backend_map`, because on the audio path the recording instance isn't in
/// that map yet — it's only added after promotion, which the append path skips.
recording_backend_id: Option<lightningbeam_core::action::BackendClipInstanceId>,
loop_start: Beats, loop_start: Beats,
loop_len: Beats, loop_len: Beats,
takes: Vec<lightningbeam_core::clip::AudioTake>, takes: Vec<lightningbeam_core::clip::AudioTake>,
@ -2069,11 +2074,12 @@ impl EditorApp {
layer_id: uuid::Uuid, layer_id: uuid::Uuid,
recording_instance_id: uuid::Uuid, recording_instance_id: uuid::Uuid,
recording_clip_id: uuid::Uuid, recording_clip_id: uuid::Uuid,
recording_backend_id: Option<lightningbeam_core::action::BackendClipInstanceId>,
loop_start: Beats, loop_start: Beats,
loop_len: Beats, loop_len: Beats,
takes: Vec<lightningbeam_core::clip::AudioTake>, takes: Vec<lightningbeam_core::clip::AudioTake>,
) -> bool { ) -> bool {
let Some((target_instance_id, target_clip_id)) = self.action_executor.document().take_folder_at( let Some(target_instance_id) = self.action_executor.document().take_folder_at(
&layer_id, &layer_id,
loop_start, loop_start,
loop_len, loop_len,
@ -2082,9 +2088,12 @@ impl EditorApp {
return false; return false;
}; };
// Drop the recording's backend clip; the target instance's own clip gets repointed at the // Tear down the recording's own backend clip. Without this it keeps playing on top of the
// new active take by the action's backend sync. // take folder we're appending to — two takes sounding at once. The target instance's clip is
let backend_id = self.clip_instance_to_backend_map.remove(&recording_instance_id); // separately repointed at the new active take by the action's backend sync below.
let backend_id = recording_backend_id
.or_else(|| self.clip_instance_to_backend_map.remove(&recording_instance_id));
self.clip_instance_to_backend_map.remove(&recording_instance_id);
let track_id = self.layer_to_track_map.get(&layer_id).copied(); let track_id = self.layer_to_track_map.get(&layer_id).copied();
if let (Some(backend_id), Some(track_id), Some(controller_arc)) = if let (Some(backend_id), Some(track_id), Some(controller_arc)) =
(backend_id, track_id, self.audio_controller.as_ref()) (backend_id, track_id, self.audio_controller.as_ref())
@ -2112,7 +2121,6 @@ impl EditorApp {
let action = lightningbeam_core::actions::AppendTakesAction::new( let action = lightningbeam_core::actions::AppendTakesAction::new(
layer_id, layer_id,
target_instance_id, target_instance_id,
target_clip_id,
takes, takes,
); );
@ -6692,6 +6700,13 @@ impl eframe::App for EditorApp {
layer_id, layer_id,
recording_instance_id: instance_id, recording_instance_id: instance_id,
recording_clip_id: clip_id, recording_clip_id: clip_id,
// The engine's recording clip. It isn't in
// clip_instance_to_backend_map yet (that only happens on
// promotion, which we're skipping), so hand it over
// directly or it'll keep sounding alongside the folder.
recording_backend_id: Some(
lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id),
),
loop_start, loop_start,
loop_len: loop_len_beats, loop_len: loop_len_beats,
takes: new_takes, takes: new_takes,
@ -6701,22 +6716,18 @@ impl eframe::App for EditorApp {
continue; continue;
} }
// Promote the in-progress recording clip to a take folder. // Finalize the recording clip, and hang the takes off the INSTANCE.
{ {
let doc = self.action_executor.document_mut(); let doc = self.action_executor.document_mut();
if let Some(clip) = doc.audio_clips.get_mut(&clip_id) { if let Some(clip) = doc.audio_clips.get_mut(&clip_id) {
clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder { // The clip's own content is take 1; the instance's take
takes: takes.iter().enumerate().map(|(i, &(pool_index, _))| { // list overrides it with whichever take is active. Every
lightningbeam_core::clip::AudioTake { // take is exactly one cycle region long, so the clip's
name: format!("Take {}", i + 1), // duration is the region.
content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, clip.finalize_recording(
} takes[0].0,
}).collect(), loop_len_seconds.seconds_to_f64(),
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()); clip.name = format!("Cycle recording ({} takes)", takes.len());
} }
@ -6735,7 +6746,14 @@ impl eframe::App for EditorApp {
inst.trim_start = daw_backend::ContentTime::ZERO; inst.trim_start = daw_backend::ContentTime::ZERO;
inst.trim_end = inst.trim_end =
Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64())); Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64()));
inst.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();
inst.active_take = Some(last_take); inst.active_take = Some(last_take);
inst.recorded_loop_beats = Some(loop_len_beats);
} }
} }
} }
@ -7029,6 +7047,9 @@ impl eframe::App for EditorApp {
layer_id, layer_id,
recording_instance_id: rec_inst, recording_instance_id: rec_inst,
recording_clip_id: doc_clip_id, recording_clip_id: doc_clip_id,
// The MIDI path maps its recording instance during
// MidiRecordingProgress, so the map has it.
recording_backend_id: None,
loop_start, loop_start,
loop_len: loop_len_beats, loop_len: loop_len_beats,
takes: new_takes, takes: new_takes,
@ -7046,17 +7067,10 @@ impl eframe::App for EditorApp {
{ {
let doc = self.action_executor.document_mut(); let doc = self.action_executor.document_mut();
if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) {
clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder { // The clip's own content stays take 1 (the clip the
takes: clip_ids.iter().enumerate().map(|(i, &mid)| { // recording started on); the instance's take list
lightningbeam_core::clip::AudioTake { // overrides it with whichever take is active. MIDI takes
name: format!("Take {}", i + 1), // are beats-domain and each spans one cycle region.
content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid },
}
}).collect(),
recorded_loop_beats: loop_len_beats,
};
// MIDI takes are beats-domain, and every take spans exactly
// one cycle region.
clip.set_content_duration(ClipDuration::Beats(loop_len_beats)); clip.set_content_duration(ClipDuration::Beats(loop_len_beats));
clip.name = format!("Cycle recording ({} takes)", clip_ids.len()); clip.name = format!("Cycle recording ({} takes)", clip_ids.len());
} }
@ -7071,7 +7085,14 @@ impl eframe::App for EditorApp {
inst.timeline_duration = None; inst.timeline_duration = None;
inst.trim_start = daw_backend::ContentTime::ZERO; inst.trim_start = daw_backend::ContentTime::ZERO;
inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64())); inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64()));
inst.takes = clip_ids.iter().enumerate().map(|(i, &mid)| {
lightningbeam_core::clip::AudioTake {
name: format!("Take {}", i + 1),
content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid },
}
}).collect();
inst.active_take = Some(last_take); inst.active_take = Some(last_take);
inst.recorded_loop_beats = Some(loop_len_beats);
} }
} }
} }
@ -7263,6 +7284,7 @@ impl eframe::App for EditorApp {
req.layer_id, req.layer_id,
req.recording_instance_id, req.recording_instance_id,
req.recording_clip_id, req.recording_clip_id,
req.recording_backend_id,
req.loop_start, req.loop_start,
req.loop_len, req.loop_len,
req.takes, req.takes,

View File

@ -43,6 +43,7 @@ pub const CHEVRONS_UP: &str = "\u{e074}";
pub const PLAY: &str = "\u{e13c}"; pub const PLAY: &str = "\u{e13c}";
pub const PAUSE: &str = "\u{e12e}"; pub const PAUSE: &str = "\u{e12e}";
pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle pub const REPEAT: &str = "\u{e146}"; // cycle / loop region toggle
pub const TRASH: &str = "\u{e18d}"; // delete a take
pub const SETTINGS: &str = "\u{e154}"; pub const SETTINGS: &str = "\u{e154}";
pub const SEARCH: &str = "\u{e151}"; pub const SEARCH: &str = "\u{e151}";
pub const PLUS: &str = "\u{e13d}"; pub const PLUS: &str = "\u{e13d}";

View File

@ -7,17 +7,7 @@
//! - Image Assets (static images) //! - Image Assets (static images)
use eframe::egui; use eframe::egui;
use lightningbeam_core::clip::{AudioClip, ResolvedContent, VectorClip}; use lightningbeam_core::clip::{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};
@ -928,9 +918,9 @@ impl AssetLibraryPane {
continue; continue;
} }
let (extra_info, drag_clip_type) = match &clip.resolve(None) { let (extra_info, drag_clip_type) = match &clip.resolve() {
ResolvedContent::Audio { .. } => (take_label(clip, "Sampled"), DragClipType::AudioSampled), ResolvedContent::Audio { .. } => ("Sampled".to_string(), DragClipType::AudioSampled),
ResolvedContent::Midi { .. } => (take_label(clip, "MIDI"), DragClipType::AudioMidi), ResolvedContent::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi),
ResolvedContent::Recording => { ResolvedContent::Recording => {
// Skip recording-in-progress clips (and empty take folders) from asset library // Skip recording-in-progress clips (and empty take folders) from asset library
continue; continue;
@ -1128,12 +1118,12 @@ 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.resolve(None) { let (extra_info, drag_clip_type) = match &clip.resolve() {
ResolvedContent::Audio { .. } => { ResolvedContent::Audio { .. } => {
(take_label(clip, "Sampled"), DragClipType::AudioSampled) ("Sampled".to_string(), DragClipType::AudioSampled)
} }
ResolvedContent::Midi { .. } => { ResolvedContent::Midi { .. } => {
(take_label(clip, "MIDI"), DragClipType::AudioMidi) ("MIDI".to_string(), DragClipType::AudioMidi)
} }
ResolvedContent::Recording => { ResolvedContent::Recording => {
// Skip recording-in-progress clips (and empty take folders) // Skip recording-in-progress clips (and empty take folders)
@ -1775,7 +1765,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 Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { if let Some(audio_pool_index) = clip.audio_pool_index().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 {
@ -1800,7 +1790,7 @@ 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.resolve(None) { match &clip.resolve() {
ResolvedContent::Audio { .. } => { 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 {
@ -2354,7 +2344,7 @@ 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.resolve(None) { match &clip.resolve() {
ResolvedContent::Audio { 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)
@ -2491,7 +2481,7 @@ 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.resolve(None) { match &clip.resolve() {
ResolvedContent::Audio { 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)
@ -2812,7 +2802,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 Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { if let Some(audio_pool_index) = clip.audio_pool_index().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() {
@ -2852,7 +2842,7 @@ 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.resolve(None) { match &clip.resolve() {
ResolvedContent::Audio { .. } => { 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 {
@ -3197,7 +3187,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 Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { if let Some(audio_pool_index) = clip.audio_pool_index().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);

View File

@ -1807,16 +1807,10 @@ 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:");

View File

@ -467,7 +467,7 @@ impl PianoRollPane {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
// Resolve through the instance's active take, so a MIDI take folder edits // Resolve through the instance's active take, so a MIDI take folder edits
// whichever take it's actually playing. // whichever take it's actually playing.
if let Some(midi_clip_id) = clip.resolved_midi_clip_id(instance.active_take) { if let Some(midi_clip_id) = instance.resolved_midi_clip_id(clip) {
let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); let duration = instance.effective_duration(clip.content_duration(), document.tempo_map());
// A MIDI clip's content time IS beats, which is what the piano roll's // A MIDI clip's content time IS beats, which is what the piano roll's
// x-axis uses. // x-axis uses.

View File

@ -298,6 +298,9 @@ pub struct TimelinePane {
take_badge_hits: Vec<(egui::Rect, uuid::Uuid, uuid::Uuid, usize, usize)>, take_badge_hits: Vec<(egui::Rect, uuid::Uuid, uuid::Uuid, usize, usize)>,
/// The take-folder instance whose take menu is open, if any. /// The take-folder instance whose take menu is open, if any.
open_take_menu: Option<(uuid::Uuid, uuid::Uuid)>, open_take_menu: Option<(uuid::Uuid, uuid::Uuid)>,
/// The (instance, take index) being renamed inline in the take menu.
renaming_take: Option<(uuid::Uuid, usize)>,
take_rename_buffer: String,
/// Seconds between the cycle region's start and where the current recording actually began. /// 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 /// Zero unless the user punched in mid-region. Used to line the live waveform preview up with
/// the region on each pass. /// the region on each pass.
@ -741,6 +744,8 @@ impl TimelinePane {
keyframe_diamond_hits: Vec::new(), keyframe_diamond_hits: Vec::new(),
take_badge_hits: Vec::new(), take_badge_hits: Vec::new(),
open_take_menu: None, open_take_menu: None,
renaming_take: None,
take_rename_buffer: String::new(),
cycle_record_lead_secs: 0.0, cycle_record_lead_secs: 0.0,
duration: 10.0, // Default 10 seconds duration: 10.0, // Default 10 seconds
is_scrubbing: false, is_scrubbing: false,
@ -1148,6 +1153,28 @@ impl TimelinePane {
// The backend records in the beats domain; start_time is the seconds playhead. // The backend records in the beats domain; start_time is the seconds playhead.
let start_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(start_time)); let start_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(start_time));
// Does this layer already hold takes over the cycle region? If so this recording is another
// take, however short it runs — without this, stopping before the loop came round would land
// it as a separate overlapping clip instead of joining the take list. The engine can't work
// this out for itself: whether takes exist is document state.
let force_takes: std::collections::HashMap<uuid::Uuid, bool> = {
let doc = shared.action_executor.document();
let region = match (doc.cycle_enabled, doc.cycle_region) {
(true, Some((ls, le))) if le > ls => Some((ls, le - ls)),
_ => None,
};
candidates
.iter()
.map(|&(layer_id, _, _)| {
let forced = region.is_some_and(|(loop_start, loop_len)| {
doc.take_folder_at(&layer_id, loop_start, loop_len, &uuid::Uuid::nil())
.is_some()
});
(layer_id, forced)
})
.collect()
};
// Step 4: Dispatch recording for each candidate // Step 4: Dispatch recording for each candidate
for &(layer_id, ref cat, _) in &candidates { for &(layer_id, ref cat, _) in &candidates {
match cat { match cat {
@ -1171,7 +1198,7 @@ impl TimelinePane {
} }
if let Some(controller_arc) = shared.audio_controller { if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
controller.start_recording(track_id, start_beats); controller.start_recording(track_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false));
println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time); println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time);
} }
shared.recording_layer_ids.push(layer_id); shared.recording_layer_ids.push(layer_id);
@ -1184,7 +1211,7 @@ impl TimelinePane {
if let Some(controller_arc) = shared.audio_controller { if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
let clip_id = controller.create_midi_clip(track_id, start_beats, Beats::ZERO); let clip_id = controller.create_midi_clip(track_id, start_beats, Beats::ZERO);
controller.start_midi_recording(track_id, clip_id, start_beats); controller.start_midi_recording(track_id, clip_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false));
shared.recording_clips.insert(layer_id, clip_id); shared.recording_clips.insert(layer_id, clip_id);
println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}",
track_id, start_time, clip_id); track_id, start_time, clip_id);
@ -1695,9 +1722,7 @@ impl TimelinePane {
return; return;
}; };
// The instance's *stored* selection, which is what rollback must restore — not `active`, let Some(instance) = document
// which is that value clamped for display.
let old_take = document
.get_layer(&layer_id) .get_layer(&layer_id)
.and_then(|l| match l { .and_then(|l| match l {
lightningbeam_core::layer::AnyLayer::Audio(al) => { lightningbeam_core::layer::AnyLayer::Audio(al) => {
@ -1705,7 +1730,14 @@ impl TimelinePane {
} }
_ => None, _ => None,
}) })
.and_then(|ci| ci.active_take); 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 = instance.active_take;
let take_names: Vec<String> = instance.takes.iter().map(|t| t.name.clone()).collect();
let mut close = false; let mut close = false;
let area = egui::Area::new(ui.id().with(("take_menu", instance_id))) let area = egui::Area::new(ui.id().with(("take_menu", instance_id)))
@ -1715,22 +1747,53 @@ impl TimelinePane {
egui::Frame::popup(ui.style()).show(ui, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| {
for i in 0..count { for i in 0..count {
let is_active = i == active; let is_active = i == active;
if ui let renaming = self.renaming_take == Some((instance_id, i));
.selectable_label(is_active, format!("Take {}", i + 1))
.clicked() ui.horizontal(|ui| {
{ if renaming {
if !is_active { let edit = ui.add(
pending_actions.push(Box::new( egui::TextEdit::singleline(&mut self.take_rename_buffer)
lightningbeam_core::actions::SetActiveTakeAction::new( .desired_width(110.0),
layer_id, );
instance_id, edit.request_focus();
i, // Commit on Enter or on clicking away; Escape abandons.
old_take, let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
), let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
)); if enter || edit.lost_focus() && !escape {
let name = self.take_rename_buffer.trim().to_string();
if !name.is_empty() && name != take_names[i] {
pending_actions.push(Box::new(
lightningbeam_core::actions::RenameTakeAction::new(
layer_id, instance_id, i, name,
),
));
}
self.renaming_take = None;
} else if escape {
self.renaming_take = None;
}
return;
} }
close = true;
} let label = ui.selectable_label(is_active, &take_names[i]);
if label.clicked() {
if !is_active {
pending_actions.push(Box::new(
lightningbeam_core::actions::SetActiveTakeAction::new(
layer_id, instance_id, i, old_take,
),
));
}
close = true;
}
// Double-click a take to rename it in place. Deleting lives on the clip's
// right-click menu, not here — a trash icon per row is a small target
// sitting right next to the one you actually meant to click.
if label.double_clicked() {
self.renaming_take = Some((instance_id, i));
self.take_rename_buffer = take_names[i].clone();
}
});
} }
}); });
}); });
@ -1743,6 +1806,7 @@ impl TimelinePane {
} }
if close { if close {
self.open_take_menu = None; self.open_take_menu = None;
self.renaming_take = None;
} }
} }
@ -3971,7 +4035,7 @@ impl TimelinePane {
if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) { if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) {
// Resolve through the instance's active take, so a take folder draws // Resolve through the instance's active take, so a take folder draws
// whichever take it actually plays. // whichever take it actually plays.
match &clip.resolve(clip_instance.active_take) { match &clip_instance.resolve(clip) {
// MIDI: Draw piano roll (with loop iterations) // MIDI: Draw piano roll (with loop iterations)
lightningbeam_core::clip::ResolvedContent::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) {
@ -4357,12 +4421,10 @@ impl TimelinePane {
// only. Records a hit rect so the click that opens the take menu can be // 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 // dispatched after rendering (the usual two-phase pattern), rather than
// mutating the document mid-paint. // mutating the document mid-paint.
if let Some(take_count) = document // Only worth showing when there's actually a choice to make.
.get_audio_clip(&clip_instance.clip_id) if clip_instance.takes.len() > 1 {
.and_then(|c| c.takes().map(|t| t.len())) let take_count = clip_instance.takes.len();
.filter(|n| *n > 0) let active = clip_instance.active_take_index();
{
let active = clip_instance.active_take.unwrap_or(0).min(take_count - 1);
let label = format!("Take {}/{}", active + 1, take_count); let label = format!("Take {}/{}", active + 1, take_count);
let text_color = theme.text_color( let text_color = theme.text_color(
&["#timeline", ".take-badge"], &["#timeline", ".take-badge"],
@ -6596,6 +6658,22 @@ impl PaneRenderer for TimelinePane {
enabled enabled
}; };
// Take management for the clip that was right-clicked. Only offered when there's more
// than one take — with a single take there's nothing to choose between, and "delete the
// only take" would leave the instance with nothing to play.
let take_target: Option<(uuid::Uuid, uuid::Uuid, usize, String)> = ctx_clip_id.and_then(|instance_id| {
let context_layers = document.context_layers(shared.editing_clip_id.as_ref());
for (layer, instances) in all_layer_clip_instances(&context_layers) {
if let Some(ci) = instances.iter().find(|ci| ci.id == instance_id) {
if ci.takes.len() > 1 {
let active = ci.active_take_index();
return Some((layer.id(), instance_id, active, ci.takes[active].name.clone()));
}
}
}
None
});
let area_id = ui.id().with("clip_context_menu"); let area_id = ui.id().with("clip_context_menu");
let mut item_clicked = false; let mut item_clicked = false;
let area_response = egui::Area::new(area_id) let area_response = egui::Area::new(area_id)
@ -6661,6 +6739,34 @@ impl PaneRenderer for TimelinePane {
shared.pending_menu_actions.push(crate::menu::MenuAction::Delete); shared.pending_menu_actions.push(crate::menu::MenuAction::Delete);
item_clicked = true; item_clicked = true;
} }
// Take management, on the clip that was right-clicked. Takes live on the
// INSTANCE, so on a comped split this prunes the half you clicked and leaves
// the other half's alternatives alone.
if let Some((take_layer_id, take_instance_id, active_index, active_name)) = &take_target {
ui.separator();
// Deletes the take that's PLAYING — the one the badge is showing — so
// it's named rather than just "Delete Take".
if menu_item(ui, &format!("Delete \"{}\"", active_name), true) {
shared.pending_actions.push(Box::new(
lightningbeam_core::actions::DeleteTakeAction::new(
*take_layer_id,
*take_instance_id,
*active_index,
),
));
item_clicked = true;
}
if menu_item(ui, "Delete Unused Takes", true) {
shared.pending_actions.push(Box::new(
lightningbeam_core::actions::DeleteUnusedTakesAction::new(
*take_layer_id,
*take_instance_id,
),
));
item_clicked = true;
}
}
}); });
}); });