From 554dc629d5f21386e5e75b8eaeabd224e4f13e1b Mon Sep 17 00:00:00 2001 From: AlphaNeon <102443376+ItsAlphaNeon@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:01:33 -0700 Subject: [PATCH] Playlist Consolidate and Export all playlist tracks render hosted plugins Both rendered with a bare Engine::new on the UI thread: no hosted instruments and no hosted effects, so plugin channels and effect slots came out silent (and Consolidate then muted its source clips). They now run through the export path on the audio thread (App::start_export_of), one track at a time for the track export, so hosted plugins play as in File > Export. Files are 32-bit float WAV at the output device's rate; spans fix the lengths so track files line up. Consolidate still mutes its sources (the spec is silent; see REQUESTS 16). Co-Authored-By: Claude Sonnet 5.5 --- crates/os-app/src/app.rs | 80 +++++++++++++++++- crates/os-app/src/playlist.rs | 153 ++++++++++++++++++++++------------ 2 files changed, 179 insertions(+), 54 deletions(-) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index dcd97d3a..55d01ee0 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -1516,6 +1516,8 @@ pub struct App { /// The running export is PATTERNS > Quick render as audio clip / Render and replace's (spec: ui/pattern-render.md): /// what to do with its file. pub(crate) pattern_render: Option, + /// The playlist's Consolidate or Export all playlist tracks, running as an export (`playlist::Render`). + pub(crate) playlist_render: Option, /// `device::opens` when the hosted plugins last followed the output device's rate (`follow_device`). device_opens: u64, /// The project, tempo and Lemon Studio's own saved state (`lemon_state::saved_form`) as last opened, saved or @@ -1744,6 +1746,7 @@ impl App { export: None, export_reveal: false, pattern_render: None, + playlist_render: None, device_opens: 0, saved: (Project::default(), 0.0, Vec::new()), backup: Default::default(), @@ -1945,6 +1948,11 @@ impl App { return; } let p = crate::rack::play_project(&self.state); + self.start_export_of(job, p, song); + } + + /// `start_export` for the project `p` (a playlist render's own clips) rather than the one being edited. + pub(crate) fn start_export_of(&mut self, job: crate::export::Job, p: os_model::Project, song: bool) { let rate = Transport::device_rate().unwrap_or(44100); let first = job.options.ticked().next().unwrap_or(0); let name = job.file(first).file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned()); @@ -1975,6 +1983,9 @@ impl App { if let Some(p) = self.pattern_render.take() { return self.pattern_render_done(p, r); } + if let Some(p) = self.playlist_render.take() { + return self.playlist_render_done(p, r); + } self.state.message = Some(match r { Ok((secs, files)) => { if self.export_reveal { @@ -19690,6 +19701,72 @@ mod tests { let _ = std::fs::remove_file(&wav); } + /// Follows a playlist render (Consolidate, Export all playlist tracks) as the app's tick does, until it is done. + fn finish_playlist_render(app: &mut App) -> String { + let t0 = std::time::Instant::now(); + while app.export.is_some() || app.playlist_render.is_some() { + assert!(t0.elapsed().as_secs() < 120, "the render did not finish"); + app.export_tick(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.state.message.clone().unwrap_or_default() + } + + /// An app whose rack has a hosted plugin channel (Lemon Trio) playing a note in pattern 1, which one playlist clip + /// on track 0 plays. + fn plugin_clip_app() -> (App, u32) { + use os_model::{Clip, ClipSource}; + let mut app = App::new(None); + app.plugins = crate::plugins::Plugins::default(); + app.plugins.available = crate::plugins::builtin(); + // The export renders at the output device's rate; hosted plugins run at it. + app.plugins.set_rate(Transport::device_rate().unwrap_or(44100)); + app.state.project = test_project(); + let row = add_plugin_channel(&mut app.state.project, crate::osc3::LEMON_TRIO); + let index = app.plugins.index_of(crate::osc3::LEMON_TRIO).unwrap(); + app.plugins.create(row, index).unwrap(); + app.state.hosted_rows.push(row); + app.transport.set_instruments(app.plugins.instruments()); + let ppq = u32::from(app.state.project.ppq); + let ch = app.state.project.channels[row].index; + app.state.project.patterns[0].notes = vec![os_model::Note { length: ppq, ..os_model::Note::step(ch, 0) }]; + crate::playlist::arr_mut(&mut app.state).clips = vec![Clip::new(0, 4 * ppq, 0, ClipSource::Pattern(1))]; + app.state.path = Some(std::env::temp_dir().join(format!("os-plugin-render-{}.flp", std::process::id()))); + (app, ppq) + } + + /// Export all playlist tracks and Consolidate play hosted plugins: a plugin channel's track is not silent in its + /// file, and Consolidate's new clip carries the plugin's sound (it used to render with a bare engine). + #[test] + fn playlist_renders_play_hosted_plugins() { + use crate::playlist as pl; + let loud = |s: &os_engine::sample::Sample| s.left.iter().any(|v| v.abs() > 0.01); + let (mut app, ppq) = plugin_clip_app(); + // The old render: an engine with no hosted instruments leaves the plugin channel silent. + let mut bare = os_engine::Engine::new(&crate::rack::play_project(&app.state), &mut os_engine::SampleLibrary::default(), 44100, PlayMode::Song); + assert!(bare.render_all(512).iter().all(|v| v.abs() < 1e-6), "a bare engine is silent for a plugin channel"); + pl::run(&mut app, pl::Cmd::ExportTracks).unwrap(); + let m = finish_playlist_render(&mut app); + let dir = std::env::temp_dir().join("Rendered").join(format!("os-plugin-render-{} tracks", std::process::id())); + assert!(m.starts_with("Exported 1 track"), "{m}"); + let t1 = os_engine::sample::load(&dir.join("01 Track 1.wav")).expect("track file"); + assert!(loud(&t1), "the plugin channel's track is not silent"); + let _ = std::fs::remove_dir_all(&dir); + // Consolidate: the clip is muted, a new audio clip carries the sound. + app.state.playlist.sel = vec![0]; + pl::run(&mut app, pl::Cmd::Consolidate).unwrap(); + let m = finish_playlist_render(&mut app); + assert!(m.starts_with("Consolidated to"), "{m}"); + let clips = pl::clips(&app.state).to_vec(); + assert_eq!(clips.len(), 2); + assert!(clips[0].muted && !clips[1].muted, "source muted, render audible"); + assert_eq!(clips[1].length, 4 * ppq); + let path = m.trim_start_matches("Consolidated to ").to_string(); + let r = os_engine::sample::load(std::path::Path::new(&path)).expect("render file"); + assert!(loud(&r), "the consolidated file is not silent"); + let _ = std::fs::remove_file(&path); + } + /// Tools menu (spec: ui/playlist.md §10): quick quantize moves clip starts onto the snap grid (one undo step); /// export renders every playing track to its own file, all as long as the song. #[test] @@ -19714,7 +19791,8 @@ mod tests { assert_eq!(pl::clips(&app.state)[0].position, step + 5, "one undo step"); // Export: two files (tracks 1 and 3), the same length, each with sound only where its clip is. app.state.path = Some(std::env::temp_dir().join(format!("os-export-{}.flp", std::process::id()))); - let m = pl::run(&mut app, pl::Cmd::ExportTracks).unwrap(); + pl::run(&mut app, pl::Cmd::ExportTracks).unwrap(); + let m = finish_playlist_render(&mut app); let dir = std::env::temp_dir().join("Rendered").join(format!("os-export-{} tracks", std::process::id())); assert!(m.starts_with("Exported 2 tracks"), "{m}"); let read = |name: &str| os_engine::sample::load(&dir.join(name)).expect(name); diff --git a/crates/os-app/src/playlist.rs b/crates/os-app/src/playlist.rs index 13d35bb7..fc8cc895 100644 --- a/crates/os-app/src/playlist.rs +++ b/crates/os-app/src/playlist.rs @@ -9,6 +9,7 @@ use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::rc::Rc; use os_model::{ @@ -5231,55 +5232,87 @@ pub fn quantize_starts(s: &mut State) -> String { format!("Quick quantize: moved {moved} clip start{}", if moved == 1 { "" } else { "s" }) } +/// A playlist render under way on the audio thread (`App::playlist_render`): what to do with the file(s) once written. +/// Both go through the export's path (`App::start_export_of`), so hosted instruments and effects play. +#[derive(Debug, Clone)] +pub enum Render { + /// Consolidate: the clips `used` (arranger indices) over `a..b` render to a file; they are muted and a clip of it + /// goes on `track` at `a`. + Consolidate { a: u32, b: u32, used: Vec, track: u16 }, + /// Export all playlist tracks: `todo` tracks are still to render, one at a time, from `audible` clips, each file + /// `end` ticks long, into `dir`. + Tracks { todo: Vec, audible: Vec, end: u32, dir: PathBuf, written: usize }, +} + +/// A render's job: one 32-bit float WAV, `name` in `dir`, ticks `0..ticks` exactly (the span fixes the length, so +/// the files of a track export line up), insert and master effects as the engine plays them. +fn render_job(s: &State, dir: &Path, name: &str, ticks: u32) -> crate::export::Job { + let mut j = crate::pattern_render::job(s, dir, name); + j.options.tail = crate::export::Tail::Cut; + j.options.master_fx = true; + j.span = Some((0, u64::from(ticks.max(1)))); + j.tempo = (s.project.tick_tempo(s.tempo), f64::from(ticks) / f64::from(s.project.ppq.max(1))); + j +} + +fn render_dir(s: &State) -> PathBuf { + s.path.as_ref().and_then(|p| p.parent()).map(|d| d.join("Rendered")).unwrap_or_else(|| std::env::temp_dir().join("Lemon Studio").join("Rendered")) +} + /// Tools > Export all playlist tracks (§10, OS): every track with unmuted clips on a playing track is rendered alone, /// from the song's start, into `Rendered/ tracks` next to the project file (the temp folder for a new -/// project): " .wav", 44.1 kHz float, at least as long as the song so the files line up. +/// project): " .wav", float, at least as long as the song so the files line up. Hosted instruments and +/// effects play (the render runs on the audio thread as an export does), one track after another. pub fn export_tracks(app: &mut App) -> String { + if app.export.is_some() { + return "Export tracks: another render is running".into(); + } let s = &app.state; let audible: Vec = clips(s).iter().filter(|c| !c.muted && track_on(s, c.track)).copied().collect(); let Some(end) = audible.iter().map(|c| c.end()).max() else { return "Export tracks: the arranger has no clips to play".into() }; - let mut tracks: Vec = audible.iter().map(|c| c.track).collect(); - tracks.sort_unstable(); - tracks.dedup(); + let mut todo: Vec = audible.iter().map(|c| c.track).collect(); + todo.sort_unstable(); + todo.dedup(); + todo.reverse(); // popped from the end: the first track first let stem = s.path.as_ref().and_then(|p| p.file_stem()).map_or_else(|| "Untitled".to_string(), |n| n.to_string_lossy().into_owned()); - let base = s.path.as_ref().and_then(|p| p.parent()).map(|d| d.join("Rendered")).unwrap_or_else(|| std::env::temp_dir().join("Lemon Studio").join("Rendered")); - let dir = base.join(format!("{stem} tracks")); + let dir = render_dir(s).join(format!("{stem} tracks")); if let Err(e) = std::fs::create_dir_all(&dir) { return format!("Export tracks: {e}"); } - let rate = 44100; - let mut lib = os_engine::SampleLibrary::new(crate::factory_folder::dir()); - let song_frames = os_engine::laws::tick_to_frame(f64::from(end), s.project.ppq, s.project.tick_tempo(s.tempo), rate) as usize; - let mut written = 0; - for &t in &tracks { - let mut p = s.project.clone(); - p.tempo = Some(s.tempo); - if let Some(arr) = p.arrangement_mut() { - arr.clips = audible.iter().filter(|c| c.track == t).copied().collect(); - for tr in &mut arr.tracks { - tr.enabled = true; - } - } - let mut e = os_engine::Engine::new(&p, &mut lib, rate, os_engine::PlayMode::Song); - let mut audio = e.render_all(512); - if audio.len() < song_frames * 2 { - audio.resize(song_frames * 2, 0.0); - } - let name: String = track_name(s, t).chars().map(|c| if r#"<>:"/\|?*"#.contains(c) { '_' } else { c }).collect(); - let file = dir.join(format!("{:02} {name}.wav", t + 1)); - let ok = std::fs::File::create(&file).and_then(|f| { - let mut w = std::io::BufWriter::new(f); - os_engine::wav::write_f32_stereo(&mut w, rate, &audio) - }); - if let Err(e) = ok { - return format!("Export tracks: {}: {e}", file.display()); + app.playlist_render = Some(Render::Tracks { todo, audible, end, dir, written: 0 }); + next_track(app) +} + +/// Starts the next track of an Export all playlist tracks, or ends it. +fn next_track(app: &mut App) -> String { + let Some(Render::Tracks { todo, audible, end, dir, written }) = &mut app.playlist_render else { return String::new() }; + let Some(t) = todo.pop() else { + let m = format!("Exported {written} track{} to {}", if *written == 1 { "" } else { "s" }, dir.display()); + app.playlist_render = None; + return m; + }; + let (end, dir, mine): (u32, PathBuf, Vec) = (*end, dir.clone(), audible.iter().filter(|c| c.track == t).copied().collect()); + let s = &app.state; + let name: String = track_name(s, t).chars().map(|c| if r#"<>:"/\|?*"#.contains(c) { '_' } else { c }).collect(); + let job = render_job(s, &dir, &format!("{:02} {name}", t + 1), end); + let mut p = crate::rack::play_project(s); + if let Some(arr) = p.arrangement_mut() { + arr.clips = mine; + for tr in &mut arr.tracks { + tr.enabled = true; } - written += 1; } - format!("Exported {written} track{} to {}", if written == 1 { "" } else { "s" }, dir.display()) + app.start_export_of(job, p, true); + format!("Exporting track {}...", t + 1) } +/// Tools > Consolidate (§10): the selected clips (or the range's) rendered to one audio clip, with hosted instruments +/// and effects; the source clips are muted and the new clip goes on the first free track. The render runs as an +/// export; `App::playlist_render_done` places the clip. pub fn consolidate(app: &mut App) -> String { + if app.export.is_some() { + return "Consolidate: another render is running".into(); + } let s = &mut app.state; let sel = s.playlist.sel.clone(); let (a, b) = match (s.playlist.range, sel.is_empty()) { @@ -5299,8 +5332,7 @@ pub fn consolidate(app: &mut App) -> String { sel.clone() }; // A project with just those clips, shifted to start at 0 (clips crossing the start are cut there). - let mut p = s.project.clone(); - p.tempo = Some(s.tempo); + let mut p = crate::rack::play_project(s); let mut parts = Vec::new(); for &i in &use_idx { let Some(mut c) = clips(s).get(i).copied() else { continue }; @@ -5321,29 +5353,23 @@ pub fn consolidate(app: &mut App) -> String { t.enabled = true; } } - let rate = 44100; - let mut lib = os_engine::SampleLibrary::new(crate::factory_folder::dir()); - let mut e = os_engine::Engine::new(&p, &mut lib, rate, os_engine::PlayMode::Song); - let mut audio = e.render_all(512); - let want = (os_engine::laws::tick_to_frame(f64::from(b - a), p.ppq, p.tick_tempo(s.tempo), rate) as usize) * 2; - audio.resize(want.max(2), 0.0); - let dir = s.path.as_ref().and_then(|p| p.parent()).map(|d| d.join("Rendered")).unwrap_or_else(|| std::env::temp_dir().join("Lemon Studio").join("Rendered")); + let dir = render_dir(s); if let Err(e) = std::fs::create_dir_all(&dir) { return format!("Consolidate: {e}"); } let n = std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0) + 1; - let file = dir.join(format!("Consolidated {n}.wav")); - let written = std::fs::File::create(&file).and_then(|f| { - let mut w = std::io::BufWriter::new(f); - os_engine::wav::write_f32_stereo(&mut w, rate, &audio) - }); - if let Err(e) = written { - return format!("Consolidate: {e}"); - } - let path = file.to_string_lossy().into_owned(); + let job = render_job(s, &dir, &format!("Consolidated {n}"), b - a); let track = free_track(s, track_after_used(s), a, b); + app.playlist_render = Some(Render::Consolidate { a, b, used: use_idx, track }); + app.start_export_of(job, p, true); + "Consolidating...".into() +} + +/// Places a finished Consolidate's file: the source clips muted and a clip of the render on `track`, one undo step. +fn consolidate_place(s: &mut State, a: u32, b: u32, used: &[usize], track: u16, file: &Path) -> String { + let path = file.to_string_lossy().into_owned(); snapshot(s); - for &i in &use_idx { + for &i in used { if let Some(c) = arr_mut(s).clips.get_mut(i) { c.muted = true; } @@ -5361,6 +5387,27 @@ pub fn consolidate(app: &mut App) -> String { } } +impl App { + /// A playlist render's export has finished (`App::export_tick`): Consolidate places its clip; Export all playlist + /// tracks goes on to the next track. + pub(crate) fn playlist_render_done(&mut self, r: Render, result: Result<(f64, Vec), String>) { + let msg = match (r, result) { + (Render::Consolidate { .. }, Err(e)) => format!("Consolidate: {e}"), + (Render::Tracks { .. }, Err(e)) => format!("Export tracks: {e}"), + (Render::Consolidate { a, b, used, track }, Ok((_, files))) => match files.first() { + Some(f) => consolidate_place(&mut self.state, a, b, &used, track, f), + None => "Consolidate: no file was written".into(), + }, + (Render::Tracks { todo, audible, end, dir, written }, Ok(_)) => { + self.playlist_render = Some(Render::Tracks { todo, audible, end, dir, written: written + 1 }); + next_track(self) + } + }; + self.state.message = Some(msg); + self.invalidate(); + } +} + /// Keys while the playlist is focused. Returns true when handled. pub fn key(app: &mut App, key: &winit::keyboard::Key, ctrl: bool, alt: bool) -> bool { use winit::keyboard::{Key, NamedKey};