diff --git a/Cargo.lock b/Cargo.lock index 44ad9fb..dff4cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,7 +410,7 @@ dependencies = [ [[package]] name = "clubcraft" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -442,7 +442,7 @@ dependencies = [ [[package]] name = "clubcraft-analysis" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "clubcraft-audio", @@ -459,7 +459,7 @@ dependencies = [ [[package]] name = "clubcraft-audio" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "clubcraft-core", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "clubcraft-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "blake3", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "clubcraft-engine" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "cc", @@ -505,7 +505,7 @@ dependencies = [ [[package]] name = "clubcraft-stems" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "block2 0.6.2", diff --git a/Cargo.toml b/Cargo.toml index fbd2bb5..04f17cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/beneaze/ClubCraft" diff --git a/README.md b/README.md index 9964bfd..b01cc4b 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,12 @@ Your library, stems, waveforms, exports and recordings live in `~/Music/ClubCraf ## Downloads -**[Latest release: v0.3.0](../../releases/tag/v0.3.0)** +**[Latest release: v0.3.1](../../releases/tag/v0.3.1)** | Platform | File | Notes | |---|---|---| -| 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 | +| macOS 13+ (Apple Silicon) | `ClubCraft-0.3.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.3.1-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). diff --git a/crates/app/src/live/adapter.rs b/crates/app/src/live/adapter.rs index b1adaa7..f18f95e 100644 --- a/crates/app/src/live/adapter.rs +++ b/crates/app/src/live/adapter.rs @@ -294,6 +294,9 @@ impl LiveEngine { }; if let Some(h) = &handle { let _ = h.send(Command::SetAutoGain(true)); + // the crossfader mixes deck A (left) into deck B (right); channels start "thru" + let _ = h.send(Command::SetCrossfaderAssign(LIVE_DECKS[0], clubcraft_engine::CrossfaderAssign::A)); + let _ = h.send(Command::SetCrossfaderAssign(LIVE_DECKS[1], clubcraft_engine::CrossfaderAssign::B)); } let cache = TrackCache::default(); cache.spawn_pinner(); diff --git a/crates/app/src/live/view.rs b/crates/app/src/live/view.rs index 31c6e38..a54b94d 100644 --- a/crates/app/src/live/view.rs +++ b/crates/app/src/live/view.rs @@ -296,20 +296,24 @@ impl LiveView { if widgets::toggle(ui, d.keylock, "KEYLOCK", accent, vec2(56.0, 20.0)).clicked() { out.push(LiveAction::Keylock(i, !d.keylock)); } + // three buttons across the column's 56 px: small padding, so the labels don't widen them + let key_w = (56.0 - 2.0 * 2.0) / 3.0; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - if ui.add(egui::Button::new("-").min_size(vec2(17.0, 18.0))).on_hover_text("Key down").clicked() { + ui.spacing_mut().button_padding = vec2(1.0, 1.0); + if ui.add(egui::Button::new("-").min_size(vec2(key_w, 18.0))).on_hover_text("Key down").clicked() { out.push(LiveAction::KeyShift(i, d.key_shift - 1)); } - if ui.add(egui::Button::new(RichText::new("K").size(10.0)).min_size(vec2(17.0, 18.0))).on_hover_text("Match key to master (click again to reset)").clicked() { + if ui.add(egui::Button::new(RichText::new("K").size(10.0)).min_size(vec2(key_w, 18.0))).on_hover_text("Match key to master (click again to reset)").clicked() { out.push(if d.key_shift != 0 { LiveAction::KeyShift(i, 0) } else { LiveAction::MatchKey(i) }); } - if ui.add(egui::Button::new("+").min_size(vec2(17.0, 18.0))).on_hover_text("Key up").clicked() { + if ui.add(egui::Button::new("+").min_size(vec2(key_w, 18.0))).on_hover_text("Key up").clicked() { out.push(LiveAction::KeyShift(i, d.key_shift + 1)); } }); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; + ui.spacing_mut().button_padding = vec2(1.0, 1.0); for (lbl, s) in [("◀", -1.0f32), ("▶", 1.0)] { let r = ui.add(egui::Button::new(lbl).min_size(vec2(27.0, 18.0)).sense(Sense::click_and_drag())).on_hover_text("Pitch bend"); let (p, rel) = press_release(ui, &r); @@ -472,7 +476,7 @@ impl LiveView { ui.add_enabled_ui(d.has_stems, |ui| { for k in [StemKind::Vocals, StemKind::Other, StemKind::Bass, StemKind::Drums] { let mut g = d.stem_gains[k.index()]; - if widgets::knob(ui, &mut g, 0.0..=1.0, 1.0, k.label(), theme::stem_color(k), 34.0).changed() { + if widgets::knob_level(ui, &mut g, 0.0..=1.0, 1.0, k.label(), theme::stem_color(k), 34.0).changed() { out.push(LiveAction::StemGain(i, k.index(), g)); } } @@ -523,6 +527,7 @@ impl LiveView { self.channel(&mut cu, i, d, out); } let mut mu = ui.new_child(egui::UiBuilder::new().max_rect(cols[1]).layout(Layout::top_down(Align::Center))); + beat_bars(&mut mu, decks); self.master_col(&mut mu, m, out); let xr = Rect::from_min_max(pos2(inner.left() + 14.0, inner.bottom() - 48.0), pos2(inner.right() - 14.0, inner.bottom() - 20.0)); let mut xu = ui.new_child(egui::UiBuilder::new().max_rect(xr).layout(Layout::left_to_right(Align::Center))); @@ -560,20 +565,26 @@ impl LiveView { if widgets::toggle(ui, d.pfl, "🎧", accent, vec2(40.0, 20.0)).on_hover_text("Headphone cue").clicked() { out.push(LiveAction::Pfl(i, !d.pfl)); } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - widgets::vu(ui, d.vu[0], d.vu[1], vec2(12.0, 84.0)); - // the fader shows what plays: automix moves it with its volume automation - let auto = d.auto_volume.clamp(0.0, 1.0); - let mut v = d.fader * auto; - let r = widgets::vfader(ui, &mut v, 1.0, vec2(34.0, 88.0), accent); - if r.changed() { - out.push(LiveAction::Fader(i, if auto > 0.05 { (v / auto).min(1.0) } else { v })); - } - if auto < 0.999 { - r.on_hover_text(format!("Automix volume {:.0} %", auto * 100.0)); - } - }); + ui.add_space(8.0); + // the fader is centred under the knobs above it; the level meter sits beside it on + // the channel's outer side (left deck left, right deck right) + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 88.0), Sense::hover()); + let fr = Rect::from_center_size(row.center(), vec2(34.0, 88.0)); + let vx = if i.is_multiple_of(2) { fr.left() - 3.0 - 12.0 } else { fr.right() + 3.0 }; + let vr = Rect::from_min_size(pos2(vx, row.center().y - 42.0), vec2(12.0, 84.0)); + let mut vu_ui = ui.new_child(egui::UiBuilder::new().max_rect(vr)); + widgets::vu(&mut vu_ui, d.vu[0], d.vu[1], vr.size()); + let mut fu = ui.new_child(egui::UiBuilder::new().max_rect(fr)); + // the fader shows what plays: automix moves it with its volume automation + let auto = d.auto_volume.clamp(0.0, 1.0); + let mut v = d.fader * auto; + let r = widgets::vfader(&mut fu, &mut v, 1.0, fr.size(), accent); + if r.changed() { + out.push(LiveAction::Fader(i, if auto > 0.05 { (v / auto).min(1.0) } else { v })); + } + if auto < 0.999 { + r.on_hover_text(format!("Automix volume {:.0} %", auto * 100.0)); + } } } @@ -607,3 +618,22 @@ impl LiveView { } } } + +/// Where each deck is in its bar: one row of beats per deck (A on top) in the deck's colour, the +/// current beat lit. Dark for a deck without a track or beat grid. +fn beat_bars(ui: &mut Ui, decks: &[DeckUi]) { + let (pill, gap) = (vec2(28.0, 8.0), vec2(5.0, 4.0)); + let rows = decks.len().min(2); + let (r, _) = ui.allocate_exact_size(vec2(4.0 * pill.x + 3.0 * gap.x, rows as f32 * pill.y + (rows.saturating_sub(1)) as f32 * gap.y + 4.0), Sense::hover()); + let p = ui.painter(); + for (row, d) in decks.iter().take(rows).enumerate() { + let grid = d.grid.filter(|_| d.loaded); + let n = grid.map_or(4, |g| g.beats_per_bar.clamp(1, 8)) as usize; + let lit = grid.map(|g| (g.beat_at(d.position).floor() as i64).rem_euclid(n as i64) as usize); + let w = (r.width() - (n - 1) as f32 * gap.x) / n as f32; + for k in 0..n { + let b = Rect::from_min_size(pos2(r.left() + k as f32 * (w + gap.x), r.top() + row as f32 * (pill.y + gap.y)), vec2(w, pill.y)); + p.rect_filled(b, pill.y / 2.0, if lit == Some(k) { theme::deck_color(row) } else { theme::BG }); + } + } +} diff --git a/crates/app/src/theme.rs b/crates/app/src/theme.rs index c79ed1e..8e90bb7 100644 --- a/crates/app/src/theme.rs +++ b/crates/app/src/theme.rs @@ -118,6 +118,9 @@ pub fn apply(ctx: &egui::Context) { ctx.set_theme(egui::Theme::Dark); ctx.set_visuals_of(egui::Theme::Dark, v); ctx.style_mut_of(egui::Theme::Dark, |s| { + // label text isn't selectable: hovering a track name showed a text cursor, and dragging it + // selected text instead of dragging the track + s.interaction.selectable_labels = false; s.spacing.item_spacing = egui::vec2(6.0, 5.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.text_styles.insert(TextStyle::Body, FontId::new(13.0, FontFamily::Proportional)); diff --git a/crates/app/src/widgets.rs b/crates/app/src/widgets.rs index 748fab5..2018b94 100644 --- a/crates/app/src/widgets.rs +++ b/crates/app/src/widgets.rs @@ -6,24 +6,31 @@ 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_impl(ui, value, range, default, label, color, size, None, false) +} + +/// [`knob`] for a level (a stem's volume): the coloured arc runs from the bottom of the range to +/// the value, so a part that plays is coloured and one turned off is not. +#[allow(clippy::too_many_arguments)] +pub fn knob_level(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, true) } /// [`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)) + knob_impl(ui, value, range, default, label, color, size, Some(text), false) } #[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 { +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>, level: bool) -> 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() { let fine = ui.input(|i| i.modifiers.shift); let d = -resp.drag_delta().y / if fine { 600.0 } else { 150.0 }; - *value = (*value + d * (hi - lo)).clamp(lo, hi); + *value = drag_to(ui, &resp, *value, |v| (v + d * (hi - lo)).clamp(lo, hi)); resp.mark_changed(); } if resp.double_clicked() { @@ -45,8 +52,8 @@ fn knob_impl(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, let t = (*value - lo) / (hi - lo); let a0 = std::f32::consts::PI * 0.75; let sweep = std::f32::consts::PI * 1.5; - let td = (default - lo) / (hi - lo); - // arc from default to value + // arc from the default to the value (a level: from the bottom of the range) + let td = if level { 0.0 } else { (default - lo) / (hi - lo) }; let (from, to) = if t < td { (t, td) } else { (td, t) }; let steps = 24; let mut pts = Vec::with_capacity(steps + 1); @@ -72,15 +79,26 @@ fn knob_impl(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, resp } +/// One step of a drag: `step` applied to the value the drag has reached so far, which the widget +/// keeps from the drag's start. The value the caller passes in comes back from the engine's state +/// a buffer or two late; dragging from it pulled the handle back and made it jump. +fn drag_to(ui: &Ui, resp: &Response, value: f32, step: impl FnOnce(f32) -> f32) -> f32 { + let base = if resp.drag_started() { value } else { ui.data(|d| d.get_temp::(resp.id)).unwrap_or(value) }; + let v = step(base); + ui.data_mut(|d| d.insert_temp(resp.id, v)); + v +} + /// Vertical fader. `value` 0..1 (top = 1). Double-click resets. pub fn vfader(ui: &mut Ui, value: &mut f32, default: f32, size: egui::Vec2, color: Color32) -> Response { let (rect, mut resp) = ui.allocate_exact_size(size, Sense::click_and_drag()); let track = Rect::from_center_size(rect.center(), vec2(4.0, rect.height() - 14.0)); if resp.dragged() || resp.clicked() { if let Some(pos) = resp.interact_pointer_pos() { - if resp.dragged() && !resp.drag_started() { + if resp.dragged() { let fine = ui.input(|i| i.modifiers.shift); - *value = (*value - resp.drag_delta().y / track.height() * if fine { 0.2 } else { 1.0 }).clamp(0.0, 1.0); + let d = resp.drag_delta().y / track.height() * if fine { 0.2 } else { 1.0 }; + *value = drag_to(ui, &resp, *value, |v| (v - d).clamp(0.0, 1.0)); } else if resp.clicked() { *value = (1.0 - (pos.y - track.top()) / track.height()).clamp(0.0, 1.0); } @@ -113,7 +131,8 @@ pub fn hfader_with_default(ui: &mut Ui, value: &mut f32, bipolar: bool, default: let (lo, hi) = if bipolar { (-1.0, 1.0) } else { (0.0, 1.0) }; if resp.dragged() { let fine = ui.input(|i| i.modifiers.shift); - *value = (*value + resp.drag_delta().x / track.width() * (hi - lo) * if fine { 0.2 } else { 1.0 }).clamp(lo, hi); + let d = resp.drag_delta().x / track.width() * (hi - lo) * if fine { 0.2 } else { 1.0 }; + *value = drag_to(ui, &resp, *value, |v| (v + d).clamp(lo, hi)); resp.mark_changed(); } if resp.double_clicked() { diff --git a/crates/engine/src/conductor.rs b/crates/engine/src/conductor.rs index 42656dd..3c746b9 100644 --- a/crates/engine/src/conductor.rs +++ b/crates/engine/src/conductor.rs @@ -253,6 +253,9 @@ pub(crate) struct Conductor { pub finished: bool, pub serial: u64, slot_track: [Option; 2], + /// Crossfader assignment of the schedule's decks before it started (the mix plays them + /// through, with its own volume automation); given back when it stops. + saved_assign: [Option<(DeckId, CrossfaderAssign)>; 2], state: MixPlaybackState, } @@ -309,6 +312,9 @@ impl Conductor { if let Some(ch) = mixer.channels.get_mut(d) { ch.reset_automation(); ch.fader = 1.0; + if let Some(k) = sched.decks.iter().position(|&x| x == d) { + self.saved_assign[k] = Some((d, ch.assign)); + } ch.assign = CrossfaderAssign::Thru; } } @@ -349,6 +355,11 @@ impl Conductor { ch.reset_automation(); } } + for (d, a) in self.saved_assign.iter_mut().filter_map(Option::take) { + if let Some(ch) = mixer.channels.get_mut(d) { + ch.assign = a; + } + } self.playing = false; self.slot_track = [None; 2]; Some(s) diff --git a/crates/engine/tests/engine.rs b/crates/engine/tests/engine.rs index dea9569..f550cc6 100644 --- a/crates/engine/tests/engine.rs +++ b/crates/engine/tests/engine.rs @@ -959,3 +959,48 @@ fn transition_stem_lanes_mute_each_stem_on_both_sides() { } } } + +#[test] +fn crossfader_mixes_assigned_decks_and_a_mix_gives_them_back() { + let mut core = EngineCore::new(SR, 2); + let n = SR as usize * 20; + let s: Vec = (0..n).map(|i| (2.0 * std::f64::consts::PI * 440.0 * i as f64 / SR as f64).sin() as f32 * 0.5).collect(); + let tone = Arc::new(LoadedTrack::new("tone", mono(&s))); + core.apply(Command::SetCrossfaderAssign(0, CrossfaderAssign::A)); + core.apply(Command::SetCrossfaderAssign(1, CrossfaderAssign::B)); + core.apply(Command::LoadTrack(0, tone.clone())); + core.apply(Command::SetKeylock(0, false)); + core.apply(Command::Play(0)); + let mut m = vec![0f32; 256 * 2]; + let mut c = vec![0f32; 256 * 2]; + let mut rms = |core: &mut EngineCore| { + let mut acc = 0f64; + for _ in 0..100 { + core.process(&mut m, &mut c); + acc += m.iter().map(|v| (*v as f64).powi(2)).sum::(); + } + (acc / (100.0 * m.len() as f64)).sqrt() + }; + core.apply(Command::SetCrossfader(0.0)); + let _ = rms(&mut core); + let on_a = rms(&mut core); + core.apply(Command::SetCrossfader(1.0)); + let _ = rms(&mut core); + let on_b = rms(&mut core); + eprintln!("deck A with the crossfader at A {on_a:.4}, at B {on_b:.4}"); + assert!(on_a > 0.1 && on_b < 1e-3, "the crossfader should fade deck A out ({on_a} → {on_b})"); + // a mix on decks 0/1 plays them through and gives the assignment back when it stops + let mix = two_track_mix(TransitionStyle::Crossfade, false); + let tr = |id: i64| Some(if id == 1 { tone.clone() } else { track(&s, 126.0, 0.0) }); + let mut player = MixPlayer::new(&mix, &tr, [0, 1]).unwrap(); + player.play(&mut core, 0.0).unwrap(); + let _ = rms(&mut core); + let during = rms(&mut core); + player.stop(&mut core).unwrap(); + core.apply(Command::Play(0)); + let _ = rms(&mut core); + let after = rms(&mut core); + eprintln!("mix playing with the crossfader at B {during:.4}; deck A after the mix {after:.4}"); + assert!(during > 0.1, "a mix plays its decks through, whatever the crossfader"); + assert!(after < 1e-3, "after the mix deck A is on side A again ({after})"); +}