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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
3 changes: 3 additions & 0 deletions crates/app/src/live/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
66 changes: 48 additions & 18 deletions crates/app/src/live/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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));
}
}
}

Expand Down Expand Up @@ -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 });
}
}
}
3 changes: 3 additions & 0 deletions crates/app/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
37 changes: 28 additions & 9 deletions crates/app/src/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32>, 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<f32>, 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<f32>, 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<f32>, 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<f32>, 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() {
Expand All @@ -45,8 +52,8 @@ fn knob_impl(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>,
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);
Expand All @@ -72,15 +79,26 @@ fn knob_impl(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>,
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::<f32>(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);
}
Expand Down Expand Up @@ -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() {
Expand Down
11 changes: 11 additions & 0 deletions crates/engine/src/conductor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ pub(crate) struct Conductor {
pub finished: bool,
pub serial: u64,
slot_track: [Option<usize>; 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,
}

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions crates/engine/tests/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32> = (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::<f64>();
}
(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})");
}
Loading