From fd9b665dbf00f6e66305776c0fb3134f96b36f17 Mon Sep 17 00:00:00 2001 From: peakflyer <69652960+beneaze@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:29:40 +0200 Subject: [PATCH 1/3] Stem curves play with real stems; transition editor details from DJ.Studio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stem curves had no audible effect: when a library stem separation finished, the app reloaded the track's analysis (so the editor showed stems) but kept the engine's cached copy of the track, loaded without stems. Mix playback kept using that copy and only imitated the curves with volume and EQ. TrackUpdated now drops the cached track, and redraws the open mix's timeline (a new grid moves its beats and tempo lane). A stem file whose length doesn't fit the audio is logged instead of dropped silently. New engine test: a transition lane mutes each stem on both sides. Transition editor, from screenshots of DJ.Studio and its help pages (docs/interop/djstudio.md §5.4): - Stem rows: the stem's colour fills the area under its gain curve; the waveform stays full size, grey over hatching where the stem is cut. - Stem on/off toggles sit outside the window, the outgoing track's at its end and the incoming track's at its start. - Equalizer category holds Volume, Low, Mid and High; every column has a "Manual" entry (selected for hand-drawn curves, opens drawing) and "None" names the no-change mode outside the stems. - Lane knobs show their value; each track's unplayed audio is drawn faded beside its clip. - Column headers in full stem / band colours, the chosen mode filled and outlined in them. - Entering a transition with the playhead selects it and opens its settings. Devctl gains `tcat ` for screenshots of the editor. Co-Authored-By: Claude Opus 5.5 --- crates/app/src/app.rs | 18 ++++- crates/app/src/live/adapter.rs | 22 +++--- crates/app/src/studio.rs | 21 +++++- crates/app/src/studio_timeline.rs | 103 +++++++++++++++++++++++++--- crates/app/src/studio_transition.rs | 73 ++++++++++++++++---- crates/app/src/theme.rs | 5 ++ crates/app/src/widgets.rs | 23 ++++++- crates/engine/tests/engine.rs | 28 ++++++++ 8 files changed, 259 insertions(+), 34 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 18fe343..8fb4f6c 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -161,7 +161,16 @@ impl App { for e in events { match e { Event::LibraryChanged => reload_all = true, - Event::TrackUpdated(id) => self.sh.reload_track(&self.lib, id), + Event::TrackUpdated(id) => { + self.sh.reload_track(&self.lib, id); + // the cached audio may predate the new stems: a mix would keep playing the + // stem-less copy and only imitate its stem curves + self.eng.cache.invalidate(id); + // a new grid moves the open mix's beats and tempo lane + if self.studio.mix.tracks.iter().any(|t| t.track_id == id) { + self.studio.timeline_dirty = true; + } + } Event::Error { message, .. } => self.status = message, _ => {} } @@ -756,6 +765,13 @@ impl App { "undo" => self.studio.undo(), "redo" => self.studio.redo(), "harmonize" => self.studio.harmonize(&self.sh), + "tcat" => { + // `tcat `: transition editor category (tests) + use crate::studio_transition::TCategory; + if let Some(c) = TCategory::ALL.into_iter().find(|c| format!("{c:?}").eq_ignore_ascii_case(arg.trim())) { + self.studio.tcat = c; + } + } "tab" => { use crate::studio::Tab; if let Some(t) = Tab::ALL.into_iter().find(|t| format!("{t:?}").eq_ignore_ascii_case(arg.trim())) { diff --git a/crates/app/src/live/adapter.rs b/crates/app/src/live/adapter.rs index 398f717..367725c 100644 --- a/crates/app/src/live/adapter.rs +++ b/crates/app/src/live/adapter.rs @@ -100,15 +100,21 @@ impl TrackCache { let sp = stems_path(&t.hash); let stems = match clubcraft_stems::load_stem_track(&sp) { Ok(st) if st.frames.abs_diff(audio.frames()) < 4096 => Some(Arc::new(st)), - _ => match live_sep { - Some(sep) => { - let st = Arc::new(StemTrack::new(audio.frames(), "htdemucs")); - let start = analysis.cue(clubcraft_core::CueKind::FirstBeat).map(|c| c.pos).unwrap_or(0.0); - sep.submit(id, t.hash.clone(), audio.clone(), st.clone(), (start * clubcraft_core::SAMPLE_RATE as f64) as usize); - Some(st) + other => { + // the UI shows stems for this track: say why they don't play + if let Ok(st) = &other { + log::warn!("track {id}: stem file has {} frames, the audio {}; ignored", st.frames, audio.frames()); } - None => None, - }, + match live_sep { + Some(sep) => { + let st = Arc::new(StemTrack::new(audio.frames(), "htdemucs")); + let start = analysis.cue(clubcraft_core::CueKind::FirstBeat).map(|c| c.pos).unwrap_or(0.0); + sep.submit(id, t.hash.clone(), audio.clone(), st.clone(), (start * clubcraft_core::SAMPLE_RATE as f64) as usize); + Some(st) + } + None => None, + } + } }; let lt = LoadedTrack::from_parts(&t, &analysis, stems, audio).with_user_cues(&lib.cues(id)?); let lt = Arc::new(lt); diff --git a/crates/app/src/studio.rs b/crates/app/src/studio.rs index 4bf0a3c..7abbc82 100644 --- a/crates/app/src/studio.rs +++ b/crates/app/src/studio.rs @@ -72,6 +72,8 @@ pub struct StudioView { /// A mashup preview is playing (set by the app). pub preview_playing: bool, last_selection: Selection, + /// The transition the playhead was in on the last frame. + playhead_transition: Option, /// Open panel of the bottom bar. pub tab: Option, /// Undo / redo history of the mix (one step per edit gesture). @@ -141,6 +143,7 @@ impl Default for StudioView { mashup_actions: Vec::new(), preview_playing: false, last_selection: Selection::None, + playhead_transition: None, tab: Some(Tab::Playlist), undo: Vec::new(), redo: Vec::new(), @@ -352,6 +355,7 @@ impl StudioView { if self.timeline_dirty { self.refresh_timeline(sh); } + self.follow_playhead_into_transitions(ui.ctx()); self.keyboard(ui.ctx(), sh, out); self.run_extractions(sh); // the finder was opened from elsewhere (library, menu, inspector button) @@ -372,7 +376,7 @@ impl StudioView { Tab::Playlist => (100.0, 90.0), Tab::Master => (90.0, 80.0), Tab::Track => (330.0, 220.0), - Tab::Transition => (215.0, 130.0), + Tab::Transition => (260.0, 230.0), Tab::Mashups => (430.0, 240.0), }; egui::Panel::bottom(format!("studio-tab2-{tab:?}")) @@ -799,6 +803,21 @@ impl StudioView { (n > 0).then(|| self.timeline.tracks.iter().rposition(|t| t.start_sec <= self.position + 1e-6).unwrap_or(0).min(n - 1)) } + /// When the playhead enters a transition (playing or seeking), select it, which opens its + /// settings. Only on entering: inside, the user may close the panel or select something else. + /// Not while a mouse button is held, so a drag or curve edit keeps its selection. + fn follow_playhead_into_transitions(&mut self, ctx: &egui::Context) { + let pos = self.position; + let now = self.timeline.transitions.iter().position(|t| t.start_sec <= pos && pos < t.end_sec); + if now == self.playhead_transition || ctx.input(|i| i.pointer.any_down()) { + return; + } + self.playhead_transition = now; + if let Some(k) = now.filter(|&k| k + 1 < self.mix.tracks.len()) { + self.selection = Selection::Transition(k); + } + } + fn transition_at_playhead(&self) -> Option { let n = self.timeline.transitions.len(); (n > 0).then(|| self.timeline.transitions.iter().position(|t| t.end_sec >= self.position).unwrap_or(n - 1)) diff --git a/crates/app/src/studio_timeline.rs b/crates/app/src/studio_timeline.rs index c818f75..aa55429 100644 --- a/crates/app/src/studio_timeline.rs +++ b/crates/app/src/studio_timeline.rs @@ -35,6 +35,9 @@ impl ShowMode { /// What the timeline draws and edits inside the selected transition (like DJ.Studio's /// automation choice): one row per stem, or one curve for the volume, an EQ band or the filter. +/// How much of a track's unplayed audio is shown beside its clip (s). +const GHOST_SECS: f64 = 16.0; + #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub enum CurveTarget { #[default] @@ -58,7 +61,7 @@ impl CurveTarget { CurveTarget::Filter => "Filter", } } - fn param(self) -> Option { + pub(crate) fn param(self) -> Option { match self { CurveTarget::Stems => None, CurveTarget::Volume => Some(Param::Volume), @@ -322,6 +325,7 @@ impl StudioView { if r.right() < canvas.left() || r.left() > canvas.right() { continue; } + self.draw_ghost(&p, r, i, sh, &x_of); self.draw_clip(&p, r, i, sh, &x_of); // selected range (yellow, like DJ.Studio) if let Some(rs) = self.range.filter(|rs| rs.track == i) { @@ -777,9 +781,13 @@ impl StudioView { set_lane(&mut self.mix.transitions[k], side, Param::Stem(stem), pts); changed = true; } - // on / off per stem and side (DJ.Studio's stem toggles) - let b = Rect::from_min_size(pos2(area.left() + 4.0, rr.center().y - 8.0), vec2(20.0, 16.0)); - if b.right() < area.right() && rr.height() >= 12.0 { + // on / off per stem and side (DJ.Studio's stem toggles), outside the window + // at the track's own edge: the outgoing track's at the end, the incoming + // track's at the start (see docs/interop/djstudio.md §5.4), so they cover + // no waveform. Pulled inside when that edge is off screen. + let bx = if side == Side::Out { (area.right() + 4.0).min(canvas.right() - 22.0) } else { (area.left() - 24.0).max(canvas.left() + 2.0) }; + let b = Rect::from_min_size(pos2(bx, rr.center().y - 8.0), vec2(20.0, 16.0)); + if area.width() > 24.0 && rr.height() >= 12.0 { let br = ui.interact(b, Id::new(("ttoggle", k, side == Side::In, row)), Sense::click()); painter.rect_filled(b, 4.0, if off { theme::PANEL } else { col }); painter.rect_stroke(b, 4.0, Stroke::new(1.0, col), StrokeKind::Inside); @@ -986,9 +994,9 @@ impl StudioView { ui.spacing_mut().item_spacing.x = 6.0; for (b, name) in [(2usize, "High"), (1, "Mid"), (0, "Low")] { let col = if ls.eq[b] < 0.02 { Color32::from_rgb(255, 70, 70) } else { EQ_COLORS[b] }; - widgets::knob(ui, &mut ls.eq[b], 0.0..=1.0, 0.5, name, col, 26.0); + widgets::knob_with_value(ui, &mut ls.eq[b], 0.0..=1.0, 0.5, name, col, 30.0, &eq_db_text); } - widgets::knob(ui, &mut ls.filter, 0.0..=1.0, 0.5, "Filter", FILTER_COLOR, 26.0); + widgets::knob_with_value(ui, &mut ls.filter, 0.0..=1.0, 0.5, "Filter", FILTER_COLOR, 30.0, &|v| format!("{:.0}", (v - 0.5) * 200.0)); }); } if r.height() >= 128.0 { @@ -1010,6 +1018,44 @@ impl StudioView { false } + /// The audio of track `i` that doesn't play, faded out beside its clip `r`: what comes before + /// its mix-in and after its end, as far as the file goes (at most [`GHOST_SECS`]). It shows + /// where a mix point could move to. + fn draw_ghost(&self, p: &egui::Painter, r: Rect, i: usize, sh: &Shared, x_of: &dyn Fn(f64) -> f32) { + let tt = &self.timeline.tracks[i]; + let (Some(wf), Some(a)) = (sh.waveforms.get(&tt.track_id), sh.analyses.get(&tt.track_id)) else { return }; + if r.height() < 40.0 || !self.mix.lane_audible(i) { + return; + } + let (p0, p1) = (self.timeline.track_pos(i, tt.start_beat), self.timeline.track_pos(i, tt.end_beat)); + if p1 <= p0 { + return; + } + // mix seconds per track second at the clip's edges (tempo changes inside are ignored) + let rate = (tt.end_sec - tt.start_sec) / (p1 - p0); + let wave = clip_wave_rect(r); + // only in the gap between the clips before and after it on the same lane + let lo = i.checked_sub(MIX_LANES).and_then(|j| self.timeline.tracks.get(j)).map_or(f32::NEG_INFINITY, |t| x_of(t.end_sec)); + let hi = self.timeline.tracks.get(i + MIX_LANES).map_or(f32::INFINITY, |t| x_of(t.start_sec)); + let gap = Rect::from_x_y_ranges(lo.max(p.clip_rect().left())..=hi.min(p.clip_rect().right()), wave.y_range()); + if gap.width() <= 0.0 { + return; + } + let mut ghost = p.with_clip_rect(gap); + ghost.multiply_opacity(0.22); + let wp = WaveParams { wf, grid: a.grid, cues: &[], sections: &[], style: WaveStyle::Stems, stem_gains: [1.0; 4], loop_region: None, accent: theme::deck_color(i % MIX_LANES), volume: 1.0 }; + let before = (p0 - GHOST_SECS).max(0.0); + if p0 > before { + let x0 = x_of(tt.start_sec - (p0 - before) * rate); + waveform::paint_wave(&ghost, Rect::from_x_y_ranges(x0..=r.left(), wave.y_range()), &wp, before, p0, true); + } + let after = (p1 + GHOST_SECS).min(a.duration); + if after > p1 { + let x1 = x_of(tt.end_sec + (after - p1) * rate); + waveform::paint_wave(&ghost, Rect::from_x_y_ranges(r.right()..=x1, wave.y_range()), &wp, p1, after, true); + } + } + fn draw_clip(&self, p: &egui::Painter, r: Rect, i: usize, sh: &Shared, x_of: &dyn Fn(f64) -> f32) { let tt = &self.timeline.tracks[i]; let l = i % MIX_LANES; @@ -1205,26 +1251,52 @@ impl StudioView { let beat_at_x = |x: f32| self.timeline.beat_at_sec(((x - origin) / self.zoom) as f64); let order = [StemKind::Drums, StemKind::Bass, StemKind::Other, StemKind::Vocals]; let rh = rect.height() / 4.0; - let mut mesh = egui::Mesh::default(); + // Each row shows the whole stem: its colour fills the area under the gain curve, the + // waveform stays at full size (dark where the stem plays, grey over hatching where the + // transition turns it down), so a cut stem can still be seen and placed. + let mut fill = egui::Mesh::default(); + let mut wave = egui::Mesh::default(); for (row, k) in order.into_iter().enumerate() { let r = Rect::from_min_size(pos2(rect.left(), rect.top() + row as f32 * rh), vec2(rect.width(), rh - 1.0)); let col = theme::stem_color(k); - p.rect_filled(r.intersect(clip), 0.0, theme::blend(theme::PANEL, col, 0.2)); + let rc = r.intersect(clip); + p.rect_filled(rc, 0.0, theme::blend(theme::PANEL, col, 0.12)); + let hatch = p.with_clip_rect(rc); + let mut hx = rc.left() - rc.height() - (rc.left() - origin).rem_euclid(7.0); + while hx < rc.right() { + hatch.line_segment([pos2(hx, rc.bottom()), pos2(hx + rc.height(), rc.top())], Stroke::new(1.0, theme::STEM_HATCH)); + hx += 7.0; + } + // the same plot area as the row's curve (see `edit_transition_curves`) + let (plot_top, plot_bottom) = (r.top() + 3.0, r.bottom() - 3.0); let data = &stems[k.index()]; let mut x = clip.left().floor(); while x < clip.right() { let b = beat_at_x(x + 0.5); let g = (tr.value(side, Param::Stem(k), (b - start) as f32) * tr.value(side, Param::Volume, (b - start) as f32)).clamp(0.0, 1.0); + let level = plot_bottom - g * (plot_bottom - plot_top); + if g > 0.0 { + fill.add_colored_rect(Rect::from_min_max(pos2(x, level), pos2(x + 1.0, r.bottom())), col); + } let pos = self.timeline.track_pos(i, b); let bin = ((pos * wf.rate) as usize).min(data.len().saturating_sub(1)); - let v = data.get(bin).map(|&v| v as f32 / 255.0).unwrap_or(0.0) * g; + let v = data.get(bin).map(|&v| v as f32 / 255.0).unwrap_or(0.0); let hh = v * (rh * 0.5 - 2.0); - mesh.add_colored_rect(Rect::from_min_max(pos2(x, r.center().y - hh), pos2(x + 1.0, r.center().y + hh)), col); + let (w0, w1) = (r.center().y - hh, r.center().y + hh); + // split the bar where it crosses the top of the coloured area + let cut = level.clamp(w0, w1); + if cut > w0 { + wave.add_colored_rect(Rect::from_min_max(pos2(x, w0), pos2(x + 1.0, cut)), theme::STEM_WAVE_OFF); + } + if w1 > cut { + wave.add_colored_rect(Rect::from_min_max(pos2(x, cut), pos2(x + 1.0, w1)), theme::STEM_WAVE_ON); + } x += 1.0; } } // (each row's level line is drawn, and edited, by `edit_transition_curves`) - p.add(egui::Shape::mesh(mesh)); + p.add(egui::Shape::mesh(fill)); + p.add(egui::Shape::mesh(wave)); } /// Header of sample lane `l`: name, fader, solo / mute, add a file. @@ -1530,3 +1602,12 @@ fn set_lane(t: &mut Transition, side: Side, param: Param, points: Vec<(f32, f32) None => t.lanes.push(clubcraft_core::Lane { side, param, points }), } } + +/// Lane EQ knob as dB on the mixer's curve; "kill" at the bottom. +fn eq_db_text(v: f32) -> String { + if v < 0.02 { + return "kill".into(); + } + let db = 20.0 * clubcraft_engine::dsp::eq_knob_gain(v).log10(); + format!("{:.1}", if db.abs() < 0.05 { 0.0 } else { db }) +} diff --git a/crates/app/src/studio_transition.rs b/crates/app/src/studio_transition.rs index f9bd56f..c9a3f37 100644 --- a/crates/app/src/studio_transition.rs +++ b/crates/app/src/studio_transition.rs @@ -45,19 +45,18 @@ impl ParamGroup { pub enum TCategory { #[default] Stems, + /// Volume and the three EQ bands side by side (as DJ.Studio's Equalizer category). Equalizer, - Volume, Effects, Curves, } impl TCategory { - const ALL: [TCategory; 5] = [TCategory::Stems, TCategory::Equalizer, TCategory::Volume, TCategory::Effects, TCategory::Curves]; + pub const ALL: [TCategory; 4] = [TCategory::Stems, TCategory::Equalizer, TCategory::Effects, TCategory::Curves]; fn label(self) -> &'static str { match self { TCategory::Stems => "Stems", TCategory::Equalizer => "Equalizer", - TCategory::Volume => "Volume", TCategory::Effects => "Effects", TCategory::Curves => "Curves (draw)", } @@ -68,8 +67,7 @@ impl TCategory { fn columns(self) -> Vec<(Param, &'static str, Color32)> { match self { TCategory::Stems => [StemKind::Drums, StemKind::Bass, StemKind::Other, StemKind::Vocals].into_iter().map(|k| (Param::Stem(k), k.label(), theme::stem_color(k))).collect(), - TCategory::Equalizer => vec![(Param::EqLow, "Low", EQ_COLORS[0]), (Param::EqMid, "Mid", EQ_COLORS[1]), (Param::EqHigh, "High", EQ_COLORS[2])], - TCategory::Volume => vec![(Param::Volume, "Volume", Color32::from_gray(220))], + TCategory::Equalizer => vec![(Param::Volume, "Volume", Color32::from_gray(220)), (Param::EqLow, "Low", EQ_COLORS[0]), (Param::EqMid, "Mid", EQ_COLORS[1]), (Param::EqHigh, "High", EQ_COLORS[2])], TCategory::Effects => vec![(Param::Filter, "Filter", FILTER_COLOR), (Param::Echo, "Echo", Color32::from_rgb(255, 160, 90))], TCategory::Curves => vec![], } @@ -93,6 +91,19 @@ fn mode_help(p: Param, m: Mode) -> &'static str { } } +/// Mode name in its column: for stems "Off" silences the stem, for every other parameter it +/// leaves the parameter alone, so there it is called "None". +fn mode_label(p: Param, m: Mode) -> &'static str { + match (p, m) { + (Param::Stem(_), _) => m.label(), + (_, Mode::Off) => "None", + _ => m.label(), + } +} + +/// Height of a mode entry: five modes and "Manual" fit the editor's default height. +const MODE_H: f32 = 22.0; + /// A titled column in the transition editor. fn column(ui: &mut Ui, w: f32, h: f32, title: &str, add: impl FnOnce(&mut Ui)) { egui::Frame::new().fill(theme::PANEL_HI).corner_radius(6.0).inner_margin(8.0).show(ui, |ui| { @@ -109,14 +120,15 @@ fn column(ui: &mut Ui, w: f32, h: f32, title: &str, add: impl FnOnce(&mut Ui)) { /// A choice column with a coloured header (one stem / band). fn mode_column(ui: &mut Ui, title: &str, col: Color32, h: f32, add: impl FnOnce(&mut Ui)) { - egui::Frame::new().fill(theme::PANEL_HI).corner_radius(6.0).show(ui, |ui| { + egui::Frame::new().fill(theme::blend(theme::PANEL_HI, col, 0.07)).corner_radius(6.0).show(ui, |ui| { ui.set_width(144.0); ui.set_min_height(h); ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 3.0; + ui.spacing_mut().item_spacing.y = 2.0; let (r, _) = ui.allocate_exact_size(vec2(144.0, 28.0), Sense::hover()); - ui.painter().rect_filled(r, egui::CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, theme::blend(theme::PANEL, col, 0.65)); - ui.painter().text(r.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(14.0), Color32::WHITE); + // the stem's / band's own colour at full strength, so the columns read at a glance + ui.painter().rect_filled(r, egui::CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, col); + ui.painter().text(r.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(14.0), header_text(col)); ui.add_space(4.0); ui.horizontal(|ui| { ui.add_space(8.0); @@ -126,6 +138,26 @@ fn mode_column(ui: &mut Ui, title: &str, col: Color32, h: f32, add: impl FnOnce( }); } +/// Dark text on light header colours, white on dark ones. +fn header_text(bg: Color32) -> Color32 { + let l = 0.299 * bg.r() as f32 + 0.587 * bg.g() as f32 + 0.114 * bg.b() as f32; + if l > 150.0 { + Color32::BLACK + } else { + Color32::WHITE + } +} + +/// One mode entry: the chosen one is filled with the column's colour and outlined in it. +fn mode_button(ui: &mut Ui, selected: bool, label: &str, col: Color32) -> egui::Response { + let b = if selected { + egui::Button::new(RichText::new(label).color(Color32::WHITE).strong()).fill(theme::blend(theme::PANEL_HI, col, 0.45)).stroke(Stroke::new(1.5, col)) + } else { + egui::Button::new(label).fill(Color32::TRANSPARENT).stroke(Stroke::NONE) + }; + ui.add_sized([128.0, MODE_H], b) +} + fn style_help(s: TransitionStyle) -> &'static str { match s { TransitionStyle::StemBlend => "Incoming drums first, outgoing vocals leave early, bass swapped halfway, melody crossfades.", @@ -175,6 +207,7 @@ impl StudioView { let n_tr = self.mix.transitions.len(); let locked = self.mix.transitions[k].locked; let mut changed = false; + let mut manual: Option = None; // ------------------------------------------------------------ header ui.horizontal(|ui| { @@ -339,13 +372,15 @@ impl StudioView { let cur = tr.mode(param); mode_column(ui, name, col, h, |ui| { for &m in Mode::for_param(param) { - if ui.add_sized([128.0, 26.0], egui::Button::selectable(cur == Some(m), m.label())).on_hover_text(mode_help(param, m)).clicked() && cur != Some(m) { + if mode_button(ui, cur == Some(m), mode_label(param, m), col).on_hover_text(mode_help(param, m)).clicked() && cur != Some(m) { tr.set_mode(param, m); changed = true; } } - if cur.is_none() { - ui.label(RichText::new("preset curve").size(10.5).color(theme::TEXT_DIM)).on_hover_text("This parameter follows the preset's own curve (see Curves)"); + // a curve none of the modes describe (a preset's own or a + // hand-drawn one) is "Manual"; choosing it draws that curve + if mode_button(ui, cur.is_none(), "Manual", col).on_hover_text("Draw this curve yourself in the timeline").clicked() { + manual = Some(param); } }); } @@ -354,6 +389,20 @@ impl StudioView { }); }); }); + if let Some(p) = manual { + // draw it in the timeline where it has a curve row there, else in the curve editor + let target = match p { + Param::Stem(_) => Some(crate::studio_timeline::CurveTarget::Stems), + p => crate::studio_timeline::CurveTarget::ALL.into_iter().find(|t| t.param() == Some(p)), + }; + match target { + Some(t) => self.curve_target = t, + None => { + self.tcat = TCategory::Curves; + self.tgroup = ParamGroup::FilterFx; + } + } + } if changed { self.changed(); } diff --git a/crates/app/src/theme.rs b/crates/app/src/theme.rs index 3e8d7d4..c79ed1e 100644 --- a/crates/app/src/theme.rs +++ b/crates/app/src/theme.rs @@ -19,6 +19,11 @@ pub const TEXT_DIM: Color32 = Color32::from_rgb(130, 136, 148); pub const SELECT: Color32 = Color32::from_rgb(66, 71, 83); pub const OK: Color32 = Color32::from_rgb(90, 220, 130); pub const WARN: Color32 = Color32::from_rgb(255, 190, 60); +/// Stem rows of a selected transition: the waveform over the stem's colour where it plays, the +/// waveform and the hatching where the transition turns it down. +pub const STEM_WAVE_ON: Color32 = Color32::from_black_alpha(175); +pub const STEM_WAVE_OFF: Color32 = Color32::from_rgba_premultiplied(70, 70, 70, 70); +pub const STEM_HATCH: Color32 = Color32::from_rgba_premultiplied(16, 16, 16, 16); /// Per-deck accent colours (A, B, C, D). pub const DECK: [Color32; 4] = [ diff --git a/crates/app/src/widgets.rs b/crates/app/src/widgets.rs index 7729edf..748fab5 100644 --- a/crates/app/src/widgets.rs +++ b/crates/app/src/widgets.rs @@ -6,6 +6,18 @@ use eframe::egui::{self, pos2, vec2, Color32, Rect, Response, Sense, Stroke, Str /// Rotary knob. `value` in `range`; double-click resets to `default`. Vertical drag adjusts /// (hold shift for fine control). Returns the response (`changed()` when moved). pub fn knob(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, default: f32, label: &str, color: Color32, size: f32) -> Response { + knob_impl(ui, value, range, default, label, color, size, None) +} + +/// [`knob`] that shows its value (`text` of the current value) in the middle; the pointer is a +/// short tick on the rim. +#[allow(clippy::too_many_arguments)] +pub fn knob_with_value(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, default: f32, label: &str, color: Color32, size: f32, text: &dyn Fn(f32) -> String) -> Response { + knob_impl(ui, value, range, default, label, color, size, Some(text)) +} + +#[allow(clippy::too_many_arguments)] +fn knob_impl(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, default: f32, label: &str, color: Color32, size: f32, text: Option<&dyn Fn(f32) -> String>) -> Response { let (rect, mut resp) = ui.allocate_exact_size(vec2(size, size + 13.0), Sense::click_and_drag()); let (lo, hi) = (*range.start(), *range.end()); if resp.dragged() { @@ -46,7 +58,16 @@ pub fn knob(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, p.add(egui::Shape::line(pts, Stroke::new(3.0, color))); } let a = a0 + sweep * t; - p.line_segment([c + vec2(a.cos(), a.sin()) * (r * 0.25), c + vec2(a.cos(), a.sin()) * (r - 4.0)], Stroke::new(2.0, theme::TEXT)); + let dir = vec2(a.cos(), a.sin()); + match text { + Some(text) => { + p.line_segment([c + dir * (r - 7.0), c + dir * (r - 3.0)], Stroke::new(2.0, theme::TEXT)); + p.text(c, egui::Align2::CENTER_CENTER, text(*value), egui::FontId::proportional((size * 0.3).min(11.0)), theme::TEXT); + } + None => { + p.line_segment([c + dir * (r * 0.25), c + dir * (r - 4.0)], Stroke::new(2.0, theme::TEXT)); + } + } p.text(pos2(rect.center().x, rect.bottom() - 6.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(10.0), theme::TEXT_DIM); resp } diff --git a/crates/engine/tests/engine.rs b/crates/engine/tests/engine.rs index 413334b..dea9569 100644 --- a/crates/engine/tests/engine.rs +++ b/crates/engine/tests/engine.rs @@ -931,3 +931,31 @@ fn incoming_track_starts_at_its_automation_not_full_level() { eprintln!("vocals switch on {late_ms:+.1} ms from the beat"); assert!(late_ms.abs() < 8.0, "switched {late_ms} ms off the beat"); } + +#[test] +fn transition_stem_lanes_mute_each_stem_on_both_sides() { + // each track's audio lives in one stem; a transition lane holding that stem at 0 must + // silence it for the whole window, on the outgoing and on the incoming side + let n = SR as usize * 60; + let tone: Vec = (0..n).map(|i| 0.5 * (i as f32 * 440.0 * std::f32::consts::TAU / SR as f32).sin()).collect(); + let z = vec![0f32; n]; + for (k, st) in StemKind::ALL.iter().enumerate() { + for side in [clubcraft_core::Side::Out, clubcraft_core::Side::In] { + let mut stems: [(Vec, Vec); 4] = [(z.clone(), z.clone()), (z.clone(), z.clone()), (z.clone(), z.clone()), (z.clone(), z.clone())]; + stems[k] = (tone.clone(), tone.clone()); + let loud = Arc::new(LoadedTrack::new("x", mono(&tone)).with_grid(BeatGrid::new(120.0, 0.0)).with_stems(Arc::new(StemTrack::from_planar("x", &stems)))); + let quiet = track(&z, 126.0, 0.0); + let mut mix = two_track_mix(TransitionStyle::Crossfade, true); + let mut t = Transition::new(TransitionStyle::Custom, 16); + t.lanes = vec![clubcraft_core::Lane::new(side, clubcraft_core::Param::Stem(*st), &[(0.0, 0.0), (16.0, 0.0)])]; + mix.transitions[0] = t; + let tr = |id: i64| Some(if (id == 1) == (side == clubcraft_core::Side::Out) { loud.clone() } else { quiet.clone() }); + let opts = RenderOptions { limiter: false, tail_secs: 0.5, ..Default::default() }; + let (r, rep) = render_mix_to_vec(&mix, &|id| tr(id).map(|t| t.analysis()), &tr, &opts).unwrap(); + let x = &rep.timeline.transitions[0]; + let peak = |t0: f64, t1: f64| r[(t0 * SR as f64) as usize * 2..(t1 * SR as f64) as usize * 2].iter().fold(0f32, |m, v| m.max(v.abs())); + let inside = peak(x.start_sec + 0.5, x.end_sec - 0.5); + assert!(inside < 1e-3, "{st:?} on the {side:?} side still plays at {inside} inside the transition"); + } + } +} From de8bdc4c95d9f1b679443a10b72eda1681a8ffa1 Mon Sep 17 00:00:00 2001 From: peakflyer <69652960+beneaze@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:37:39 +0200 Subject: [PATCH 2/3] Stems for transitions in seconds, savepoints, duplicate, settings in the app's look Stem separation for mix playback covers only the parts stem automation plays in (each stem transition's window and a track's own stem lanes, plus 3 s), at the Fast quality: about 5 s per transition instead of ~2.5 min per track at Best. They are separated automatically when a mix needs them, whatever the background setting, kept in a partial stem file (.regions.stems.flac, listing its chunks in CLUBCRAFT_CHUNKS) and replaced by complete stems at the export quality before a render. The transition warning, "Stems missing" and the imitation preview count those parts as stems. - Savepoints: keep the mix under a name, go back or forward to any of them; none is deleted on the way, and an unsaved state is kept first. Stored in the library (mix_savepoints). Duplicate this mix in the Mixes menu. - Opening another mix stops the preview of the previous one. - Transport: the play button is centred; the groups never overlap and use compact labels when the window is narrow. - Settings is a dialog in the app's look (panel, header, sidebar, segmented choices, checkboxes); Home's Go live / Open library no longer flicker on hover. - Devctl: duplicate, savepoint, goto-savepoint; mix prefers an exact name. Co-Authored-By: Claude Opus 5.5 --- crates/app/src/app.rs | 187 ++++++++++++++++++++++- crates/app/src/home.rs | 27 ++-- crates/app/src/jobs.rs | 24 +++ crates/app/src/live/adapter.rs | 29 +++- crates/app/src/pipeline.rs | 77 ++++++++++ crates/app/src/settings.rs | 245 +++++++++++++++++++++--------- crates/app/src/studio.rs | 143 ++++++++++++++--- crates/app/src/studio_timeline.rs | 72 ++++++++- crates/core/src/library.rs | 93 ++++++++++++ crates/stems/src/cache.rs | 103 +++++++++++-- crates/stems/src/lib.rs | 2 +- 11 files changed, 880 insertions(+), 122 deletions(-) diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 8fb4f6c..a0cdbe2 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -28,6 +28,34 @@ pub struct Shared { pub waveforms: HashMap>, pub user_cues: HashMap>, pub deck_count: usize, + /// Parts of tracks without complete stems that have stems for mix playback (track seconds, + /// see `pipeline::separate_regions`). + pub stem_regions: HashMap>, +} + +/// Identity of a region separation job: the track and its parts to 0.1 s. +fn region_key(id: TrackId, regions: &[(f64, f64)]) -> (TrackId, Vec<(i64, i64)>) { + (id, regions.iter().map(|&(a, b)| ((a * 10.0).round() as i64, (b * 10.0).round() as i64)).collect()) +} + +/// The parts (track seconds) a track's partial stem file covers, merged into ranges. +fn region_cover(hash: &str) -> Option> { + use clubcraft_core::{SAMPLE_RATE, STEM_CHUNK_FRAMES}; + let p = clubcraft_core::library::region_stems_path(hash); + if !p.exists() { + return None; + } + let mut chunks = clubcraft_stems::read_partial_chunks(&p).ok().flatten()?; + chunks.sort_unstable(); + let sec = |c: usize| (c * STEM_CHUNK_FRAMES) as f64 / SAMPLE_RATE as f64; + let mut out: Vec<(f64, f64)> = Vec::new(); + for c in chunks { + match out.last_mut() { + Some(last) if (last.1 - sec(c)).abs() < 1e-9 => last.1 = sec(c + 1), + _ => out.push((sec(c), sec(c + 1))), + } + } + Some(out) } impl Shared { @@ -55,6 +83,10 @@ impl Shared { self.waveforms.insert(id, std::sync::Arc::new(wf)); } } + match region_cover(&hash) { + Some(r) => self.stem_regions.insert(id, r), + None => self.stem_regions.remove(&id), + }; } let cues = lib.cues(id).unwrap_or_default(); self.cue_counts.insert(id, cues.len()); @@ -106,6 +138,11 @@ pub struct App { applied_prefs: Option, pub mix_summaries: Vec, prepared: (i64, usize), + /// [`StudioView::mix_serial`] of the mix the studio preview was last reset for. + preview_serial: u64, + /// Parts of tracks queued for separation because stem automation plays in them (see + /// [`region_key`]). + stems_queued: std::collections::HashSet<(TrackId, Vec<(i64, i64)>)>, /// Export waiting for stem separation. pending_export: Option, /// Last seen change from `clubcraft mcp` (marker mtime) and when it was checked. @@ -142,7 +179,7 @@ impl App { } let audio_cfg: clubcraft_engine::AudioConfig = cc.storage.and_then(|s| eframe::get_value(s, "audio")).unwrap_or_default(); let midi_port: Option = cc.storage.and_then(|s| eframe::get_value(s, "midi_port")); - let mut app = App { lib, sh, jobs, view, library: LibraryView::default(), status: String::new(), devctl: crate::devctl::DevCtl::from_env(), live: Default::default(), decks: vec![Default::default(); 2], mixer: Default::default(), browser: LibraryView::default(), studio: Default::default(), mixes: Vec::new(), eng: crate::live::adapter::LiveEngine::start(audio_cfg.clone()), settings: crate::settings::Settings::new(audio_cfg, midi_port), home: Default::default(), prefs, applied_prefs: None, menu: crate::menu::AppMenu::install(&cc.egui_ctx), ui_ctx_cmd: None, mix_summaries: Vec::new(), prepared: (-1, 0), pending_export: None, queued: Default::default(), external: (std::fs::metadata(crate::mcp::change_marker()).and_then(|m| m.modified()).ok(), 0.0) }; + let mut app = App { lib, sh, jobs, view, library: LibraryView::default(), status: String::new(), devctl: crate::devctl::DevCtl::from_env(), live: Default::default(), decks: vec![Default::default(); 2], mixer: Default::default(), browser: LibraryView::default(), studio: Default::default(), mixes: Vec::new(), eng: crate::live::adapter::LiveEngine::start(audio_cfg.clone()), settings: crate::settings::Settings::new(audio_cfg, midi_port), home: Default::default(), prefs, applied_prefs: None, menu: crate::menu::AppMenu::install(&cc.egui_ctx), ui_ctx_cmd: None, mix_summaries: Vec::new(), prepared: (-1, 0), preview_serial: 0, stems_queued: Default::default(), pending_export: None, queued: Default::default(), external: (std::fs::metadata(crate::mcp::change_marker()).and_then(|m| m.modified()).ok(), 0.0) }; app.settings.connect_midi(app.eng.handle.as_ref()); if let Some(e) = &app.eng.error { app.status = e.clone(); @@ -206,11 +243,37 @@ impl App { for id in std::mem::take(&mut self.eng.touched) { self.sh.reload_track(&self.lib, id); } + // another mix was opened: the running preview still plays the old one, so stop it (the + // next play builds the preview from the open mix) + if self.studio.mix_serial != self.preview_serial { + self.preview_serial = self.studio.mix_serial; + self.refresh_savepoints(); + self.studio.at_savepoint = self.matching_savepoint(); + if self.eng.studio_player.is_some() { + self.eng.stop_mix(crate::live::adapter::STUDIO_DECKS); + self.studio.playing = false; + } + } let key = (self.studio.mix.id, self.studio.mix.tracks.len()); if key != self.prepared { self.prepared = key; self.prepare_mix_tracks(); } + // Stem automation (stem transitions, a track's own stem lanes) on tracks without stems is + // only imitated with volume and EQ, so the parts it plays in are separated right away, at + // the Fast quality (seconds per track; complete Best stems are made for export), whatever + // the background setting. Mix order (front of the queue, so pushed in reverse); each part + // once per session; not while the timeline is stale or being dragged. + if !self.studio.timeline_dirty && !ctx.input(|i| i.pointer.any_down()) { + let fresh: Vec<(TrackId, Vec<(f64, f64)>)> = self.studio.missing_stem_regions(&self.sh).into_iter().filter(|(id, r)| !self.stems_queued.contains(®ion_key(*id, r))).collect(); + if !fresh.is_empty() { + for (id, r) in fresh.iter().rev() { + self.jobs.separate_regions(*id, r.clone()); + } + self.stems_queued.extend(fresh.iter().map(|(id, r)| region_key(*id, r))); + self.status = format!("Separating stems for the stem transitions of {} track(s)", fresh.len()); + } + } let st = self.eng.state(); self.settings.tick(&st); self.decks = (0..2).map(|d| crate::live::adapter::deck_ui(&st, d, &self.sh, &Default::default(), self.eng.is_loading(d))).collect(); @@ -437,6 +500,55 @@ impl App { } } + /// Save the open mix even when nothing changed, so it has a library id. False (with a status + /// message) when it can't be saved. + fn save_mix_now(&mut self) -> bool { + if self.studio.mix.tracks.is_empty() { + return false; + } + if self.studio.mix.id <= 0 { + self.studio.dirty = true; + } + self.save_mix(); + self.studio.mix.id > 0 && !self.studio.dirty + } + + /// Keep the open mix's current state as savepoint `name`; returns its id. + fn add_savepoint(&mut self, name: &str) -> Option { + if !self.save_mix_now() { + return None; + } + match self.lib.add_savepoint(self.studio.mix.id, name, &self.studio.mix) { + Ok(id) => { + self.studio.at_savepoint = Some(id); + self.refresh_savepoints(); + self.status = format!("Savepoint \"{name}\" kept"); + Some(id) + } + Err(e) => { + self.status = format!("savepoint: {e}"); + None + } + } + } + + fn refresh_savepoints(&mut self) { + self.studio.savepoints = if self.studio.mix.id > 0 { self.lib.savepoints(self.studio.mix.id).unwrap_or_default() } else { Vec::new() }; + } + + /// The newest savepoint the open mix equals (its name aside), if any. + fn matching_savepoint(&self) -> Option { + let mut cur = self.studio.mix.clone(); + cur.normalize(); + self.studio.savepoints.iter().rev().map(|s| s.0).find(|&id| { + self.lib.savepoint(id).ok().flatten().is_some_and(|mut m| { + m.name = cur.name.clone(); + m.normalize(); + m == cur + }) + }) + } + /// Start a waiting export once its stems are separated (or the jobs ended / were stopped). fn tick_pending_export(&mut self) { let Some(pe) = &self.pending_export else { return }; @@ -513,11 +625,68 @@ impl App { for a in actions { match a { A::SaveMix => self.save_mix(), + A::DuplicateMix => { + if !self.save_mix_now() { + continue; + } + let base = format!("{} (copy)", self.studio.mix.name); + let mut name = base.clone(); + let mut n = 2; + while self.mixes.iter().any(|m| m.1 == name) { + name = format!("{base} {n}"); + n += 1; + } + match self.lib.duplicate_mix(self.studio.mix.id, &name) { + Ok(Some(copy)) => { + self.studio.set_mix(copy); + self.refresh_mixes(); + self.status = format!("Duplicated as \"{name}\""); + } + Ok(None) => {} + Err(e) => self.status = format!("duplicate mix: {e}"), + } + } + A::AddSavepoint(name) => { + let name = if name.trim().is_empty() { format!("Savepoint {}", self.studio.savepoints.len() + 1) } else { name.trim().to_string() }; + self.add_savepoint(&name); + } + A::RestoreSavepoint(id) => { + let Some(target) = self.studio.savepoints.iter().find(|s| s.0 == id).map(|s| s.1.clone()) else { continue }; + // the state being left is kept, so going back and forth loses nothing + if self.studio.at_savepoint.is_none() && self.matching_savepoint().is_none() && !self.studio.mix.tracks.is_empty() && self.add_savepoint(&format!("Before going to \"{target}\"")).is_none() { + continue; + } + match self.lib.savepoint(id) { + Ok(Some(mut m)) => { + m.name = self.studio.mix.name.clone(); + self.studio.go_to_savepoint(m); + self.save_mix(); + self.studio.at_savepoint = Some(id); + self.status = format!("Went to savepoint \"{target}\""); + } + Ok(None) => self.refresh_savepoints(), + Err(e) => self.status = format!("savepoint: {e}"), + } + } + A::DeleteSavepoint(id) => { + if let Err(e) = self.lib.delete_savepoint(id) { + self.status = format!("delete savepoint: {e}"); + } + if self.studio.at_savepoint == Some(id) { + self.studio.at_savepoint = None; + } + self.refresh_savepoints(); + } A::FindMashups(id) => self.studio.mashup.open_for(id, &self.sh), A::Harmonize => self.studio.harmonize(&self.sh), A::SeparateStems(ids) => { - for id in &ids { - self.jobs.separate_now(*id); + // the parts the mix's stem automation plays in (Fast); whole tracks otherwise + let regions = self.studio.missing_stem_regions(&self.sh); + for id in ids.iter().rev() { + match regions.iter().find(|e| e.0 == *id) { + Some((_, r)) => self.jobs.separate_regions(*id, r.clone()), + None => self.jobs.separate_now(*id), + } } self.status = format!("Separating stems for {} track(s)", ids.len()); } @@ -684,7 +853,9 @@ impl App { match c { "djs-import" => self.handle_studio_actions(vec![crate::studio::StudioAction::ImportDjStudio]), "mix" => { - if let Some((id, _, _)) = self.mixes.iter().find(|m| m.1.to_lowercase().contains(&arg.to_lowercase())) { + // an exact name first, else the first name containing `arg` + let q = arg.trim().to_lowercase(); + if let Some((id, _, _)) = self.mixes.iter().find(|m| m.1.to_lowercase() == q).or_else(|| self.mixes.iter().find(|m| m.1.to_lowercase().contains(&q))) { if let Ok(Some(m)) = self.lib.mix(*id) { self.studio.set_mix(m); self.view = View::Studio; @@ -765,6 +936,14 @@ impl App { "undo" => self.studio.undo(), "redo" => self.studio.redo(), "harmonize" => self.studio.harmonize(&self.sh), + "duplicate" => self.handle_studio_actions(vec![crate::studio::StudioAction::DuplicateMix]), + "savepoint" => self.handle_studio_actions(vec![crate::studio::StudioAction::AddSavepoint(arg.trim().to_string())]), + "goto-savepoint" => { + // `goto-savepoint ` (tests) + if let Some(id) = self.studio.savepoints.iter().find(|s| s.1 == arg.trim()).map(|s| s.0) { + self.handle_studio_actions(vec![crate::studio::StudioAction::RestoreSavepoint(id)]); + } + } "tcat" => { // `tcat `: transition editor category (tests) use crate::studio_transition::TCategory; diff --git a/crates/app/src/home.rs b/crates/app/src/home.rs index 25351c3..9a9f13e 100644 --- a/crates/app/src/home.rs +++ b/crates/app/src/home.rs @@ -80,7 +80,7 @@ fn fmt_dur(s: f64) -> String { } } -fn fmt_date(ts: i64) -> String { +pub(crate) fn fmt_date(ts: i64) -> String { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); let d = (now - ts).max(0); match d { @@ -111,10 +111,10 @@ impl HomeView { row.label(RichText::new(greeting).size(26.0).strong().color(theme::TEXT)); row.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if ui.add(button("Go live ▶", true)).clicked() { + if pill_button(ui, "Go live ▶", true).clicked() { out.push(HomeAction::GoLive); } - if ui.add(button("Open library", false)).clicked() { + if pill_button(ui, "Open library", false).clicked() { out.push(HomeAction::GoLibrary); } }); @@ -242,14 +242,21 @@ fn panel_bg(ui: &Ui, r: Rect) { p.rect_stroke(r, 12.0, Stroke::new(1.0, theme::LINE), StrokeKind::Inside); } -fn button(text: &str, primary: bool) -> egui::Button<'_> { - let t = RichText::new(text).size(13.0); - let b = egui::Button::new(if primary { t.strong().color(Color32::BLACK) } else { t.color(theme::TEXT) }).corner_radius(8.0).min_size(vec2(0.0, 32.0)); - if primary { - b.fill(theme::accent()) - } else { - b.fill(theme::PANEL).stroke(Stroke::new(1.0, theme::LINE)) +/// Button sized to its label, painted like [`wide_button`]: hovering only changes the fill (a +/// stock egui button also swaps its border and grows on hover, which flickered here). +fn pill_button(ui: &mut Ui, text: &str, primary: bool) -> egui::Response { + let font = FontId::proportional(13.0); + let w = ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font.clone(), theme::TEXT).size().x); + let (r, resp) = ui.allocate_exact_size(vec2(w + 28.0, 32.0), Sense::click()); + let p = ui.painter(); + let hover = resp.hovered(); + let (fill, col) = if primary { (if hover { theme::accent().gamma_multiply(0.88) } else { theme::accent() }, Color32::BLACK) } else { (if hover { theme::PANEL_HI } else { theme::PANEL }, theme::TEXT) }; + p.rect_filled(r, 8.0, fill); + if !primary { + p.rect_stroke(r, 8.0, Stroke::new(1.0, theme::LINE), StrokeKind::Inside); } + p.text(r.center(), Align2::CENTER_CENTER, text, font, col); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) } /// Full-width button with a centred label. diff --git a/crates/app/src/jobs.rs b/crates/app/src/jobs.rs index f0f80f5..476a162 100644 --- a/crates/app/src/jobs.rs +++ b/crates/app/src/jobs.rs @@ -27,6 +27,8 @@ enum Job { Import(Vec, bool), /// Separate stems; `Some(quality)` overrides the playback quality (export). Separate(TrackId, Option), + /// Fast stems for parts of a track only (track seconds): its stem transitions. + SeparateRegions(TrackId, Vec<(f64, f64)>), Analyze(TrackId), } @@ -118,6 +120,11 @@ impl Jobs { /// Analyse now (fast, without stems) and separate stems afterwards (if enabled); the /// separation job re-analyses with the stems when it is done. /// Separate stems now (front of the queue), then re-analyse. + /// Fast stems for the parts `regions` (track seconds) of a track, ahead of other separation. + pub fn separate_regions(&self, id: TrackId, regions: Vec<(f64, f64)>) { + self.gpu.push(Job::SeparateRegions(id, regions), true); + } + pub fn separate_now(&self, id: TrackId) { self.gpu.push(Job::Separate(id, None), true); } @@ -271,6 +278,23 @@ fn worker(q: Arc, other: Arc, tx: Sender, settings: Arc { + let _heavy = heavy(); + let r = pipeline::separate_regions(&lib, &mut ctx, id, ®ions, &cancel, &mut |frac, label| { + let _ = tx.send(Event::Progress { track: Some(id), frac, label: label.to_string() }); + }); + match r { + Ok(changed) => { + let _ = tx.send(Event::Finished { track: Some(id) }); + if changed { + let _ = tx.send(Event::TrackUpdated(id)); + } + } + Err(e) => { + let _ = tx.send(Event::Error { track: Some(id), message: format!("stems: {e:#}") }); + } + } + } Job::Analyze(id) => { // Only analysis with stems (and the optional structure model) is heavy; the quick // analysis of a fresh import must not wait behind a long separation. diff --git a/crates/app/src/live/adapter.rs b/crates/app/src/live/adapter.rs index 367725c..b1adaa7 100644 --- a/crates/app/src/live/adapter.rs +++ b/crates/app/src/live/adapter.rs @@ -86,7 +86,8 @@ impl TrackCache { /// Load (decode, stems, analysis) a track; cached. Analyses the track first if needed. pub fn load(&self, id: TrackId, live_sep: Option<&LiveSeparator>) -> Result> { if let Some(t) = self.get(id) { - if t.stems.is_some() || live_sep.is_none() { + // a deck separates the whole track: stems for a mix's transitions only don't do + if t.stems.as_ref().is_some_and(|s| !is_region_stems(s)) || live_sep.is_none() { return Ok(t); } } @@ -112,7 +113,8 @@ impl TrackCache { sep.submit(id, t.hash.clone(), audio.clone(), st.clone(), (start * clubcraft_core::SAMPLE_RATE as f64) as usize); Some(st) } - None => None, + // mix playback: the stems separated for the mix's stem transitions, if any + None => load_region_stems(&t.hash, audio.frames()), } } }; @@ -129,10 +131,31 @@ pub(crate) fn load_uncached(id: TrackId) -> Result> { let t = lib.track(id)?.context("unknown track")?; let analysis = lib.analysis(id)?.unwrap_or_default(); let audio = Arc::new(clubcraft_audio::decode_file(&t.path, clubcraft_core::SAMPLE_RATE)?); - let stems = clubcraft_stems::load_stem_track(&stems_path(&t.hash)).ok().filter(|st| st.frames.abs_diff(audio.frames()) < 4096).map(Arc::new); + let stems = clubcraft_stems::load_stem_track(&stems_path(&t.hash)).ok().filter(|st| st.frames.abs_diff(audio.frames()) < 4096).map(Arc::new).or_else(|| load_region_stems(&t.hash, audio.frames())); Ok(Arc::new(LoadedTrack::from_parts(&t, &analysis, stems, audio).with_user_cues(&lib.cues(id)?))) } +/// Marks a [`StemTrack`] that holds only the parts separated for a mix's stem transitions. +const REGION_STEMS: &str = " (transitions)"; + +fn is_region_stems(s: &StemTrack) -> bool { + s.model.ends_with(REGION_STEMS) +} + +/// The partial stems of a track (its stem transitions, see `pipeline::separate_regions`). +fn load_region_stems(hash: &str, frames: usize) -> Option> { + let p = clubcraft_core::library::region_stems_path(hash); + if !p.exists() { + return None; + } + let mut st = clubcraft_stems::load_stem_track(&p).map_err(|e| log::warn!("{}: {e:#}", p.display())).ok()?; + if st.frames.abs_diff(frames) >= 4096 { + return None; + } + st.model.push_str(REGION_STEMS); + Some(Arc::new(st)) +} + /// Background separation of tracks loaded on a deck before their stems were cached. /// Uses the fast model so stems are available within seconds; the result is saved to the /// cache (the library job queue may later upgrade it to the high-quality model). diff --git a/crates/app/src/pipeline.rs b/crates/app/src/pipeline.rs index 7e0f69c..1a02f6d 100644 --- a/crates/app/src/pipeline.rs +++ b/crates/app/src/pipeline.rs @@ -119,9 +119,86 @@ pub fn separate( } progress(1.0, "Saving stems"); clubcraft_stems::save_stems_with_model(&path, &stems, sep.model_name())?; + // complete stems replace the parts separated for mix playback + let _ = std::fs::remove_file(clubcraft_core::library::region_stems_path(&t.hash)); Ok(()) } +/// Margin around each region (s): the stretcher reads ahead, and curves are edited at the edges. +const REGION_MARGIN: f64 = 3.0; + +/// Stems for parts of a track only (`regions` in track seconds), at the Fast quality: what mix +/// playback needs for stem transitions, ready in seconds instead of minutes (complete, Best stems +/// are separated for export). Added to the track's partial stem file +/// ([`clubcraft_core::library::region_stems_path`]); a track with complete stems is left alone. +/// Returns whether new stems were saved. +pub fn separate_regions( + lib: &Library, + ctx: &mut WorkerContext, + id: TrackId, + regions: &[(f64, f64)], + cancel: &AtomicBool, + progress: &mut dyn FnMut(f32, &str), +) -> Result { + use clubcraft_core::STEM_CHUNK_FRAMES as CHUNK; + let t = lib.track(id)?.context("unknown track")?; + if cached_stems_model(&t.hash).is_some() { + return Ok(false); + } + let path = clubcraft_core::library::region_stems_path(&t.hash); + progress(0.0, "Decoding"); + let audio = clubcraft_audio::decode_file(&t.path, clubcraft_core::SAMPLE_RATE)?; + let n = audio.frames(); + if n == 0 { + return Ok(false); + } + let last = (n - 1) / CHUNK; + let chunk_of = |sec: f64| ((sec.max(0.0) * clubcraft_core::SAMPLE_RATE as f64) as usize / CHUNK).min(last); + let wanted: Vec<(usize, usize)> = regions.iter().map(|&(a, b)| (chunk_of(a - REGION_MARGIN), chunk_of(b + REGION_MARGIN))).filter(|(a, b)| a <= b).collect(); + progress(0.0, "Loading models"); + let sep = ctx.separator(Quality::Fast)?; + let model = sep.model_name().to_string(); + // extend an earlier partial file of the same model + let st = match path.exists().then(|| clubcraft_stems::load_stem_track(&path).ok()).flatten() { + Some(e) if e.frames == n && e.model == model => e, + _ => clubcraft_core::StemTrack::new(n, model.clone()), + }; + let todo: Vec<(usize, usize)> = wanted.into_iter().filter(|&(a, b)| !(a..=b).all(|c| st.is_chunk_ready(c))).collect(); + if todo.is_empty() { + return Ok(false); + } + let total: usize = todo.iter().map(|(a, b)| b - a + 1).sum(); + let label = format!("Stems for transitions ({model})"); + for &(c0, c1) in &todo { + // runs from the region's start onwards; stopped as soon as the region is covered + let stop = AtomicBool::new(false); + let r = sep.separate_into( + &audio, + &st, + c0 * CHUNK, + &mut |_| { + let done: usize = todo.iter().map(|&(a, b)| (a..=b).filter(|&c| st.is_chunk_ready(c)).count()).sum(); + progress(done as f32 / total as f32, &label); + if (c0..=c1).all(|c| st.is_chunk_ready(c)) || cancel.load(Ordering::Relaxed) { + stop.store(true, Ordering::Relaxed); + } + }, + &stop, + ); + if cancel.load(Ordering::Relaxed) { + bail!("cancelled"); + } + if let Err(e) = r { + if !e.is::() { + return Err(e); + } + } + } + progress(1.0, "Saving stems"); + clubcraft_stems::save_partial_stem_track(&path, &st, &model)?; + Ok(true) +} + /// Full analysis (uses the structure model when stems exist; can take ~20 s). pub fn analyze(lib: &Library, id: TrackId, s: &Settings, progress: &mut dyn FnMut(f32, &str)) -> Result<()> { let src = if s.deep_structure { clubcraft_analysis::StructureSource::Auto } else { clubcraft_analysis::StructureSource::Rules }; diff --git a/crates/app/src/settings.rs b/crates/app/src/settings.rs index 4112d54..f972295 100644 --- a/crates/app/src/settings.rs +++ b/crates/app/src/settings.rs @@ -149,47 +149,65 @@ impl Settings { if !self.open { return; } - let mut open = self.open; - egui::Window::new("Settings") - .open(&mut open) - .default_size([760.0, 560.0]) - .min_width(640.0) - .resizable(true) - .collapsible(false) - .show(ctx, |ui| { - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(150.0); - for t in Tab::ALL { - if ui.add(egui::Button::new(RichText::new(t.label()).size(13.0)).selected(self.tab == t).min_size(egui::vec2(146.0, 26.0))).clicked() { - self.tab = t; - } + // A dialog in the app's own look: rounded panel over a dimmed backdrop, own header, a + // sidebar like the segmented controls; Esc or a click outside closes it. + let frame = egui::Frame::new() + .fill(theme::PANEL) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(12.0) + .inner_margin(egui::Margin::same(18)) + .shadow(egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: egui::Color32::from_black_alpha(120) }); + let mut close = false; + let resp = egui::Modal::new(egui::Id::new("settings")).frame(frame).backdrop_color(egui::Color32::from_black_alpha(120)).show(ctx, |ui| { + settings_style(ui.style_mut()); + ui.set_width(800.0); + ui.set_height(560.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Settings").size(20.0).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + close = close_button(ui).on_hover_text("Close (Esc)").clicked(); + }); + }); + ui.add_space(10.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(170.0); + ui.spacing_mut().item_spacing.y = 2.0; + for t in Tab::ALL { + if nav_item(ui, t.label(), self.tab == t).clicked() { + self.tab = t; + } + } + }); + ui.add_space(10.0); + let (sep, _) = ui.allocate_exact_size(egui::vec2(1.0, 520.0), egui::Sense::hover()); + ui.painter().rect_filled(sep, 0.0, theme::LINE); + ui.add_space(14.0); + ui.vertical(|ui| { + egui::ScrollArea::vertical().id_salt("settings-body").show(ui, |ui| { + ui.set_min_width(590.0); + match self.tab { + Tab::General => general_ui(ui, prefs), + Tab::Decks => decks_ui(ui, prefs), + Tab::Mixer => mixer_ui(ui, prefs), + Tab::Audio => self.audio_ui(ui, handle), + Tab::Waveforms => waveforms_ui(ui, prefs), + Tab::Analysis => analysis_ui(ui, prefs, actions), + Tab::Studio => studio_ui(ui, prefs), + Tab::Export => export_ui(ui, prefs), + Tab::Controllers => self.midi_ui(ui, handle), + } + if !self.message.is_empty() { + ui.add_space(8.0); + ui.label(RichText::new(&self.message).color(theme::TEXT_DIM)); } - }); - ui.separator(); - ui.vertical(|ui| { - egui::ScrollArea::vertical().id_salt("settings-body").show(ui, |ui| { - ui.set_min_width(520.0); - match self.tab { - Tab::General => general_ui(ui, prefs), - Tab::Decks => decks_ui(ui, prefs), - Tab::Mixer => mixer_ui(ui, prefs), - Tab::Audio => self.audio_ui(ui, handle), - Tab::Waveforms => waveforms_ui(ui, prefs), - Tab::Analysis => analysis_ui(ui, prefs, actions), - Tab::Studio => studio_ui(ui, prefs), - Tab::Export => export_ui(ui, prefs), - Tab::Controllers => self.midi_ui(ui, handle), - } - if !self.message.is_empty() { - ui.add_space(8.0); - ui.label(RichText::new(&self.message).color(theme::TEXT_DIM)); - } - }); }); }); }); - self.open = open; + }); + if close || resp.should_close() { + self.open = false; + } } fn audio_ui(&mut self, ui: &mut egui::Ui, handle: Option<&EngineHandle>) { @@ -328,10 +346,92 @@ pub enum SettingsAction { ReanalyseAll, } +/// Widget look inside the settings dialog: choices like the app's segmented controls (graphite +/// segment on a dark track), no hover growth, checkboxes with a visible box and an accent tick. +fn settings_style(s: &mut egui::Style) { + let v = &mut s.visuals; + v.selection.bg_fill = theme::SEG_ON; + v.selection.stroke = egui::Stroke::new(1.0, theme::TEXT); + for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] { + w.expansion = 0.0; + w.corner_radius = egui::CornerRadius::same(6); + w.fg_stroke = egui::Stroke::new(1.5, theme::accent()); + } + v.widgets.inactive.bg_fill = theme::SEG_TRACK; + v.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, theme::LINE); + v.widgets.hovered.weak_bg_fill = theme::SEG_HOVER; + v.widgets.hovered.bg_fill = theme::SEG_HOVER; + v.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme::SEG_ON_LINE); + v.widgets.active.weak_bg_fill = theme::SEG_ON; + s.spacing.button_padding = egui::vec2(10.0, 4.0); + s.spacing.item_spacing = egui::vec2(8.0, 8.0); +} + +fn nav_item(ui: &mut Ui, text: &str, selected: bool) -> egui::Response { + let (r, resp) = ui.allocate_exact_size(egui::vec2(170.0, 30.0), egui::Sense::click()); + let p = ui.painter(); + if selected { + p.rect_filled(r, 6.0, theme::SEG_ON); + p.rect_stroke(r, 6.0, egui::Stroke::new(1.0, theme::SEG_ON_LINE), egui::StrokeKind::Inside); + } else if resp.hovered() { + p.rect_filled(r, 6.0, theme::SEG_HOVER); + } + let col = if selected || resp.hovered() { theme::TEXT } else { theme::TEXT_DIM }; + p.text(r.left_center() + egui::vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, text, egui::FontId::proportional(13.5), col); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +fn close_button(ui: &mut Ui) -> egui::Response { + let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 28.0), egui::Sense::click()); + let p = ui.painter(); + if resp.hovered() { + p.circle_filled(r.center(), 14.0, theme::SEG_HOVER); + } + let (c, d) = (r.center(), 5.0); + let stroke = egui::Stroke::new(1.6, if resp.hovered() { theme::TEXT } else { theme::TEXT_DIM }); + p.line_segment([c + egui::vec2(-d, -d), c + egui::vec2(d, d)], stroke); + p.line_segment([c + egui::vec2(-d, d), c + egui::vec2(d, -d)], stroke); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// Checkbox in the dialog's look: a small rounded square, accent-filled with a tick when on. +fn check(ui: &mut Ui, value: &mut bool, label: &str) -> egui::Response { + let font = egui::FontId::proportional(13.0); + let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), theme::TEXT).size().x); + let (r, mut resp) = ui.allocate_exact_size(egui::vec2(16.0 + 7.0 + w, 22.0), egui::Sense::click()); + if resp.clicked() { + *value = !*value; + resp.mark_changed(); + } + let p = ui.painter(); + let b = egui::Rect::from_center_size(egui::pos2(r.left() + 8.0, r.center().y), egui::vec2(16.0, 16.0)); + if *value { + p.rect_filled(b, 4.0, theme::accent()); + let c = theme::BG; + p.line_segment([b.left_center() + egui::vec2(3.5, 0.5), b.center_bottom() + egui::vec2(-1.0, -4.0)], egui::Stroke::new(2.0, c)); + p.line_segment([b.center_bottom() + egui::vec2(-1.0, -4.0), b.right_top() + egui::vec2(-3.5, 4.0)], egui::Stroke::new(2.0, c)); + } else { + p.rect_filled(b, 4.0, if resp.hovered() { theme::SEG_HOVER } else { theme::SEG_TRACK }); + p.rect_stroke(b, 4.0, egui::Stroke::new(1.0, if resp.hovered() { theme::SEG_ON_LINE } else { theme::LINE }), egui::StrokeKind::Inside); + } + p.text(egui::pos2(b.right() + 7.0, r.center().y), egui::Align2::LEFT_CENTER, label, font, theme::TEXT); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + fn section(ui: &mut Ui, title: &str) { ui.add_space(2.0); ui.label(RichText::new(title).size(16.0).strong()); - ui.add_space(4.0); + ui.add_space(6.0); +} + +/// A row whose options are one choice: drawn as a segmented control. +fn row_choice(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) { + row(ui, label, |ui| { + egui::Frame::new().fill(theme::SEG_TRACK).corner_radius(8.0).inner_margin(egui::Margin::same(3)).show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(add); + }); + }); } fn hint(ui: &mut Ui, text: &str) { @@ -340,14 +440,16 @@ fn hint(ui: &mut Ui, text: &str) { fn row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) { ui.horizontal(|ui| { - ui.add_sized(egui::vec2(190.0, 20.0), egui::Label::new(label)); + // labels left-aligned in a fixed column, like a form + let (r, _) = ui.allocate_exact_size(egui::vec2(170.0, 26.0), egui::Sense::hover()); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(13.0), theme::TEXT_DIM); add(ui); }); } fn general_ui(ui: &mut Ui, p: &mut Prefs) { section(ui, "General"); - row(ui, "Key notation", |ui| { + row_choice(ui, "Key notation", |ui| { ui.selectable_value(&mut p.key_notation, KeyNotation::Camelot, "Camelot (8A)"); ui.selectable_value(&mut p.key_notation, KeyNotation::OpenKey, "Open Key (1m)"); ui.selectable_value(&mut p.key_notation, KeyNotation::Musical, "Musical (Am)"); @@ -371,7 +473,7 @@ fn general_ui(ui: &mut Ui, p: &mut Prefs) { ui.add(egui::Slider::new(&mut p.ui_scale, 0.8..=1.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0))); }); row(ui, "On start", |ui| { - ui.checkbox(&mut p.start_on_home, "Open the home page"); + check(ui, &mut p.start_on_home, "Open the home page"); }); ui.add_space(8.0); hint(ui, &format!("Library, stems and exports are stored in {}", clubcraft_core::paths::data_dir().display())); @@ -379,69 +481,74 @@ fn general_ui(ui: &mut Ui, p: &mut Prefs) { fn decks_ui(ui: &mut Ui, p: &mut Prefs) { section(ui, "Decks"); - row(ui, "Cue button", |ui| { + row_choice(ui, "Cue button", |ui| { ui.selectable_value(&mut p.cue_button, CueButton::Cdj, "CDJ").on_hover_text("Stopped: set / preview the cue. Playing: back to the cue and stop."); ui.selectable_value(&mut p.cue_button, CueButton::Hold, "Hold").on_hover_text("Plays while held, returns on release"); ui.selectable_value(&mut p.cue_button, CueButton::Cup, "Cue-up-play").on_hover_text("Release starts playback from the cue"); }); - row(ui, "Hot cue while stopped", |ui| { + row_choice(ui, "Hot cue while stopped", |ui| { ui.selectable_value(&mut p.hot_cue_press, HotCuePress::Preview, "Preview while held"); ui.selectable_value(&mut p.hot_cue_press, HotCuePress::Play, "Start playing"); }); - row(ui, "Park loaded track at", |ui| { + row_choice(ui, "Park loaded track at", |ui| { ui.selectable_value(&mut p.load_position, LoadPosition::FirstBeat, "First beat"); ui.selectable_value(&mut p.load_position, LoadPosition::MixIn, "Mix-in cue"); ui.selectable_value(&mut p.load_position, LoadPosition::FileStart, "Start of file"); }); - row(ui, "Default pitch range", |ui| { + row_choice(ui, "Default pitch range", |ui| { for r in [6u32, 8, 10, 16, 25, 50, 100] { ui.selectable_value(&mut p.pitch_range, r, format!("±{r}%")); } }); row(ui, "On load", |ui| { - ui.checkbox(&mut p.keylock, "Keylock on"); - ui.checkbox(&mut p.quantize, "Quantize on"); - ui.checkbox(&mut p.sync_on_load, "Sync to master"); + check(ui, &mut p.keylock, "Keylock on"); + check(ui, &mut p.quantize, "Quantize on"); + check(ui, &mut p.sync_on_load, "Sync to master"); }); row(ui, "Reset on load", |ui| { - ui.checkbox(&mut p.reset_eq_on_load, "EQ & filter"); - ui.checkbox(&mut p.reset_stems_on_load, "Stems"); - ui.checkbox(&mut p.reset_key_on_load, "Key shift"); - ui.checkbox(&mut p.reset_pitch_on_load, "Pitch"); + check(ui, &mut p.reset_eq_on_load, "EQ & filter"); + check(ui, &mut p.reset_stems_on_load, "Stems"); + check(ui, &mut p.reset_key_on_load, "Key shift"); + check(ui, &mut p.reset_pitch_on_load, "Pitch"); }); } fn mixer_ui(ui: &mut Ui, p: &mut Prefs) { section(ui, "Mixer"); - row(ui, "Crossfader curve", |ui| { + row_choice(ui, "Crossfader curve", |ui| { ui.selectable_value(&mut p.crossfader_curve, XfCurve::Smooth, "Smooth"); ui.selectable_value(&mut p.crossfader_curve, XfCurve::Full, "Full"); ui.selectable_value(&mut p.crossfader_curve, XfCurve::Cut, "Cut"); ui.selectable_value(&mut p.crossfader_curve, XfCurve::Scratch, "Scratch"); }); - row(ui, "Channel fader curve", |ui| { + row_choice(ui, "Channel fader curve", |ui| { ui.selectable_value(&mut p.fader_curve, FaderLaw::Linear, "Linear"); ui.selectable_value(&mut p.fader_curve, FaderLaw::Smooth, "Smooth"); ui.selectable_value(&mut p.fader_curve, FaderLaw::Fast, "Fast"); }); row(ui, "Auto gain", |ui| { - ui.checkbox(&mut p.auto_gain, "Match the loudness of every track"); + check(ui, &mut p.auto_gain, "Match the loudness of every track"); }); row(ui, "Master limiter", |ui| { - ui.checkbox(&mut p.limiter, "On"); + check(ui, &mut p.limiter, "On"); ui.add_enabled(p.limiter, egui::Slider::new(&mut p.limiter_ceiling_db, -6.0..=0.0).suffix(" dB").text("ceiling")); }); } fn waveforms_ui(ui: &mut Ui, p: &mut Prefs) { section(ui, "Waveforms"); - row(ui, "Colours", |ui| { + row_choice(ui, "Colours", |ui| { ui.selectable_value(&mut p.stem_waveforms, true, "By stem"); ui.selectable_value(&mut p.stem_waveforms, false, "By frequency band"); }); row(ui, "Stem palette", |ui| { - ui.selectable_value(&mut p.soft_stem_colors, true, "Soft"); - ui.selectable_value(&mut p.soft_stem_colors, false, "Vivid"); + egui::Frame::new().fill(theme::SEG_TRACK).corner_radius(8.0).inner_margin(egui::Margin::same(3)).show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + ui.selectable_value(&mut p.soft_stem_colors, true, "Soft"); + ui.selectable_value(&mut p.soft_stem_colors, false, "Vivid"); + }); + }); for k in [clubcraft_core::StemKind::Drums, clubcraft_core::StemKind::Bass, clubcraft_core::StemKind::Other, clubcraft_core::StemKind::Vocals] { ui.label(RichText::new("■").size(14.0).color(theme::stem_color(k))); } @@ -461,26 +568,26 @@ fn analysis_ui(ui: &mut Ui, p: &mut Prefs, actions: &mut Vec) { }); hint(ui, "Detected tempos are folded into this range (e.g. 70–180 for house and techno, 85–175 for drum & bass at half time)."); row(ui, "Structure", |ui| { - ui.checkbox(&mut p.deep_structure, "Deep structure analysis (All-In-One model)"); + check(ui, &mut p.deep_structure, "Deep structure analysis (All-In-One model)"); }); hint(ui, "Slightly better sections and cues; needs stems, about 1 min and 1.5 GB extra per track."); ui.add_space(6.0); section(ui, "Stems"); row(ui, "Separate automatically", |ui| { - ui.checkbox(&mut p.separate_stems, "For every imported track (in the background)"); + check(ui, &mut p.separate_stems, "For every imported track (in the background)"); }); let qualities = [ ("best", "Best", "Mel-Band RoFormer vocals + htdemucs_ft: cleanest vocals, ~2.5 min per track"), ("high", "High", "htdemucs_ft, ~80 s per track"), ("fast", "Fast", "htdemucs, ~17 s per track"), ]; - row(ui, "Quality for playback", |ui| { + row_choice(ui, "Quality for playback", |ui| { for (q, l, h) in qualities { ui.selectable_value(&mut p.stem_quality, q.to_string(), l).on_hover_text(h); } }); hint(ui, "Used for the decks, the studio and background separation. A deck plays a track while its stems are separated: with Fast the part under the playhead has stems within a second, with Best after about 15 s."); - row(ui, "Quality for export", |ui| { + row_choice(ui, "Quality for export", |ui| { for (q, l, h) in qualities { ui.selectable_value(&mut p.export_stem_quality, q.to_string(), l).on_hover_text(h); } @@ -510,7 +617,7 @@ fn studio_ui(ui: &mut Ui, p: &mut Prefs) { } }); }); - row(ui, "Transition length", |ui| { + row_choice(ui, "Transition length", |ui| { ui.selectable_value(&mut p.transition_bars, None, "Auto"); for b in [4u32, 8, 16, 32] { ui.selectable_value(&mut p.transition_bars, Some(b), format!("{b} bars")); @@ -518,29 +625,29 @@ fn studio_ui(ui: &mut Ui, p: &mut Prefs) { }); hint(ui, "Auto picks the length from the outro of the outgoing and the intro of the incoming track. Stem transitions fall back to a bass swap for tracks without stems."); row(ui, "Harmonic mixing", |ui| { - ui.checkbox(&mut p.allow_key_shift, "Shift keys by ±1 semitone to avoid clashes"); + check(ui, &mut p.allow_key_shift, "Shift keys by ±1 semitone to avoid clashes"); }); row(ui, "Keylock in mixes", |ui| { - ui.checkbox(&mut p.mix_keylock, "Keep the original key when the tempo changes"); + check(ui, &mut p.mix_keylock, "Keep the original key when the tempo changes"); }); } fn export_ui(ui: &mut Ui, p: &mut Prefs) { section(ui, "Mix export"); - row(ui, "Format", |ui| { + row_choice(ui, "Format", |ui| { for f in [AudioFormat::Wav16, AudioFormat::Wav24, AudioFormat::Wav32Float] { ui.selectable_value(&mut p.export_format, f, f.label()); } }); row(ui, "Cue sheet", |ui| { - ui.checkbox(&mut p.export_cue_sheet, "Write a .cue track list next to the mix"); + check(ui, &mut p.export_cue_sheet, "Write a .cue track list next to the mix"); }); row(ui, "Tail", |ui| { ui.add(egui::Slider::new(&mut p.export_tail_secs, 0.0..=10.0).suffix(" s").text("after the last track")); }); ui.add_space(6.0); section(ui, "Recording (live)"); - row(ui, "Format", |ui| { + row_choice(ui, "Format", |ui| { for f in [AudioFormat::Wav16, AudioFormat::Wav24, AudioFormat::Wav32Float] { ui.selectable_value(&mut p.record_format, f, f.label()); } diff --git a/crates/app/src/studio.rs b/crates/app/src/studio.rs index 7abbc82..de1926c 100644 --- a/crates/app/src/studio.rs +++ b/crates/app/src/studio.rs @@ -25,6 +25,13 @@ pub enum StudioAction { AutoMixPoints, /// Separate stems for these tracks now. SeparateStems(Vec), + /// Save the open mix as a new mix and open that copy. + DuplicateMix, + /// Keep the open mix's current state as a savepoint with this name (empty: numbered). + AddSavepoint(String), + /// Go back (or forward) to a savepoint; every other savepoint stays. + RestoreSavepoint(i64), + DeleteSavepoint(i64), } /// Tabs of the bottom bar; each opens a panel above the transport. @@ -74,6 +81,13 @@ pub struct StudioView { last_selection: Selection, /// The transition the playhead was in on the last frame. playhead_transition: Option, + /// Counts [`Self::set_mix`] calls: a running preview belongs to the mix it was started with. + pub mix_serial: u64, + /// Savepoints of the open mix, oldest first: (id, name, created at). Kept by the app. + pub savepoints: Vec<(i64, String, i64)>, + /// The savepoint the mix is at (saved or restored, unchanged since). + pub at_savepoint: Option, + savepoint_name: String, /// Open panel of the bottom bar. pub tab: Option, /// Undo / redo history of the mix (one step per edit gesture). @@ -83,6 +97,12 @@ pub struct StudioView { last_change: Option, /// Width of the centred transport group (measured the previous frame). transport_w: f32, + /// Distance from the transport group's left edge to the centre of its play button (last + /// frame), so the play button itself sits in the middle of the window. + play_offset: f32, + /// Width the transport row needs at full size (left + centre + right groups, measured the + /// last time it was laid out that way); narrower rows use the compact labels. + transport_full_w: f32, pub selection: Selection, /// Pixels per second in the timeline. pub zoom: f32, @@ -144,12 +164,18 @@ impl Default for StudioView { preview_playing: false, last_selection: Selection::None, playhead_transition: None, + mix_serial: 0, + savepoints: Vec::new(), + at_savepoint: None, + savepoint_name: String::new(), tab: Some(Tab::Playlist), undo: Vec::new(), redo: Vec::new(), committed: Mix::default(), last_change: None, transport_w: 420.0, + play_offset: 210.0, + transport_full_w: 0.0, selection: Selection::None, zoom: 6.0, scroll: 0.0, @@ -212,6 +238,8 @@ fn analysis_of(sh: &Shared, id: TrackId) -> Option { impl StudioView { pub fn set_mix(&mut self, mix: Mix) { + self.mix_serial += 1; + self.at_savepoint = None; self.committed = mix.clone(); self.undo.clear(); self.redo.clear(); @@ -242,6 +270,14 @@ impl StudioView { self.timeline_dirty = true; self.dirty = true; self.edited = true; + self.at_savepoint = None; + } + + /// Replace the mix with the state of a savepoint, as one undo step. + pub fn go_to_savepoint(&mut self, m: Mix) { + self.undo.push(self.mix.clone()); + self.redo.clear(); + self.restore(m); } fn restore(&mut self, m: Mix) { @@ -892,14 +928,13 @@ impl StudioView { /// Tracks whose stem automation needs stems they don't have: none at all, or (with /// `quality`) only weaker ones than that quality. pub(crate) fn tracks_needing_stems(&self, sh: &Shared, quality: Option<&str>) -> Vec { + // for playback, the parts of the tracks the stem automation plays in are enough + let Some(quality) = quality else { return self.missing_stem_regions(sh).into_iter().map(|e| e.0).collect() }; let mut ids: Vec = Vec::new(); for (i, t) in self.mix.tracks.iter().enumerate() { let uses = !t.stems.is_empty() || (i > 0 && self.mix.transitions.get(i - 1).is_some_and(|x| x.uses_stems())) || self.mix.transitions.get(i).is_some_and(|x| x.uses_stems()); let model = sh.analyses.get(&t.track_id).and_then(|a| a.stems_model.as_deref()); - let has = match quality { - Some(q) => crate::pipeline::stems_meet(model, q), - None => model.is_some(), - }; + let has = crate::pipeline::stems_meet(model, quality); if uses && !has && !ids.contains(&t.track_id) { ids.push(t.track_id); } @@ -907,9 +942,55 @@ impl StudioView { ids } + /// Savepoints: keep the current state under a name, go back or forward to any of them. + fn savepoints_menu(&mut self, ui: &mut Ui, has: bool, compact: bool, out: &mut Vec) { + let n = self.savepoints.len(); + let label = match (compact, n) { + (true, 0) => "⏺ ⏷".to_string(), + (true, n) => format!("⏺ {n} ⏷"), + (false, 0) => "⏺ Savepoints ⏷".to_string(), + (false, n) => format!("⏺ Savepoints ({n}) ⏷"), + }; + ui.menu_button(label, |ui| { + ui.set_min_width(300.0); + ui.horizontal(|ui| { + let hint = format!("Savepoint {}", n + 1); + ui.add(egui::TextEdit::singleline(&mut self.savepoint_name).hint_text(hint).desired_width(170.0)); + if ui.add_enabled(has, egui::Button::new("Save point")).on_hover_text("Keep the mix as it is now").clicked() { + out.push(StudioAction::AddSavepoint(std::mem::take(&mut self.savepoint_name))); + ui.close(); + } + }); + if n > 0 { + ui.separator(); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + // newest first + for (id, name, at) in self.savepoints.iter().rev() { + ui.horizontal(|ui| { + let here = self.at_savepoint == Some(*id); + let text = format!("{}{name}", if here { "● " } else { "" }); + let tip = if here { "The mix is at this savepoint" } else { "Go to this savepoint (the current state is kept as a savepoint first)" }; + if ui.add_sized([200.0, 20.0], egui::Button::selectable(here, text)).on_hover_text(tip).clicked() && !here { + out.push(StudioAction::RestoreSavepoint(*id)); + ui.close(); + } + ui.label(RichText::new(crate::home::fmt_date(*at)).size(11.0).color(theme::TEXT_DIM)); + if ui.small_button("🗑").on_hover_text("Delete this savepoint").clicked() { + out.push(StudioAction::DeleteSavepoint(*id)); + } + }); + } + }); + } + ui.separator(); + ui.label(RichText::new("Going to a savepoint keeps all the others, so you can switch back and forth.").size(11.0).color(theme::TEXT_DIM)); + }); + } + fn transport(&mut self, ui: &mut Ui, sh: &Shared, mixes: &[(i64, String, i64)], out: &mut Vec, load_mix: &mut Option, new_mix: &mut bool) { let full = ui.available_rect_before_wrap(); let has = !self.mix.tracks.is_empty(); + let compact = full.width() < self.transport_full_w; // ---- left: mixes, name, edit menu, undo / redo let mut left = ui.new_child(egui::UiBuilder::new().max_rect(full).layout(Layout::left_to_right(Align::Center))); left.spacing_mut().item_spacing.x = 6.0; @@ -919,6 +1000,10 @@ impl StudioView { *new_mix = true; ui.close(); } + if ui.add_enabled(has, egui::Button::new("Duplicate this mix")).on_hover_text("Save a copy of this mix and open it, to try something out").clicked() { + out.push(StudioAction::DuplicateMix); + ui.close(); + } ui.separator(); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for (id, name, _) in mixes { @@ -940,7 +1025,7 @@ impl StudioView { ui.close(); } }); - if left.add(egui::TextEdit::singleline(&mut self.mix.name).font(egui::FontId::proportional(15.0)).frame(egui::Frame::NONE).desired_width(190.0)).changed() { + if left.add(egui::TextEdit::singleline(&mut self.mix.name).font(egui::FontId::proportional(15.0)).frame(egui::Frame::NONE).desired_width(if compact { 110.0 } else { 190.0 })).changed() { self.dirty = true; } if self.dirty { @@ -950,6 +1035,7 @@ impl StudioView { } else { left.label(RichText::new("saved").size(11.0).color(theme::TEXT_DIM)); } + self.savepoints_menu(&mut left, has, compact, out); left.menu_button("Edit ⏷", |ui| { ui.set_min_width(240.0); if ui.add_enabled(self.mix.tracks.len() > 2, egui::Button::new("✨ Harmonize order")).on_hover_text("Re-order for harmonic mixing, smooth tempo and an energy arc").clicked() { @@ -970,11 +1056,14 @@ impl StudioView { ui.close(); } }); - if left.add_enabled(!self.undo.is_empty(), egui::Button::new("↩").min_size(vec2(28.0, 26.0))).on_hover_text("Undo (⌘Z)").clicked() { - self.undo(); - } - if left.add_enabled(!self.redo.is_empty(), egui::Button::new("↪").min_size(vec2(28.0, 26.0))).on_hover_text("Redo (Shift+⌘Z)").clicked() { - self.redo(); + // (compact: undo / redo stay in the Edit menu and on ⌘Z) + if !compact { + if left.add_enabled(!self.undo.is_empty(), egui::Button::new("↩").min_size(vec2(28.0, 26.0))).on_hover_text("Undo (⌘Z)").clicked() { + self.undo(); + } + if left.add_enabled(!self.redo.is_empty(), egui::Button::new("↪").min_size(vec2(28.0, 26.0))).on_hover_text("Redo (Shift+⌘Z)").clicked() { + self.redo(); + } } // ---- right: output let mut right = ui.new_child(egui::UiBuilder::new().max_rect(full).layout(Layout::right_to_left(Align::Center))); @@ -989,17 +1078,22 @@ impl StudioView { } let missing = self.tracks_missing_stems(sh); if !missing.is_empty() { - let b = egui::Button::new(RichText::new(format!("Stems missing ({})", missing.len())).color(theme::WARN).size(12.0)); + let text = if compact { format!("⚠ {}", missing.len()) } else { format!("Stems missing ({})", missing.len()) }; + let b = egui::Button::new(RichText::new(text).color(theme::WARN).size(12.0)); if right.add(b).on_hover_text("These tracks have stem automation (in transitions or on the track) but no stems yet, so it is imitated with volume and EQ. Click to separate their stems for the real sound.").clicked() { out.push(StudioAction::SeparateStems(missing)); } } - if !self.message.is_empty() { - right.label(RichText::new(&self.message).color(theme::TEXT_DIM).size(11.0)); - } - // ---- centre: transport (centred on the measured width of the last frame) + // (the status message is drawn below, where it fits; it doesn't count as width the row needs) + let right_w = right.min_rect().width(); + // ---- centre: transport, placed so the play button is in the middle of the window (by + // last frame's measurements), but moved right of the left group when that reaches into + // it (and kept off the right group while there is room) let cw = self.transport_w.max(100.0); - let crect = egui::Rect::from_min_size(egui::pos2(full.center().x - cw / 2.0, full.top()), vec2(cw + 8.0, full.height())); + let gap = 12.0; + let (left_end, right_start) = (left.min_rect().right() + gap, right.min_rect().left() - gap); + let cx = (full.center().x - self.play_offset).min(right_start - cw).max(left_end); + let crect = egui::Rect::from_min_size(egui::pos2(cx, full.top()), vec2(cw + 8.0, full.height())); let mut c = ui.new_child(egui::UiBuilder::new().max_rect(crect).layout(Layout::left_to_right(Align::Center))); c.spacing_mut().item_spacing.x = 8.0; let round = |t: &str| egui::Button::new(RichText::new(t).size(15.0)).corner_radius(17.0).min_size(vec2(34.0, 34.0)); @@ -1013,7 +1107,9 @@ impl StudioView { out.push(StudioAction::Seek(self.timeline.transitions[k].start_sec)); } } - if crate::widgets::play_button(&mut c, self.playing, has, 40.0).on_hover_text("Play / pause (Space)").clicked() { + let play = crate::widgets::play_button(&mut c, self.playing, has, 40.0); + self.play_offset = play.rect.center().x - crect.left(); + if play.on_hover_text("Play / pause (Space)").clicked() { out.push(StudioAction::PlayPause); } if c.add_enabled(!self.timeline.transitions.is_empty(), round("⏩")).on_hover_text("Next transition (])").clicked() { @@ -1033,6 +1129,19 @@ impl StudioView { self.follow = !self.follow; } self.transport_w = c.min_rect().width(); + // status message between the transport and the right group, when it fits there + if !self.message.is_empty() { + let text = RichText::new(&self.message).color(theme::TEXT_DIM).size(11.0); + let w = ui.fonts_mut(|f| f.layout_no_wrap(self.message.clone(), egui::FontId::proportional(11.0), theme::TEXT_DIM).size().x); + let free = egui::Rect::from_x_y_ranges((cx + cw + gap)..=(right_start), full.y_range()); + if free.width() >= w { + let mut m = ui.new_child(egui::UiBuilder::new().max_rect(free).layout(Layout::right_to_left(Align::Center))); + m.label(text); + } + } + if !compact { + self.transport_full_w = left.min_rect().width() + self.transport_w + right_w + 4.0 * gap; + } ui.allocate_rect(full, Sense::hover()); } diff --git a/crates/app/src/studio_timeline.rs b/crates/app/src/studio_timeline.rs index aa55429..1cb6b3e 100644 --- a/crates/app/src/studio_timeline.rs +++ b/crates/app/src/studio_timeline.rs @@ -816,7 +816,7 @@ impl StudioView { } } } - if !has_stems && area.width() > 200.0 { + if !self.side_has_stems(sh, k, side) && area.width() > 200.0 { painter.text(pos2(area.right() - 6.0, area.top() + 3.0), egui::Align2::RIGHT_TOP, "no stems yet: imitated with volume and EQ", egui::FontId::proportional(10.0), theme::TEXT_DIM); } } @@ -1475,16 +1475,78 @@ impl StudioView { /// Both tracks of transition `k` have stems. pub(crate) fn stems_ok(&self, sh: &Shared, k: usize) -> bool { - let has = |i: usize| self.mix.tracks.get(i).is_some_and(|t| sh.analyses.get(&t.track_id).is_some_and(|a| a.stems_model.is_some())); - has(k) && has(k + 1) + self.side_has_stems(sh, k, Side::Out) && self.side_has_stems(sh, k, Side::In) + } + + /// Whether the track on `side` of transition `k` has stems for the transition's window: + /// complete stems, or the part separated for mix playback. + pub(crate) fn side_has_stems(&self, sh: &Shared, k: usize, side: Side) -> bool { + let i = if side == Side::Out { k } else { k + 1 }; + let Some(x) = self.timeline.transitions.get(k) else { return false }; + let (a, b) = (self.timeline.track_pos(i, x.start_beat), self.timeline.track_pos(i, x.start_beat + x.beats)); + self.has_stems_for(sh, i, a, b) + } + + /// Whether track `i` has stems for its part `a..b` (track seconds). + fn has_stems_for(&self, sh: &Shared, i: usize, a: f64, b: f64) -> bool { + let Some(id) = self.mix.tracks.get(i).map(|t| t.track_id) else { return false }; + let an = sh.analyses.get(&id); + if an.is_some_and(|a| a.stems_model.is_some()) { + return true; + } + // the part beyond the file's ends needs nothing + let end = an.map_or(f64::INFINITY, |an| an.duration); + let (a, b) = (a.max(0.0), b.min(end)); + b <= a || sh.stem_regions.get(&id).is_some_and(|r| r.iter().any(|&(x, y)| x <= a + 1e-6 && y >= b - 1e-6)) + } + + /// Parts of track `i` (track seconds) whose stem automation needs stems: the windows of its + /// stem transitions and the span of its own stem lanes. + pub(crate) fn stem_regions_needed(&self, i: usize) -> Vec<(f64, f64)> { + let tl = &self.timeline; + let window = |k: usize| tl.transitions.get(k).map(|x| (tl.track_pos(i, x.start_beat), tl.track_pos(i, x.start_beat + x.beats))); + let mut out = Vec::new(); + if i > 0 && self.mix.transitions.get(i - 1).is_some_and(|t| t.uses_stems()) { + out.extend(window(i - 1)); + } + if self.mix.transitions.get(i).is_some_and(|t| t.uses_stems()) { + out.extend(window(i)); + } + if let Some(mt) = self.mix.tracks.get(i) { + for l in &mt.stems { + let (a, b) = l.points.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.0), b.max(p.0))); + if a <= b { + out.push((a as f64, b as f64)); + } + } + } + out.retain(|&(a, b)| b > a.max(0.0)); + out + } + + /// Tracks of the mix whose stem automation still lacks stems, in mix order, with the parts + /// (track seconds) that need them. + pub(crate) fn missing_stem_regions(&self, sh: &Shared) -> Vec<(clubcraft_core::TrackId, Vec<(f64, f64)>)> { + let mut out: Vec<(clubcraft_core::TrackId, Vec<(f64, f64)>)> = Vec::new(); + for (i, t) in self.mix.tracks.iter().enumerate() { + let missing: Vec<(f64, f64)> = self.stem_regions_needed(i).into_iter().filter(|&(a, b)| !self.has_stems_for(sh, i, a, b)).collect(); + if missing.is_empty() { + continue; + } + match out.iter_mut().find(|e| e.0 == t.track_id) { + Some(e) => e.1.extend(missing), + None => out.push((t.track_id, missing)), + } + } + out } /// Transition `k` as it plays: on a track without stems, the stem lanes are emulated with its /// volume and EQ. pub(crate) fn effective_transition(&self, sh: &Shared, k: usize) -> Option { let tr = self.mix.transitions.get(k)?; - let has = |i: usize| self.mix.tracks.get(i).is_some_and(|t| sh.analyses.get(&t.track_id).is_some_and(|a| a.stems_model.is_some())); - Some(if tr.uses_stems() && !self.stems_ok(sh, k) { tr.without_stems_on(!has(k), !has(k + 1)) } else { tr.clone() }) + let (out, inn) = (self.side_has_stems(sh, k, Side::Out), self.side_has_stems(sh, k, Side::In)); + Some(if tr.uses_stems() && !(out && inn) { tr.without_stems_on(!out, !inn) } else { tr.clone() }) } /// Ruler strip at the top of a clip: bar numbers of the track (bar 1 = first downbeat), bar and diff --git a/crates/core/src/library.rs b/crates/core/src/library.rs index 9de9bc6..4c9af57 100644 --- a/crates/core/src/library.rs +++ b/crates/core/src/library.rs @@ -124,6 +124,13 @@ CREATE TABLE IF NOT EXISTS mixes ( created_at INTEGER NOT NULL, modified_at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS mix_savepoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mix_id INTEGER NOT NULL REFERENCES mixes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + json TEXT NOT NULL, + created_at INTEGER NOT NULL +); "#; fn now() -> i64 { @@ -466,9 +473,63 @@ impl Library { } pub fn delete_mix(&self, id: i64) -> Result<()> { + self.conn.execute("DELETE FROM mix_savepoints WHERE mix_id = ?1", params![id])?; self.conn.execute("DELETE FROM mixes WHERE id = ?1", params![id])?; Ok(()) } + + /// Copy mix `id` as a new mix named `name` (its savepoints stay with the original). Returns + /// the new mix. + pub fn duplicate_mix(&self, id: i64, name: &str) -> Result> { + let Some(mut m) = self.mix(id)? else { return Ok(None) }; + m.id = 0; + m.name = name.to_string(); + self.save_mix(&mut m)?; + Ok(Some(m)) + } + + /// Keep the state `mix` of saved mix `mix_id` as a savepoint named `name`. Savepoints are + /// never replaced: restoring one leaves all others, earlier and later, in place. + pub fn add_savepoint(&self, mix_id: i64, name: &str, mix: &Mix) -> Result { + self.conn.execute( + "INSERT INTO mix_savepoints (mix_id, name, json, created_at) VALUES (?1, ?2, ?3, ?4)", + params![mix_id, name, serde_json::to_string(mix)?, now()], + )?; + Ok(self.conn.last_insert_rowid()) + } + + /// Savepoints of mix `mix_id`, oldest first: (id, name, created at). + pub fn savepoints(&self, mix_id: i64) -> Result> { + let mut st = self.conn.prepare("SELECT id, name, created_at FROM mix_savepoints WHERE mix_id = ?1 ORDER BY created_at, id")?; + let rows = st.query_map(params![mix_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + Ok(rows.collect::>()?) + } + + /// The mix as it was at savepoint `id` (with its mix's id). + pub fn savepoint(&self, id: i64) -> Result> { + let row: Option<(i64, String)> = self + .conn + .query_row("SELECT mix_id, json FROM mix_savepoints WHERE id = ?1", params![id], |r| Ok((r.get(0)?, r.get(1)?))) + .optional()?; + Ok(match row { + Some((mix_id, j)) => { + let mut m: Mix = serde_json::from_str(&j)?; + m.id = mix_id; + Some(m) + } + None => None, + }) + } + + pub fn rename_savepoint(&self, id: i64, name: &str) -> Result<()> { + self.conn.execute("UPDATE mix_savepoints SET name = ?2 WHERE id = ?1", params![id, name])?; + Ok(()) + } + + pub fn delete_savepoint(&self, id: i64) -> Result<()> { + self.conn.execute("DELETE FROM mix_savepoints WHERE id = ?1", params![id])?; + Ok(()) + } } // --------------------------------------------------------------- waveform cache @@ -544,6 +605,12 @@ pub fn stems_path(hash: &str) -> PathBuf { crate::paths::stems_dir().join(format!("{hash}.stems.flac")) } +/// Path of the partial stem file for a track hash: stems for the parts of the track that mix +/// playback needs (its stem transitions), until complete stems replace them. +pub fn region_stems_path(hash: &str) -> PathBuf { + crate::paths::stems_dir().join(format!("{hash}.regions.stems.flac")) +} + #[cfg(test)] mod tests { use super::*; @@ -567,4 +634,30 @@ mod tests { let w = Waveform { rate: 100.0, peak: vec![1, 2], low: vec![3, 4], mid: vec![5, 6], high: vec![7, 8], stems: None }; assert_eq!(Waveform::from_bytes(&w.to_bytes()).unwrap(), w); } + + #[test] + fn savepoints_keep_every_state_and_duplicates_are_separate() { + let lib = Library::in_memory().unwrap(); + let mut mix = crate::Mix { name: "Set".into(), ..Default::default() }; + lib.save_mix(&mut mix).unwrap(); + let a = lib.add_savepoint(mix.id, "A", &mix).unwrap(); + mix.keylock = !mix.keylock; + let b = lib.add_savepoint(mix.id, "B", &mix).unwrap(); + // going back to A leaves B (the later state) in place + let back = lib.savepoint(a).unwrap().unwrap(); + assert_eq!((back.id, back.keylock), (mix.id, !mix.keylock)); + assert_eq!(lib.savepoints(mix.id).unwrap().iter().map(|s| s.1.as_str()).collect::>(), ["A", "B"]); + assert_eq!(lib.savepoint(b).unwrap().unwrap().keylock, mix.keylock); + lib.rename_savepoint(b, "B2").unwrap(); + lib.delete_savepoint(a).unwrap(); + assert_eq!(lib.savepoints(mix.id).unwrap().iter().map(|s| s.1.as_str()).collect::>(), ["B2"]); + // a duplicate is its own mix, without the savepoints + let copy = lib.duplicate_mix(mix.id, "Set (copy)").unwrap().unwrap(); + assert_ne!(copy.id, mix.id); + assert_eq!(lib.mix(copy.id).unwrap().unwrap().name, "Set (copy)"); + assert!(lib.savepoints(copy.id).unwrap().is_empty()); + lib.delete_mix(mix.id).unwrap(); + assert!(lib.savepoints(mix.id).unwrap().is_empty()); + assert!(lib.mix(copy.id).unwrap().is_some()); + } } diff --git a/crates/stems/src/cache.rs b/crates/stems/src/cache.rs index 4bd6fb9..bfd3b67 100644 --- a/crates/stems/src/cache.rs +++ b/crates/stems/src/cache.rs @@ -38,6 +38,17 @@ fn vorbis_comment(tags: &[(&str, String)]) -> Vec { /// Model name recorded in a stem cache file (`CLUBCRAFT_MODEL`), reading only the FLAC metadata /// blocks (a few hundred bytes), not the audio. `Ok(None)` if the file has no (or an empty) model tag. pub fn read_model_name(path: &Path) -> Result> { + Ok(read_header_tags(path)?.into_iter().find(|(k, _)| k == "CLUBCRAFT_MODEL").map(|(_, v)| v).filter(|v| !v.is_empty())) +} + +/// The chunks (of [`STEM_CHUNK_FRAMES`]) a partial stem file holds (`CLUBCRAFT_CHUNKS`), reading +/// only the metadata; `Ok(None)` for a complete file. +pub fn read_partial_chunks(path: &Path) -> Result>> { + Ok(read_header_tags(path)?.into_iter().find(|(k, _)| k == "CLUBCRAFT_CHUNKS").map(|(_, v)| parse_chunks(&v))) +} + +/// The Vorbis comments of a FLAC file, reading only its metadata blocks. +fn read_header_tags(path: &Path) -> Result> { use std::io::Read; let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?; // The metadata blocks are tiny (STREAMINFO + Vorbis comment); read until the last one. @@ -70,8 +81,37 @@ pub fn read_model_name(path: &Path) -> Result> { } want = if complete { p } else { p + 4 }.max(buf.len() + 1); } - let tags = read_tags(&buf)?; - Ok(tags.into_iter().find(|(k, _)| k == "CLUBCRAFT_MODEL").map(|(_, v)| v).filter(|v| !v.is_empty())) + read_tags(&buf) +} + +/// Chunk list as ranges: `0-3,10,12-15`. +fn format_chunks(chunks: &[usize]) -> String { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < chunks.len() { + let mut j = i; + while j + 1 < chunks.len() && chunks[j + 1] == chunks[j] + 1 { + j += 1; + } + out.push(if j > i { format!("{}-{}", chunks[i], chunks[j]) } else { chunks[i].to_string() }); + i = j + 1; + } + out.join(",") +} + +fn parse_chunks(s: &str) -> Vec { + let mut out = Vec::new(); + for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('-') { + Some((a, b)) => { + if let (Ok(a), Ok(b)) = (a.parse::(), b.parse::()) { + out.extend(a..=b.min(a + 1_000_000)); + } + } + None => out.extend(part.parse::().ok()), + } + } + out } /// Read the Vorbis comments of a FLAC file (only the metadata header is parsed). @@ -157,6 +197,7 @@ fn write_flac( frames: usize, model: &str, peak: f32, + extra_tags: &[(&str, String)], fill: &FillFn, ) -> Result<()> { let scale = (peak * (32768.0 / 32767.0)).max(1.0); @@ -166,11 +207,13 @@ fn write_flac( let source = StemSource { frames, pos: 0, k: 32768.0 / scale, fill, buf: Vec::new() }; let mut stream = flacenc::encode_with_fixed_block_size(&config, source, BLOCK).map_err(|e| anyhow!("flac encode: {e:?}"))?; - let tags = vorbis_comment(&[ - ("CLUBCRAFT_STEMS", "drums,bass,other,vocals".into()), + let mut tags = vec![ + ("CLUBCRAFT_STEMS", "drums,bass,other,vocals".to_string()), ("CLUBCRAFT_MODEL", model.to_string()), ("CLUBCRAFT_SCALE", format!("{scale}")), - ]); + ]; + tags.extend(extra_tags.iter().cloned()); + let tags = vorbis_comment(&tags); stream.add_metadata_block(MetadataBlockData::new_unknown(4, &tags).map_err(|e| anyhow!("{e:?}"))?); let mut sink = flacenc::bitsink::ByteSink::new(); stream.write(&mut sink).map_err(|e| anyhow!("flac write: {e:?}"))?; @@ -210,7 +253,7 @@ pub fn save_stems_with_model(path: &Path, stems: &Stems, model: &str) -> Result< } let n = stems.frames(); let peak = stems.stems.iter().flat_map(|(l, r)| l.iter().chain(r)).fold(0f32, |m, v| m.max(v.abs())); - write_flac(path, n, model, peak, &|a, b, k, out| { + write_flac(path, n, model, peak, &[], &|a, b, k, out| { for f in a..b { for (l, r) in &stems.stems { out.push(q(l[f], k)); @@ -227,11 +270,22 @@ pub fn save_stem_track(path: &Path, t: &StemTrack) -> Result<()> { /// [`save_stem_track`], recording `model` as the model that produced the stems. pub fn save_stem_track_as(path: &Path, t: &StemTrack, model: &str) -> Result<()> { + write_stem_track(path, t, model, &[]) +} + +/// Save the chunks of `t` that are ready as a partial stem file: the other chunks are silence and +/// the file lists the real ones (`CLUBCRAFT_CHUNKS`), so [`load_stem_track`] leaves them unset. +pub fn save_partial_stem_track(path: &Path, t: &StemTrack, model: &str) -> Result<()> { + let ready: Vec = (0..t.num_chunks()).filter(|&c| t.is_chunk_ready(c)).collect(); + write_stem_track(path, t, model, &[("CLUBCRAFT_CHUNKS", format_chunks(&ready))]) +} + +fn write_stem_track(path: &Path, t: &StemTrack, model: &str, extra_tags: &[(&str, String)]) -> Result<()> { let peak = (0..t.num_chunks()) .filter_map(|c| t.chunk(c)) .map(|c| c.peak()) .fold(0f32, f32::max); - write_flac(path, t.frames, model, peak, &|a, b, k, out| { + write_flac(path, t.frames, model, peak, extra_tags, &|a, b, k, out| { // Blocks never straddle chunks: BLOCK divides STEM_CHUNK_FRAMES. let c = a / STEM_CHUNK_FRAMES; let o = (a - c * STEM_CHUNK_FRAMES) * STEM_CHANNELS; @@ -246,7 +300,7 @@ pub fn save_stem_track_as(path: &Path, t: &StemTrack, model: &str) -> Result<()> /// Decode a stem cache file into interleaved 8-channel chunks of `STEM_CHUNK_FRAMES` frames /// (already scaled). FLAC frames are independent, so chunks are decoded on several threads. /// Returns (frames, model, chunks). -fn decode(path: &Path) -> Result<(usize, String, Vec)> { +fn decode(path: &Path) -> Result<(usize, String, Vec, Option>)> { use symphonia::core::codecs::audio::AudioDecoderOptions; use symphonia::core::errors::Error; use symphonia::core::formats::probe::Hint; @@ -262,6 +316,7 @@ fn decode(path: &Path) -> Result<(usize, String, Vec)> { } let scale: f32 = tag("CLUBCRAFT_SCALE").and_then(|s| s.parse().ok()).unwrap_or(1.0); let model = tag("CLUBCRAFT_MODEL").unwrap_or_default(); + let partial = tag("CLUBCRAFT_CHUNKS").map(|v| parse_chunks(&v)); // STREAMINFO (always the first block): block sizes and total sample count. let si = bytes.get(8..26).ok_or_else(|| anyhow!("truncated STREAMINFO"))?; let (min_bs, max_bs) = (u16::from_be_bytes([si[0], si[1]]) as usize, u16::from_be_bytes([si[2], si[3]]) as usize); @@ -346,12 +401,12 @@ fn decode(path: &Path) -> Result<(usize, String, Vec)> { Ok(()) })?; let chunks = chunks.into_iter().map(|c| c.ok_or_else(|| anyhow!("missing chunk"))).collect::>>()?; - Ok((total, model, chunks)) + Ok((total, model, chunks, partial)) } /// Load a stem cache file into planar [`Stems`]. pub fn load_stems(path: &Path) -> Result { - let (frames, _, chunks) = decode(path)?; + let (frames, _, chunks, _) = decode(path)?; let mut stems: [(Vec, Vec); 4] = Default::default(); std::thread::scope(|s| { for (k, (l, r)) in stems.iter_mut().enumerate() { @@ -371,12 +426,15 @@ pub fn load_stems(path: &Path) -> Result { Ok(Stems { sample_rate: SAMPLE_RATE, stems }) } -/// Load a stem cache file as a complete [`StemTrack`] (for the live engine). +/// Load a stem cache file as a [`StemTrack`] (for the live engine). Of a partial file only the +/// chunks it holds are set. pub fn load_stem_track(path: &Path) -> Result { - let (frames, model, chunks) = decode(path)?; + let (frames, model, chunks, partial) = decode(path)?; let t = StemTrack::new(frames, model); for (i, c) in chunks.into_iter().enumerate() { - t.set_stem_chunk(i, c); + if partial.as_ref().is_none_or(|p| p.contains(&i)) { + t.set_stem_chunk(i, c); + } } Ok(t) } @@ -427,4 +485,23 @@ mod tests { assert_eq!(read_model_name(&p).unwrap(), None); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn partial_files_keep_only_their_chunks() { + let t = StemTrack::new(2 * STEM_CHUNK_FRAMES + 1000, "htdemucs"); + let chunk = |i: usize, v: f32| vec![v; t.chunk_frames(i) * STEM_CHANNELS]; + t.set_chunk(0, chunk(0, 0.25)); + t.set_chunk(2, chunk(2, -0.5)); + let dir = std::env::temp_dir().join(format!("ccstems-partial-{}", std::process::id())); + let p = dir.join("t.regions.stems.flac"); + save_partial_stem_track(&p, &t, "htdemucs").unwrap(); + assert_eq!(read_partial_chunks(&p).unwrap(), Some(vec![0, 2])); + assert_eq!(read_model_name(&p).unwrap().as_deref(), Some("htdemucs")); + let back = load_stem_track(&p).unwrap(); + assert!(back.is_chunk_ready(0) && !back.is_chunk_ready(1) && back.is_chunk_ready(2)); + assert!((back.frame(10).unwrap()[0] - 0.25).abs() < 1e-3); + assert!((back.frame(2 * STEM_CHUNK_FRAMES + 10).unwrap()[7] + 0.5).abs() < 1e-3); + assert_eq!(parse_chunks(&format_chunks(&[0, 1, 2, 5, 7, 8])), vec![0, 1, 2, 5, 7, 8]); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/crates/stems/src/lib.rs b/crates/stems/src/lib.rs index 8ad6d81..8d50405 100644 --- a/crates/stems/src/lib.rs +++ b/crates/stems/src/lib.rs @@ -48,7 +48,7 @@ pub mod stft; pub use backend::BackendKind; pub use roformer::Roformer; -pub use cache::{load_stem_track, load_stems, save_stem_track, save_stem_track_as, read_model_name, save_stems, save_stems_with_model}; +pub use cache::{load_stem_track, load_stems, read_model_name, read_partial_chunks, save_partial_stem_track, save_stem_track, save_stem_track_as, save_stems, save_stems_with_model}; pub use model::Manifest; use anyhow::{bail, Result}; From 2c6d0429e473631e295253d24d2b658fa5c3cf21 Mon Sep 17 00:00:00 2001 From: peakflyer <69652960+beneaze@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:38:21 +0200 Subject: [PATCH 3/3] v0.3.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- README.md | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f8029e..44ad9fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,7 +410,7 @@ dependencies = [ [[package]] name = "clubcraft" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "chrono", @@ -442,7 +442,7 @@ dependencies = [ [[package]] name = "clubcraft-analysis" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clubcraft-audio", @@ -459,7 +459,7 @@ dependencies = [ [[package]] name = "clubcraft-audio" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clubcraft-core", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "clubcraft-core" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "blake3", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "clubcraft-engine" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "cc", @@ -505,7 +505,7 @@ dependencies = [ [[package]] name = "clubcraft-stems" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "block2 0.6.2", diff --git a/Cargo.toml b/Cargo.toml index a7b5a16..fbd2bb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.2.1" +version = "0.3.0" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/beneaze/ClubCraft" diff --git a/README.md b/README.md index fac54f4..9964bfd 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Every screenshot here is the real app at work on a mix imported from DJ.Studio.

Design the whole set

Drop tracks in and ✨ Harmonize orders them by Camelot key, tempo and energy arc, using simulated annealing over the whole set. Mix-in and mix-out points come from the analysed structure, and transition lengths fit the intro and outro of each pair.

- Slide tracks and transitions by bars, collapse lanes, solo or mute them, shape a lane with its EQ and filter, and render the result to WAV with a cue sheet, faster than real time. Everything else lives in a DJ.Studio-style bar at the bottom: the transport, plus tabs for Zoom, Playlist (a sortable track table with keys, harmonic matches, energy and BPM), Transition, Track, Mashups and Master. Two sample lanes take one-shots, loops and FX dropped from Finder. Drag across a track's waveform to select a range and right-click to copy it to a sample lane: the full sound, or just its drums, bass, melody, vocals, instrumental or drums + bass (⌘L, ⇧⌘1–6). The copy is stretched and key-shifted like the mix plays it, or you can save it as a WAV. Every edit can be undone. + Slide tracks and transitions by bars, collapse lanes, solo or mute them, shape a lane with its EQ and filter, and render the result to WAV with a cue sheet, faster than real time. Everything else lives in a DJ.Studio-style bar at the bottom: the transport, plus tabs for Zoom, Playlist (a sortable track table with keys, harmonic matches, energy and BPM), Transition, Track, Mashups and Master. Two sample lanes take one-shots, loops and FX dropped from Finder. Drag across a track's waveform to select a range and right-click to copy it to a sample lane: the full sound, or just its drums, bass, melody, vocals, instrumental or drums + bass (⌘L, ⇧⌘1–6). The copy is stretched and key-shifted like the mix plays it, or you can save it as a WAV. Every edit can be undone, and savepoints keep versions of a mix to switch between (none is lost on the way); Duplicate copies a mix to try something out. The live view during an automix transition: two scrolling stem-coloured waveforms in phase, deck A playing Define (Club Mix) and deck B Alone, both at 122.5 BPM with eight auto-generated hot cues, the mixer in the middle with the automix moving the channel faders, and the browser with the automix queue below @@ -92,7 +92,7 @@ Every screenshot here is the real app at work on a mix imported from DJ.Studio.
The library: BPM, key, energy and cues for every analysed track.

A library that analyses itself

- Drop a folder in and every track is analysed within about a second. Stems are separated on demand: when a transition needs them, when you export a mix, or for the whole library in the background if you switch that on. Sort and search by artist, title, BPM, key or energy, and drag tracks onto a deck or into a mix. + Drop a folder in and every track is analysed within about a second. Stems are separated on demand: the parts of a track that a stem transition plays in, in seconds at the Fast quality, as soon as a mix needs them; whole tracks at your export quality before an export; or the whole library in the background if you switch that on. Sort and search by artist, title, BPM, key or energy, and drag tracks onto a deck or into a mix.

Imports your DJ.Studio projects: mix points, cues, transitions with their curves, stem automation and DJ.Studio's timing. The importer is built clean-room, from DJ.Studio's own files and exports only ([how it works](docs/interop/djstudio.md)). Exports beat grids, keys, hot cues and automix points to VirtualDJ. @@ -118,7 +118,7 @@ Every screenshot here is the real app at work on a mix imported from DJ.Studio.

To switch stems off in any part of a song, drag across the waveform and right-click: mute drums, bass, melody or vocals there, or make that part an acapella or an instrumental.

- Until a track has stems, its stem curves are imitated with volume and EQ: each transition keeps its own shape, and the timeline shows it marked "no stems". Stems missing in the transport bar separates them for the whole mix in one click, and an export separates the ones it needs first. The editor also warns when the outgoing track fades out before its transition ends, or when the incoming track comes in much quieter. + Stems for a mix's stem transitions are separated automatically, only for the transition windows and at the Fast quality, so they are ready in seconds per transition. Until then the stem curves are imitated with volume and EQ: each transition keeps its own shape, and the timeline shows it marked "no stems". An export separates the whole tracks at the export quality first. The editor also warns when the outgoing track fades out before its transition ends, or when the incoming track comes in much quieter.

Keyboard first

Studio: Space play · ← → bar (⇧ 8 bars) · [ ] transitions · F follow · + − zoom · ⌘Z / ⇧⌘Z undo / redo · ⌘S save.
Live: Z X C and M , . cue/play/sync · 1–8 hot cues (⇧ deck B) · ← → crossfader.
@@ -261,12 +261,12 @@ Your library, stems, waveforms, exports and recordings live in `~/Music/ClubCraf ## Downloads -**[Latest release: v0.2.1](../../releases/tag/v0.2.1)** +**[Latest release: v0.3.0](../../releases/tag/v0.3.0)** | Platform | File | Notes | |---|---|---| -| macOS 13+ (Apple Silicon) | `ClubCraft-0.2.1-macos-arm64.dmg` | All CoreML stem models and the COCOLA mashup model bundled. Ad-hoc signed: right-click › Open the first time | -| Windows 10/11 (x64) | `ClubCraft-0.2.1-windows-x64.zip` | `ClubCraft.exe`, `DirectML.dll`, the ONNX stem models and the COCOLA mashup model | +| macOS 13+ (Apple Silicon) | `ClubCraft-0.3.0-macos-arm64.dmg` | All CoreML stem models and the COCOLA mashup model bundled. Ad-hoc signed: right-click › Open the first time | +| Windows 10/11 (x64) | `ClubCraft-0.3.0-windows-x64.zip` | `ClubCraft.exe`, `DirectML.dll`, the ONNX stem models and the COCOLA mashup model | Development builds: `tools/bundle-macos.sh` (macOS), and the `ClubCraft-windows-x64` artifact of every [CI run](../../actions) (Windows, without models).