Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion crates/os-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::pattern_render::Pending>,
/// The playlist's Consolidate or Export all playlist tracks, running as an export (`playlist::Render`).
pub(crate) playlist_render: Option<crate::playlist::Render>,
/// `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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand All @@ -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);
Expand Down
153 changes: 100 additions & 53 deletions crates/os-app/src/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<usize>, 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<u16>, audible: Vec<Clip>, 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/<project> tracks` next to the project file (the temp folder for a new
/// project): "<NN> <track name>.wav", 44.1 kHz float, at least as long as the song so the files line up.
/// project): "<NN> <track name>.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<Clip> = 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<u16> = audible.iter().map(|c| c.track).collect();
tracks.sort_unstable();
tracks.dedup();
let mut todo: Vec<u16> = 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<Clip>) = (*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()) {
Expand All @@ -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 };
Expand All @@ -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;
}
Expand All @@ -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<PathBuf>), 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};
Expand Down
Loading