From de2021d9b89b7f8e2298505184e7b8981e9ffa4d Mon Sep 17 00:00:00 2001 From: deadYokai Date: Mon, 20 Apr 2026 23:26:43 +0300 Subject: [PATCH] redesign --- crates/ui/src/app.rs | 113 ++++++------ crates/ui/src/main.rs | 1 + crates/ui/src/panels/advanced.rs | 3 + crates/ui/src/panels/env_lfo_sidebar.rs | 169 +++++++++++++++++ crates/ui/src/panels/filter.rs | 123 +++++++++---- crates/ui/src/panels/fx.rs | 120 ++++++++---- crates/ui/src/panels/header.rs | 56 ++++-- crates/ui/src/panels/lfo.rs | 73 +++++--- crates/ui/src/panels/macro_bar.rs | 28 ++- crates/ui/src/panels/mod.rs | 3 + crates/ui/src/panels/mod_matrix.rs | 134 +++++++++++--- crates/ui/src/panels/osc.rs | 82 ++++++--- crates/ui/src/panels/voice.rs | 10 + crates/ui/src/theme.css | 203 +++++++++++++++++---- crates/ui/src/widgets/env_display.rs | 25 +++ crates/ui/src/widgets/filter_response.rs | 114 ++++++++++++ crates/ui/src/widgets/mod.rs | 4 +- crates/ui/src/widgets/spectrum.rs | 45 +++++ crates/ui/src/widgets/wavetable_display.rs | 65 ++++++- 19 files changed, 1109 insertions(+), 262 deletions(-) create mode 100644 crates/ui/src/panels/advanced.rs create mode 100644 crates/ui/src/panels/env_lfo_sidebar.rs create mode 100644 crates/ui/src/panels/voice.rs create mode 100644 crates/ui/src/widgets/filter_response.rs create mode 100644 crates/ui/src/widgets/spectrum.rs diff --git a/crates/ui/src/app.rs b/crates/ui/src/app.rs index 02c0cdd..219e539 100644 --- a/crates/ui/src/app.rs +++ b/crates/ui/src/app.rs @@ -3,33 +3,23 @@ use vizia::prelude::*; use crate::widgets::piano::PianoKeyboard; +pub const MOD_NSRC: usize = 19; +pub const MOD_NDST: usize = 11; + #[derive(Clone, Copy, Debug, PartialEq, Data)] pub enum Panel { - Osc, - Env, - Lfo, - Filter, - Fx, - ModMatrix, + Voice, + Effects, + Matrix, } impl Panel { - pub const ALL: &'static [Self] = &[ - Self::Osc, - Self::Env, - Self::Lfo, - Self::Filter, - Self::Fx, - Self::ModMatrix, - ]; + pub const ALL: &'static [Self] = &[Self::Voice, Self::Effects, Self::Matrix]; pub fn label(self) -> &'static str { match self { - Self::Osc => "OSC", - Self::Env => "ENV", - Self::Lfo => "LFO", - Self::Filter => "FLT", - Self::Fx => "FX", - Self::ModMatrix => "MOD", + Self::Voice => "VOICE", + Self::Effects => "EFFECTS", + Self::Matrix => "MATRIX", } } } @@ -46,6 +36,11 @@ pub struct AppData { pub store: ParamStore, pub held_notes: Vec, pub octave: i32, + pub mod_depths: Vec, + pub fx_enabled: Vec, + pub active_env: usize, + pub active_lfo: usize, + pub fx_selected: usize, } impl AppData { @@ -56,13 +51,18 @@ impl AppData { Self { params, store, - active_panel: Panel::Osc, + active_panel: Panel::Voice, preset_name: "Init".into(), voice_count: 0, cpu_load: 0.0, host_bpm: 120.0, held_notes: Vec::new(), octave: 4, + mod_depths: vec![0.0; MOD_NSRC * MOD_NDST], + fx_enabled: vec![true; 6], + active_env: 0, + active_lfo: 0, + fx_selected: 0, } } } @@ -70,13 +70,18 @@ impl AppData { #[derive(Debug)] pub enum AppEvent { SetParam(ParamId, f32), + SetParamRaw(ParamId, f32), SetPanel(Panel), UpdateMetrics { voices: u8, cpu: f32 }, - NoteOn(u8, u8), NoteOff(u8), OctaveUp, OctaveDown, + SetModDepth { src: usize, dst: usize, depth: f32 }, + ToggleFx(usize), + SelectEnv(usize), + SelectLfo(usize), + SelectFx(usize), } impl Model for AppData { @@ -87,12 +92,16 @@ impl Model for AppData { self.params[*id as usize] = v; self.store.set(*id, v); } + AppEvent::SetParamRaw(id, val) => { + self.params[*id as usize] = *val; + self.store.set(*id, *val); + } AppEvent::SetPanel(p) => self.active_panel = *p, AppEvent::UpdateMetrics { voices, cpu } => { self.voice_count = *voices; self.cpu_load = *cpu; } - AppEvent::NoteOn(note, _vel) => { + AppEvent::NoteOn(note, _) => { if !self.held_notes.contains(note) { self.held_notes.push(*note); } @@ -100,6 +109,20 @@ impl Model for AppData { AppEvent::NoteOff(note) => self.held_notes.retain(|n| n != note), AppEvent::OctaveUp => self.octave = (self.octave + 1).min(8), AppEvent::OctaveDown => self.octave = (self.octave - 1).max(0), + AppEvent::SetModDepth { src, dst, depth } => { + let idx = src * MOD_NDST + dst; + if idx < self.mod_depths.len() { + self.mod_depths[idx] = depth.clamp(-1.0, 1.0); + } + } + AppEvent::ToggleFx(s) => { + if *s < self.fx_enabled.len() { + self.fx_enabled[*s] = !self.fx_enabled[*s]; + } + } + AppEvent::SelectEnv(i) => self.active_env = *i, + AppEvent::SelectLfo(i) => self.active_lfo = *i, + AppEvent::SelectFx(i) => self.fx_selected = *i, }); event.map(|we: &WindowEvent, _| { crate::widgets::piano::handle_kbd(cx, we, self.octave); @@ -112,48 +135,26 @@ pub fn build_root(cx: &mut Context) { crate::panels::header::build(cx); HStack::new(cx, |cx| { - VStack::new(cx, |cx| { - for &p in Panel::ALL { - tab_button(cx, p); - } - }) - .width(Pixels(56.0)) - .height(Stretch(1.0)) - .background_color(Color::rgb(14, 14, 26)); + crate::panels::macro_bar::build(cx); - Binding::new(cx, AppData::active_panel, |cx, panel_lens| { - VStack::new(cx, |cx| match panel_lens.get(cx) { - Panel::Osc => crate::panels::osc::build(cx), - Panel::Env => crate::panels::env_panel::build(cx), - Panel::Lfo => crate::panels::lfo::build(cx), - Panel::Filter => crate::panels::filter::build(cx), - Panel::Fx => crate::panels::fx::build(cx), - Panel::ModMatrix => crate::panels::mod_matrix::build(cx), + Binding::new(cx, AppData::active_panel, |cx, p| { + VStack::new(cx, |cx| match p.get(cx) { + Panel::Voice => crate::panels::voice::build(cx), + Panel::Effects => crate::panels::fx::build(cx), + Panel::Matrix => crate::panels::mod_matrix::build(cx), }) - .class("panel") .width(Stretch(1.0)) - .height(Stretch(1.0)); + .height(Stretch(1.0)) + .background_color(Color::rgb(12, 12, 20)); }); - crate::panels::macro_bar::build(cx); + crate::panels::env_lfo_sidebar::build(cx); }) .height(Stretch(1.0)); + PianoKeyboard::new(cx); }) - .background_color(Color::rgb(18, 18, 28)) + .background_color(Color::rgb(10, 10, 16)) .width(Stretch(1.0)) .height(Stretch(1.0)); } - -fn tab_button(cx: &mut Context, p: Panel) { - Binding::new(cx, AppData::active_panel, move |cx, active_lens| { - let al = active_lens.get(cx) == p; - Label::new(cx, p.label()) - .class("tab") - .checked(al) - .on_press(move |cx| cx.emit(AppEvent::SetPanel(p))) - .width(Stretch(1.0)) - .height(Pixels(44.0)) - .text_align(TextAlign::Center); - }); -} diff --git a/crates/ui/src/main.rs b/crates/ui/src/main.rs index b672133..3873f2e 100644 --- a/crates/ui/src/main.rs +++ b/crates/ui/src/main.rs @@ -12,6 +12,7 @@ fn main() { .expect("theme.css"); AppData::new(ParamStore::new()).build(cx); app::build_root(cx); + cx.focus(); }) .title("Tenko") .inner_size((1280, 760)) diff --git a/crates/ui/src/panels/advanced.rs b/crates/ui/src/panels/advanced.rs new file mode 100644 index 0000000..48c04b2 --- /dev/null +++ b/crates/ui/src/panels/advanced.rs @@ -0,0 +1,3 @@ +use vizia::prelude::*; + +pub fn build(_cx: &mut Context) {} diff --git a/crates/ui/src/panels/env_lfo_sidebar.rs b/crates/ui/src/panels/env_lfo_sidebar.rs new file mode 100644 index 0000000..3486746 --- /dev/null +++ b/crates/ui/src/panels/env_lfo_sidebar.rs @@ -0,0 +1,169 @@ +use crate::app::{AppData, AppEvent}; +use crate::widgets::{ + env_display::EnvDisplay, knob::labeled_knob, wavetable_display::WavetableDisplay, +}; +use params::ParamId; +use vizia::prelude::*; + +const ENV_KNOBS: [[ParamId; 6]; 3] = [ + [ + ParamId::Env1Delay, + ParamId::Env1Attack, + ParamId::Env1Hold, + ParamId::Env1Decay, + ParamId::Env1Sustain, + ParamId::Env1Release, + ], + [ + ParamId::Env2Delay, + ParamId::Env2Attack, + ParamId::Env2Hold, + ParamId::Env2Decay, + ParamId::Env2Sustain, + ParamId::Env2Release, + ], + [ + ParamId::Env3Delay, + ParamId::Env3Attack, + ParamId::Env3Hold, + ParamId::Env3Decay, + ParamId::Env3Sustain, + ParamId::Env3Release, + ], +]; + +const ENV_LABELS: [&str; 6] = ["DELAY", "ATTACK", "HOLD", "DECAY", "SUSTAIN", "RELEASE"]; + +const LFO_KNOBS: [[ParamId; 4]; 4] = [ + [ + ParamId::Lfo1Rate, + ParamId::Lfo1Phase, + ParamId::Lfo1Depth, + ParamId::Lfo1WavePos, + ], + [ + ParamId::Lfo2Rate, + ParamId::Lfo2Phase, + ParamId::Lfo2Depth, + ParamId::Lfo2WavePos, + ], + [ + ParamId::Lfo3Rate, + ParamId::Lfo3Phase, + ParamId::Lfo3Depth, + ParamId::Lfo3WavePos, + ], + [ + ParamId::Lfo4Rate, + ParamId::Lfo4Phase, + ParamId::Lfo4Depth, + ParamId::Lfo4WavePos, + ], +]; + +const SYNC_PARAMS: [ParamId; 4] = [ + ParamId::Lfo1Sync, + ParamId::Lfo2Sync, + ParamId::Lfo3Sync, + ParamId::Lfo4Sync, +]; + +pub fn build(cx: &mut Context) { + VStack::new(cx, |cx| { + env_section(cx); + Element::new(cx) + .height(Pixels(1.0)) + .background_color(Color::from("#1e1e30")); + lfo_section(cx); + }) + .width(Pixels(480.0)) + .background_color(Color::from("#0e0e18")) + .height(Stretch(1.0)); +} + +fn env_section(cx: &mut Context) { + VStack::new(cx, |cx| { + Binding::new(cx, AppData::active_env, |cx, lens| { + let active = lens.get(cx); + HStack::new(cx, |cx| { + for i in 0..3usize { + Label::new(cx, format!("ENV {}", i + 1)) + .class("env-tab") + .checked(active == i) + .on_press(move |cx| cx.emit(AppEvent::SelectEnv(i))); + } + }) + .horizontal_gap(Pixels(4.0)) + .bottom(Pixels(8.0)); + }); + + Binding::new(cx, AppData::active_env, |cx, lens| { + let env_idx = lens.get(cx); + EnvDisplay::new(cx, env_idx) + .width(Stretch(1.0)) + .height(Pixels(100.0)); + + HStack::new(cx, |cx| { + for &k in &ENV_KNOBS[env_idx] { + labeled_knob(cx, k); + } + }) + .horizontal_gap(Pixels(4.0)) + .top(Pixels(8.0)) + .padding_left(Pixels(4.0)); + }); + }) + .padding(Pixels(12.0)) + .height(Pixels(220.0)); +} + +fn lfo_section(cx: &mut Context) { + VStack::new(cx, |cx| { + Binding::new(cx, AppData::active_lfo, |cx, lens| { + let active = lens.get(cx); + HStack::new(cx, |cx| { + for i in 0..4usize { + Label::new(cx, format!("LFO {}", i + 1)) + .class("lfo-tab") + .checked(active == i) + .on_press(move |cx| cx.emit(AppEvent::SelectLfo(i))); + } + }) + .horizontal_gap(Pixels(4.0)) + .bottom(Pixels(8.0)); + }); + + Binding::new(cx, AppData::active_lfo, |cx, lens| { + let lfo_idx = lens.get(cx); + let knobs = &LFO_KNOBS[lfo_idx]; + let sync_id = SYNC_PARAMS[lfo_idx]; + + WavetableDisplay::with_live_param(cx, knobs[3]) + .width(Stretch(1.0)) + .height(Pixels(90.0)); + + HStack::new(cx, |cx| { + for &k in knobs.iter() { + labeled_knob(cx, k); + } + + Binding::new(cx, AppData::params.idx(sync_id as usize), move |cx, sl| { + let synced = sl.get(cx) > 0.5; + Label::new(cx, "SYNC") + .class("sync-toggle") + .checked(synced) + .on_press(move |cx| { + cx.emit(AppEvent::SetParam(sync_id, if synced { 0.0 } else { 1.0 })); + }) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + }); + }) + .horizontal_gap(Pixels(4.0)) + .top(Pixels(8.0)) + .padding_left(Pixels(4.0)); + }); + }) + .padding(Pixels(12.0)) + .height(Stretch(1.0)); +} diff --git a/crates/ui/src/panels/filter.rs b/crates/ui/src/panels/filter.rs index f75ae05..285e7d0 100644 --- a/crates/ui/src/panels/filter.rs +++ b/crates/ui/src/panels/filter.rs @@ -1,4 +1,5 @@ -use crate::widgets::knob::labeled_knob; +use crate::app::{AppData, AppEvent}; +use crate::widgets::{filter_response::FilterResponseDisplay, knob::labeled_knob}; use params::ParamId; use vizia::prelude::*; @@ -16,56 +17,98 @@ const FILTER_KNOBS: [[ParamId; 4]; 2] = [ ParamId::Filter2Keytrack, ], ]; -const TYPES: [&str; 4] = ["LADDER", "SVF", "COMB", "FORMANT"]; +const TYPE_PARAMS: [ParamId; 2] = [ParamId::Filter1Type, ParamId::Filter2Type]; +const FILTER_TYPES: [&str; 4] = ["LADDER", "SVF", "COMB", "FORMANT"]; + +const ROUTING_ID: ParamId = ParamId::FilterRouting; pub fn build(cx: &mut Context) { VStack::new(cx, |cx| { - Label::new(cx, "FILTERS") - .class("section-title") - .bottom(Pixels(6.0)); + Binding::new(cx, AppData::params.idx(ROUTING_ID as usize), |cx, l| { + let serial = l.get(cx) < 0.5; + HStack::new(cx, |cx| { + for (i, &lbl) in ["SERIAL", "PARALLEL"].iter().enumerate() { + let active = if i == 0 { serial } else { !serial }; + Label::new(cx, lbl) + .class("filter-type-btn") + .checked(active) + .on_press(move |cx| { + cx.emit(AppEvent::SetParamRaw(ROUTING_ID, i as f32)); + }); + } + }) + .horizontal_gap(Pixels(4.0)) + .padding(Pixels(6.0)); + }); HStack::new(cx, |cx| { - for m in ["SERIAL", "PARALLEL"] { - Label::new(cx, m).class("tab").padding(Pixels(5.0)); + for slot in 0..2usize { + filter_slot(cx, slot); } }) - .bottom(Pixels(8.0)); + .height(Pixels(160.0)) + .horizontal_gap(Pixels(6.0)) + .padding(Pixels(6.0)); + }) + .background_color(Color::from("#0e0e18")) + .border_width(Pixels(1.0)) + .border_color(Color::from("#1e1e30")); +} - for (i, knobs) in FILTER_KNOBS.iter().enumerate() { - HStack::new(cx, |cx| { - VStack::new(cx, |cx| { - Label::new(cx, format!("FILTER {}", i + 1)).class("knob-label"); +fn filter_slot(cx: &mut Context, slot: usize) { + let type_id = TYPE_PARAMS[slot]; + VStack::new(cx, |cx| { + HStack::new(cx, |cx| { + Element::new(cx) + .class("osc-enable") + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + + Label::new(cx, format!("FILTER {}", slot + 1)) + .class("osc-label") + .left(Pixels(6.0)) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + + Element::new(cx).width(Stretch(1.0)); + + Binding::new( + cx, + AppData::params.idx(type_id as usize), + move |cx, lens| { + let cur = lens.get(cx).round() as usize; HStack::new(cx, |cx| { - for t in TYPES { + for (i, &t) in FILTER_TYPES.iter().enumerate() { Label::new(cx, t) - .class("tab") - .padding(Pixels(3.0)) - .height(Pixels(20.0)); + .class("filter-type-btn") + .checked(cur == i) + .on_press(move |cx| { + cx.emit(AppEvent::SetParamRaw(type_id, i as f32)); + }); } }) - .top(Pixels(4.0)); - Element::new(cx) - .width(Pixels(170.0)) - .height(Pixels(52.0)) - .background_color(Color::from("#12121c")) - .corner_radius(Pixels(3.0)) - .top(Pixels(4.0)); - }) - .width(Pixels(178.0)); - HStack::new(cx, |cx| { - for &p in knobs.iter() { - labeled_knob(cx, p); - } - }) - .horizontal_gap(Pixels(2.0)) - .left(Pixels(10.0)); - }) - .background_color(Color::from("#1a1a28")) - .corner_radius(Pixels(4.0)) - .padding(Pixels(8.0)) - .height(Pixels(120.0)); - } + .horizontal_gap(Pixels(2.0)); + }, + ); + }) + .height(Pixels(22.0)); + + FilterResponseDisplay::new(cx, slot) + .width(Stretch(1.0)) + .height(Stretch(1.0)); + + HStack::new(cx, |cx| { + for &p in FILTER_KNOBS[slot].iter() { + labeled_knob(cx, p); + } + }) + .horizontal_gap(Pixels(4.0)) + .height(Pixels(60.0)) + .padding_top(Pixels(4.0)); }) - .vertical_gap(Pixels(8.0)) - .padding(Pixels(12.0)); + .background_color(Color::from("#111119")) + .corner_radius(Pixels(4.0)) + .padding(Pixels(8.0)) + .width(Stretch(1.0)) + .vertical_gap(Pixels(4.0)); } diff --git a/crates/ui/src/panels/fx.rs b/crates/ui/src/panels/fx.rs index 5a6cd4a..bb16d76 100644 --- a/crates/ui/src/panels/fx.rs +++ b/crates/ui/src/panels/fx.rs @@ -1,3 +1,4 @@ +use crate::app::{AppData, AppEvent}; use crate::widgets::knob::labeled_knob; use params::ParamId; use vizia::prelude::*; @@ -9,11 +10,11 @@ struct Slot { const CHAIN: &[Slot] = &[ Slot { - name: "DIST", + name: "Distortion", params: &[ParamId::DistDrive], }, Slot { - name: "CHORUS", + name: "Chorus", params: &[ ParamId::ChorusRate, ParamId::ChorusDepth, @@ -21,7 +22,7 @@ const CHAIN: &[Slot] = &[ ], }, Slot { - name: "PHASER", + name: "Phaser", params: &[ ParamId::PhaserRate, ParamId::PhaserDepth, @@ -29,7 +30,7 @@ const CHAIN: &[Slot] = &[ ], }, Slot { - name: "REVERB", + name: "Reverb", params: &[ ParamId::ReverbSize, ParamId::ReverbDamping, @@ -37,7 +38,7 @@ const CHAIN: &[Slot] = &[ ], }, Slot { - name: "DELAY", + name: "Delay", params: &[ ParamId::DelayTime, ParamId::DelayFeedback, @@ -56,40 +57,95 @@ const CHAIN: &[Slot] = &[ ]; pub fn build(cx: &mut Context) { - VStack::new(cx, |cx| { - Label::new(cx, "FX CHAIN") - .class("section-title") - .bottom(Pixels(6.0)); - for slot in CHAIN { - fx_row(cx, slot); - } + HStack::new(cx, |cx| { + VStack::new(cx, |cx| { + Label::new(cx, "EFFECTS") + .class("section-title") + .padding(Pixels(10.0)) + .padding_bottom(Pixels(4.0)); + + for (i, slot) in CHAIN.iter().enumerate() { + fx_list_item(cx, i, slot.name); + } + }) + .width(Pixels(200.0)) + .background_color(Color::from("#0e0e18")) + .border_width(Pixels(1.0)) + .border_color(Color::from("#1e1e30")); + + Binding::new(cx, AppData::fx_selected, |cx, sel_lens| { + let sel = sel_lens.get(cx); + let slot = &CHAIN[sel.min(CHAIN.len() - 1)]; + fx_detail(cx, sel, slot); + }); }) - .vertical_gap(Pixels(5.0)) - .padding(Pixels(12.0)); + .width(Stretch(1.0)) + .height(Stretch(1.0)); } -fn fx_row(cx: &mut Context, slot: &'static Slot) { - HStack::new(cx, |cx| { - Element::new(cx) - .width(Pixels(10.0)) - .height(Pixels(10.0)) - .background_color(Color::from("#ff7830")) - .corner_radius(Pixels(5.0)); - Label::new(cx, slot.name) - .color(Color::from("#d8d8e8")) - .width(Pixels(55.0)) - .left(Pixels(8.0)); +fn fx_list_item(cx: &mut Context, slot_idx: usize, name: &'static str) { + Binding::new(cx, AppData::fx_selected, move |cx, sel_lens| { + let selected = sel_lens.get(cx) == slot_idx; + Binding::new(cx, AppData::fx_enabled.idx(slot_idx), move |cx, en_lens| { + let enabled = en_lens.get(cx); + HStack::new(cx, |cx| { + Label::new(cx, "") + .class("fx-bypass") + .checked(enabled) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)) + .on_press(move |cx| cx.emit(AppEvent::ToggleFx(slot_idx))); + + Label::new(cx, name) + .class("fx-item-name") + .checked(selected) + .left(Pixels(8.0)) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)) + .color(if selected { + Color::from("#ff8c00") + } else { + Color::from("#707090") + }); + }) + .class("fx-list-item") + .checked(selected) + .height(Pixels(40.0)) + .on_press(move |cx| cx.emit(AppEvent::SelectFx(slot_idx))); + }); + }); +} + +fn fx_detail(cx: &mut Context, slot_idx: usize, slot: &'static Slot) { + VStack::new(cx, |cx| { + HStack::new(cx, |cx| { + Binding::new(cx, AppData::fx_enabled.idx(slot_idx), move |cx, en_lens| { + let enabled = en_lens.get(cx); + Label::new(cx, "") + .class("fx-bypass") + .checked(enabled) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)) + .on_press(move |cx| cx.emit(AppEvent::ToggleFx(slot_idx))); + }); + Label::new(cx, slot.name) + .class("section-title") + .left(Pixels(8.0)) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + }) + .height(Pixels(36.0)) + .padding(Pixels(8.0)); + HStack::new(cx, |cx| { for &p in slot.params { labeled_knob(cx, p); } }) - .horizontal_gap(Pixels(2.0)); + .horizontal_gap(Pixels(12.0)) + .padding(Pixels(16.0)); }) - .background_color(Color::from("#1a1a28")) - .corner_radius(Pixels(4.0)) - .padding(Pixels(8.0)) - .height(Pixels(68.0)) - .padding_top(Stretch(1.0)) - .padding_bottom(Stretch(1.0)); + .width(Stretch(1.0)) + .height(Stretch(1.0)) + .background_color(Color::from("#0c0c14")); } diff --git a/crates/ui/src/panels/header.rs b/crates/ui/src/panels/header.rs index 9c2fb19..2b60174 100644 --- a/crates/ui/src/panels/header.rs +++ b/crates/ui/src/panels/header.rs @@ -1,14 +1,33 @@ -use crate::app::AppData; +use crate::app::{AppData, AppEvent, Panel}; +use crate::widgets::spectrum::SpectrumWidget; use vizia::prelude::*; pub fn build(cx: &mut Context) { HStack::new(cx, |cx| { - Label::new(cx, AppData::preset_name) - .font_size(13.0) - .color(Color::from("#d8d8e8")) - .width(Pixels(180.0)); + Label::new(cx, "TENKO") + .color(Color::from("#ff8c00")) + .font_size(14.0) + .font_weight(FontWeightKeyword::Bold) + .width(Pixels(72.0)); - Element::new(cx).width(Stretch(1.0)).height(Stretch(1.0)); + for &p in Panel::ALL { + Binding::new(cx, AppData::active_panel, move |cx, active_lens| { + let al = active_lens.get(cx) == p; + Label::new(cx, p.label()) + .class("top-tab") + .checked(al) + .on_press(move |cx| cx.emit(AppEvent::SetPanel(p))) + .height(Stretch(1.0)); + }); + } + + Element::new(cx).width(Stretch(1.0)); + + SpectrumWidget::new(cx, 80) + .width(Pixels(160.0)) + .height(Pixels(22.0)); + + Element::new(cx).width(Pixels(12.0)); stat_pair(cx, "BPM", AppData::host_bpm.map(|b| format!("{b:.0}"))); stat_pair(cx, "VOICES", AppData::voice_count.map(|v| format!("{v}"))); @@ -17,22 +36,31 @@ pub fn build(cx: &mut Context) { "CPU", AppData::cpu_load.map(|c| format!("{:.0}%", c * 100.0)), ); + + Element::new(cx).width(Pixels(12.0)); + + Label::new(cx, AppData::preset_name) + .color(Color::from("#c0c0d8")) + .font_size(12.0) + .width(Pixels(140.0)); }) - .height(Pixels(34.0)) - .background_color(Color::from("#0e0e1a")) - .padding(Pixels(8.0)) + .height(Pixels(36.0)) + .background_color(Color::from("#09090f")) + .padding(Pixels(6.0)) .padding_top(Stretch(1.0)) .padding_bottom(Stretch(1.0)) - .horizontal_gap(Pixels(16.0)); + .horizontal_gap(Pixels(0.0)); } fn stat_pair(cx: &mut Context, key: &'static str, val_lens: impl Lens) { HStack::new(cx, |cx| { Label::new(cx, key) .class("knob-label") - .color(Color::from("#888899")); + .color(Color::from("#525270")); Label::new(cx, val_lens) - .color(Color::from("#ff7830")) - .left(Pixels(4.0)); - }); + .color(Color::from("#ff8c00")) + .left(Pixels(3.0)) + .font_size(10.0); + }) + .padding(Pixels(4.0)); } diff --git a/crates/ui/src/panels/lfo.rs b/crates/ui/src/panels/lfo.rs index 65c068d..f8ede4b 100644 --- a/crates/ui/src/panels/lfo.rs +++ b/crates/ui/src/panels/lfo.rs @@ -1,3 +1,4 @@ +use crate::app::AppEvent; use crate::widgets::{knob::labeled_knob, wavetable_display::WavetableDisplay}; use params::ParamId; use vizia::prelude::*; @@ -29,36 +30,20 @@ const LFO_KNOBS: [[ParamId; 4]; 4] = [ ], ]; +const SYNC_PARAMS: [ParamId; 4] = [ + ParamId::Lfo1Sync, + ParamId::Lfo2Sync, + ParamId::Lfo3Sync, + ParamId::Lfo4Sync, +]; + pub fn build(cx: &mut Context) { VStack::new(cx, |cx| { Label::new(cx, "LFOs") .class("section-title") .bottom(Pixels(6.0)); for (i, knobs) in LFO_KNOBS.iter().enumerate() { - HStack::new(cx, |cx| { - VStack::new(cx, |cx| { - Label::new(cx, format!("LFO {}", i + 1)).class("knob-label"); - let sine: Vec = (0..96) - .map(|j| (j as f32 / 96.0 * std::f32::consts::TAU).sin()) - .collect(); - WavetableDisplay::new(cx, sine) - .width(Pixels(110.0)) - .height(Pixels(52.0)); - }) - .width(Pixels(118.0)); - HStack::new(cx, |cx| { - for &p in knobs.iter() { - labeled_knob(cx, p); - } - }) - .horizontal_gap(Pixels(2.0)) - .left(Pixels(10.0)); - Label::new(cx, "SYNC").class("knob-label").left(Pixels(8.0)); - }) - .background_color(Color::from("#1a1a28")) - .corner_radius(Pixels(4.0)) - .padding(Pixels(8.0)) - .height(Pixels(84.0)); + lfo_row(cx, i, knobs); } Button::new(cx, |cx| Label::new(cx, "+ LFO")) .class("add-btn") @@ -67,3 +52,43 @@ pub fn build(cx: &mut Context) { .vertical_gap(Pixels(8.0)) .padding(Pixels(12.0)); } + +fn lfo_row(cx: &mut Context, idx: usize, knobs: &'static [ParamId; 4]) { + let sync_id = SYNC_PARAMS[idx]; + HStack::new(cx, |cx| { + VStack::new(cx, |cx| { + Label::new(cx, format!("LFO {}", idx + 1)).class("knob-label"); + WavetableDisplay::with_live_param(cx, knobs[3]) + .width(Pixels(110.0)) + .height(Pixels(52.0)); + }) + .width(Pixels(118.0)); + + HStack::new(cx, |cx| { + for &p in knobs.iter() { + labeled_knob(cx, p); + } + }) + .horizontal_gap(Pixels(2.0)) + .left(Pixels(10.0)); + + Binding::new( + cx, + crate::app::AppData::params.idx(sync_id as usize), + move |cx, lens| { + let synced = lens.get(cx) > 0.5; + Label::new(cx, "SYNC") + .class("sync-toggle") + .checked(synced) + .on_press(move |cx| { + cx.emit(AppEvent::SetParam(sync_id, if synced { 0.0 } else { 1.0 })); + }) + .left(Pixels(8.0)); + }, + ); + }) + .background_color(Color::from("#1a1a28")) + .corner_radius(Pixels(4.0)) + .padding(Pixels(8.0)) + .height(Pixels(84.0)); +} diff --git a/crates/ui/src/panels/macro_bar.rs b/crates/ui/src/panels/macro_bar.rs index 0d709dd..bb1548f 100644 --- a/crates/ui/src/panels/macro_bar.rs +++ b/crates/ui/src/panels/macro_bar.rs @@ -2,22 +2,19 @@ use crate::widgets::knob::labeled_knob; use params::ParamId; use vizia::prelude::*; -const MACROS: [ParamId; 8] = [ +const MACROS: [ParamId; 4] = [ ParamId::Macro1, ParamId::Macro2, ParamId::Macro3, ParamId::Macro4, - ParamId::Macro5, - ParamId::Macro6, - ParamId::Macro7, - ParamId::Macro8, ]; pub fn build(cx: &mut Context) { VStack::new(cx, |cx| { Label::new(cx, "MACRO") .class("section-title") - .bottom(Pixels(6.0)); + .padding_bottom(Pixels(6.0)); + for &m in &MACROS { VStack::new(cx, |cx| { labeled_knob(cx, m); @@ -25,9 +22,26 @@ pub fn build(cx: &mut Context) { .class("macro-knob") .bottom(Pixels(3.0)); } + + Element::new(cx).height(Stretch(1.0)); + + macro_wheel_label(cx, "PITCH\nWHL"); + macro_wheel_label(cx, "MOD\nWHL"); }) .width(Pixels(68.0)) - .background_color(Color::from("#16162a")) + .background_color(Color::from("#0c0c14")) .padding(Pixels(8.0)) .vertical_gap(Pixels(2.0)); } + +fn macro_wheel_label(cx: &mut Context, text: &'static str) { + Label::new(cx, text) + .class("knob-label") + .width(Stretch(1.0)) + .height(Pixels(28.0)) + .background_color(Color::from("#14141e")) + .corner_radius(Pixels(3.0)) + .padding(Pixels(3.0)) + .text_align(TextAlign::Center) + .bottom(Pixels(4.0)); +} diff --git a/crates/ui/src/panels/mod.rs b/crates/ui/src/panels/mod.rs index e0438fc..48bddb2 100644 --- a/crates/ui/src/panels/mod.rs +++ b/crates/ui/src/panels/mod.rs @@ -1,3 +1,5 @@ +pub mod advanced; +pub mod env_lfo_sidebar; pub mod env_panel; pub mod filter; pub mod fx; @@ -6,3 +8,4 @@ pub mod lfo; pub mod macro_bar; pub mod mod_matrix; pub mod osc; +pub mod voice; diff --git a/crates/ui/src/panels/mod_matrix.rs b/crates/ui/src/panels/mod_matrix.rs index 9510107..b17cab5 100644 --- a/crates/ui/src/panels/mod_matrix.rs +++ b/crates/ui/src/panels/mod_matrix.rs @@ -1,10 +1,12 @@ +use crate::app::{AppData, AppEvent, MOD_NDST, MOD_NSRC}; use vizia::prelude::*; +use vizia::vg; -const SOURCES: &[&str] = &[ +const SOURCES: [&str; MOD_NSRC] = [ "ENV1", "ENV2", "ENV3", "LFO1", "LFO2", "LFO3", "LFO4", "VEL", "NOTE", "MOD", "AFT", "M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8", ]; -const DESTS: &[&str] = &[ +const DESTS: [&str; MOD_NDST] = [ "CUT1", "RES1", "CUT2", "RES2", "P1", "P2", "P3", "AMP", "PAN", "LR1", "LR2", ]; @@ -12,33 +14,32 @@ pub fn build(cx: &mut Context) { VStack::new(cx, |cx| { Label::new(cx, "MOD MATRIX — 64 slots") .class("section-title") - .bottom(Pixels(6.0)); - Label::new(cx, "Click cell → depth · Ctrl+click → clear") + .bottom(Pixels(4.0)); + Label::new(cx, "Drag cell · Dbl-click to reset") .class("knob-label") .bottom(Pixels(8.0)); ScrollView::new(cx, |cx| { VStack::new(cx, |cx| { - // column header HStack::new(cx, |cx| { - Element::new(cx).width(Pixels(48.0)); - for &d in DESTS { + Element::new(cx).width(Pixels(50.0)); + for &d in &DESTS { Label::new(cx, d) .class("knob-label") .width(Pixels(38.0)) .text_align(TextAlign::Center); } }) - .height(Pixels(20.0)); + .height(Pixels(18.0)); - for &src in SOURCES { + for (si, &src) in SOURCES.iter().enumerate() { HStack::new(cx, |cx| { - Label::new(cx, src).class("knob-label").width(Pixels(48.0)); - for _ in DESTS { - mod_cell(cx); + Label::new(cx, src).class("knob-label").width(Pixels(50.0)); + for di in 0..MOD_NDST { + ModCell::new(cx, si, di); } }) - .height(Pixels(34.0)) + .height(Pixels(32.0)) .padding_top(Stretch(1.0)) .padding_bottom(Stretch(1.0)); } @@ -48,14 +49,101 @@ pub fn build(cx: &mut Context) { .padding(Pixels(12.0)); } -fn mod_cell(cx: &mut Context) { - Element::new(cx) - .width(Pixels(34.0)) - .height(Pixels(26.0)) - .background_color(Color::rgb(30, 30, 48)) - .border_color(Color::rgb(42, 42, 68)) - .border_width(Pixels(1.0)) - .corner_radius(Pixels(3.0)) - .left(Pixels(2.0)) - .cursor(CursorIcon::Hand); +struct ModCell { + src: usize, + dst: usize, + depth: f32, + drag_y: Option, + drag_v: f32, +} + +impl ModCell { + fn new(cx: &mut Context, src: usize, dst: usize) -> Handle { + let cell_idx = src * MOD_NDST + dst; + let init = cx + .data::() + .map(|d| d.mod_depths[cell_idx]) + .unwrap_or(0.0); + Self { + src, + dst, + depth: init, + drag_y: None, + drag_v: 0.0, + } + .build(cx, |_| {}) + .bind(AppData::mod_depths.idx(cell_idx), |h, l| { + let v = l.get(&h); + h.modify(|c: &mut ModCell| c.depth = v); + }) + .class("mod-cell") + .width(Pixels(34.0)) + .height(Pixels(24.0)) + .left(Pixels(2.0)) + .cursor(CursorIcon::NsResize) + } +} + +impl View for ModCell { + fn event(&mut self, cx: &mut EventContext, event: &mut Event) { + event.map(|we: &WindowEvent, _| match we { + WindowEvent::MouseDown(MouseButton::Left) => { + cx.capture(); + self.drag_y = Some(cx.mouse().cursor_y); + self.drag_v = self.depth; + } + WindowEvent::MouseUp(MouseButton::Left) => { + cx.release(); + self.drag_y = None; + } + WindowEvent::MouseMove(_, y) => { + if let Some(oy) = self.drag_y { + let new_v = (self.drag_v + (oy - y) * 0.006).clamp(-1.0, 1.0); + cx.emit(AppEvent::SetModDepth { + src: self.src, + dst: self.dst, + depth: new_v, + }); + } + } + WindowEvent::MouseDoubleClick(MouseButton::Left) => { + cx.emit(AppEvent::SetModDepth { + src: self.src, + dst: self.dst, + depth: 0.0, + }); + } + _ => {} + }); + } + + fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) { + let b = cx.bounds(); + let mut p = vg::Paint::default(); + + p.set_style(vg::PaintStyle::Fill); + p.set_color(vg::Color::from_argb(255, 26, 26, 44)); + canvas.draw_round_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), 3.0, 3.0, &p); + + if self.depth.abs() > 0.01 { + let fh = self.depth.abs() * b.h; + let (fy, color) = if self.depth > 0.0 { + (b.y + b.h - fh, vg::Color::from_argb(170, 255, 120, 48)) + } else { + (b.y, vg::Color::from_argb(170, 80, 140, 255)) + }; + p.set_color(color); + canvas.draw_round_rect( + vg::Rect::from_xywh(b.x + 2.0, fy, b.w - 4.0, fh), + 2.0, + 2.0, + &p, + ); + } + + p.set_style(vg::PaintStyle::Stroke); + p.set_stroke_width(1.0); + p.set_color(vg::Color::from_argb(255, 40, 40, 66)); + canvas.draw_round_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), 3.0, 3.0, &p); + } } diff --git a/crates/ui/src/panels/osc.rs b/crates/ui/src/panels/osc.rs index cc669e5..ab37cba 100644 --- a/crates/ui/src/panels/osc.rs +++ b/crates/ui/src/panels/osc.rs @@ -37,40 +37,72 @@ const OSC_KNOBS: [[ParamId; 8]; 3] = [ pub fn build(cx: &mut Context) { VStack::new(cx, |cx| { - Label::new(cx, "OSCILLATORS") - .class("section-title") - .bottom(Pixels(6.0)); for (i, knobs) in OSC_KNOBS.iter().enumerate() { - osc_row(cx, i + 1, knobs); + osc_strip(cx, i, knobs); } - Button::new(cx, |cx| Label::new(cx, "+ OSC")) - .class("add-btn") - .top(Pixels(6.0)); }) - .vertical_gap(Pixels(8.0)) - .padding(Pixels(12.0)); + .vertical_gap(Pixels(3.0)) + .padding(Pixels(8.0)) + .height(Stretch(1.0)); } -fn osc_row(cx: &mut Context, idx: usize, knobs: &'static [ParamId; 8]) { +fn osc_strip(cx: &mut Context, idx: usize, knobs: &'static [ParamId; 8]) { HStack::new(cx, |cx| { + Element::new(cx) + .class("osc-enable") + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + + Label::new(cx, format!("OSC\n{}", idx + 1)) + .class("osc-label") + .width(Pixels(24.0)) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + VStack::new(cx, |cx| { - Label::new(cx, format!("OSC {idx}")).class("knob-label"); - let sine: Vec = (0..128) - .map(|i| (i as f32 / 128.0 * std::f32::consts::TAU).sin()) - .collect(); - WavetableDisplay::new(cx, sine); + labeled_knob(cx, knobs[2]); + labeled_knob(cx, knobs[3]); }) - .width(Pixels(158.0)); - HStack::new(cx, |cx| { - for &p in knobs { - labeled_knob(cx, p); - } + .width(Pixels(54.0)) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + + WavetableDisplay::with_live_param(cx, knobs[7]) + .width(Stretch(1.0)) + .height(Stretch(1.0)); + + VStack::new(cx, |cx| { + Label::new(cx, "UNISON") + .class("knob-label") + .bottom(Pixels(2.0)); + HStack::new(cx, |cx| { + labeled_knob(cx, knobs[4]); + labeled_knob(cx, knobs[5]); + labeled_knob(cx, knobs[6]); + }) + .horizontal_gap(Pixels(2.0)); }) - .horizontal_gap(Pixels(2.0)) - .left(Pixels(10.0)); + .width(Pixels(164.0)) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); + + VStack::new(cx, |cx| { + Label::new(cx, "OUTPUT") + .class("knob-label") + .bottom(Pixels(2.0)); + HStack::new(cx, |cx| { + labeled_knob(cx, knobs[0]); + labeled_knob(cx, knobs[1]); + }) + .horizontal_gap(Pixels(2.0)); + }) + .width(Pixels(110.0)) + .top(Stretch(1.0)) + .bottom(Stretch(1.0)); }) - .background_color(Color::from("#1a1a28")) - .corner_radius(Pixels(4.0)) + .class("osc-strip") .padding(Pixels(8.0)) - .height(Pixels(98.0)); + .padding_left(Pixels(10.0)) + .height(Pixels(96.0)) + .horizontal_gap(Pixels(10.0)); } diff --git a/crates/ui/src/panels/voice.rs b/crates/ui/src/panels/voice.rs new file mode 100644 index 0000000..7ac0324 --- /dev/null +++ b/crates/ui/src/panels/voice.rs @@ -0,0 +1,10 @@ +use vizia::prelude::*; + +pub fn build(cx: &mut Context) { + VStack::new(cx, |cx| { + crate::panels::osc::build(cx); + crate::panels::filter::build(cx); + }) + .width(Stretch(1.0)) + .height(Stretch(1.0)); +} diff --git a/crates/ui/src/theme.css b/crates/ui/src/theme.css index 0460f1c..bc44ada 100644 --- a/crates/ui/src/theme.css +++ b/crates/ui/src/theme.css @@ -3,47 +3,176 @@ } * { - font-family: "Inter", "Segoe UI", sans-serif; + font-family: "Inter", "Segoe UI", "Liberation Sans", sans-serif; } .panel { - background-color: #1a1a28; - border-radius: 6px; - padding: 10px; -} - -.tab { - background-color: #22223a; - color: #888899; - padding: 6px 14px; - border-radius: 4px 4px 0 0; -} - -.tab.active { - background-color: #1a1a28; - color: #ff7830; -} - -.knob-label { - color: #888899; - font-size: 10px; -} - -.section-title { - color: #ff7830; - font-size: 11px; - font-weight: bold; -} - -.macro-knob { - background-color: #22223a; - border-radius: 6px; + background-color: #14141e; + border-radius: 4px; padding: 8px; } -.add-btn { - background-color: #22223a; - color: #888899; - border-radius: 4px; - padding: 4px 10px; +.section-title { + color: #ff8c00; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.10em; +} + +.knob-label { + color: #565672; + font-size: 9px; + letter-spacing: 0.06em; + text-align: center; + text-transform: uppercase; +} + +.top-tab { + background-color: transparent; + color: #525270; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 0px 16px; + cursor: pointer; + text-align: center; +} + +.top-tab:hover { + color: #b0b0cc; +} + +.top-tab:checked { + color: #ff8c00; + border-bottom: 2px solid #ff8c00; +} + +.env-tab, +.lfo-tab { + background-color: #1a1a28; + color: #525270; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + padding: 3px 10px; + border-radius: 3px; + cursor: pointer; +} + +.env-tab:checked, +.lfo-tab:checked { + background-color: #261a35; + color: #ff8c00; +} + +.osc-strip { + background-color: #111119; + border-radius: 4px; +} + +.osc-label { + color: #444460; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-align: center; +} + +.osc-enable { + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #ff8c00; + cursor: pointer; +} + +.fx-list-item { + border-radius: 4px; + cursor: pointer; + padding: 7px 10px; +} + +.fx-list-item:hover { + background-color: #1c1c2c; +} + +.fx-list-item:checked { + background-color: #201830; +} + +.fx-item-name { + color: #707090; + font-size: 11px; + font-weight: 500; +} + +.fx-item-name:checked { + color: #ff8c00; +} + +.filter-type-btn { + background-color: #1e1e2e; + color: #525270; + border-radius: 3px; + padding: 2px 5px; + font-size: 9px; + font-weight: 700; + cursor: pointer; +} + +.filter-type-btn:checked { + background-color: #281640; + color: #c070ff; +} + +.sync-toggle { + background-color: #1e1e2e; + color: #525270; + border-radius: 3px; + padding: 2px 6px; + font-size: 9px; + font-weight: 700; + cursor: pointer; +} + +.sync-toggle:checked { + background-color: #162618; + color: #40c060; +} + +.fx-bypass { + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #26263a; + cursor: pointer; +} + +.fx-bypass:checked { + background-color: #ff8c00; +} + +.macro-knob { + background-color: #1a1a28; + border-radius: 5px; + padding: 6px 4px; + align-items: center; +} + +.add-btn { + background-color: #1e1e2e; + color: #525270; + border-radius: 3px; + padding: 3px 10px; + font-size: 10px; + cursor: pointer; +} + +.add-btn:hover { + color: #c0c0d8; +} + +.mod-cell { + border-radius: 3px; + cursor: ns-resize; } diff --git a/crates/ui/src/widgets/env_display.rs b/crates/ui/src/widgets/env_display.rs index cbf7be5..a93eeb0 100644 --- a/crates/ui/src/widgets/env_display.rs +++ b/crates/ui/src/widgets/env_display.rs @@ -28,6 +28,30 @@ impl EnvDisplay { release: g(ids[5]), } .build(cx, |_| {}) + .bind(crate::app::AppData::params.idx(ids[0] as usize), |h, l| { + let v = l.get(&h); + h.modify(|e: &mut EnvDisplay| e.delay = v); + }) + .bind(crate::app::AppData::params.idx(ids[1] as usize), |h, l| { + let v = l.get(&h); + h.modify(|e: &mut EnvDisplay| e.attack = v); + }) + .bind(crate::app::AppData::params.idx(ids[2] as usize), |h, l| { + let v = l.get(&h); + h.modify(|e: &mut EnvDisplay| e.hold = v); + }) + .bind(crate::app::AppData::params.idx(ids[3] as usize), |h, l| { + let v = l.get(&h); + h.modify(|e: &mut EnvDisplay| e.decay = v); + }) + .bind(crate::app::AppData::params.idx(ids[4] as usize), |h, l| { + let v = l.get(&h); + h.modify(|e: &mut EnvDisplay| e.sustain = v); + }) + .bind(crate::app::AppData::params.idx(ids[5] as usize), |h, l| { + let v = l.get(&h); + h.modify(|e: &mut EnvDisplay| e.release = v); + }) .width(Pixels(210.0)) .height(Pixels(72.0)) } @@ -66,6 +90,7 @@ impl View for EnvDisplay { fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) { let b = cx.bounds(); let mut p = vg::Paint::default(); + p.set_color(vg::Color::from_argb(255, 18, 18, 30)); canvas.draw_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), &p); diff --git a/crates/ui/src/widgets/filter_response.rs b/crates/ui/src/widgets/filter_response.rs new file mode 100644 index 0000000..12c5e1c --- /dev/null +++ b/crates/ui/src/widgets/filter_response.rs @@ -0,0 +1,114 @@ +use params::ParamId; +use vizia::prelude::*; +use vizia::vg; + +pub struct FilterResponseDisplay { + cutoff: f32, + resonance: f32, +} + +impl FilterResponseDisplay { + pub fn new(cx: &mut Context, filter_idx: usize) -> Handle { + let (cut_id, res_id) = if filter_idx == 0 { + (ParamId::Filter1Cutoff, ParamId::Filter1Resonance) + } else { + (ParamId::Filter2Cutoff, ParamId::Filter2Resonance) + }; + let g = |id: ParamId| { + cx.data::() + .map(|d| d.params[id as usize]) + .unwrap_or(id.default_value()) + }; + Self { + cutoff: g(cut_id), + resonance: g(res_id), + } + .build(cx, |_| {}) + .bind( + crate::app::AppData::params.idx(cut_id as usize), + move |h, l| { + let v = l.get(&h); + h.modify(|d: &mut FilterResponseDisplay| d.cutoff = v); + }, + ) + .bind( + crate::app::AppData::params.idx(res_id as usize), + move |h, l| { + let v = l.get(&h); + h.modify(|d: &mut FilterResponseDisplay| d.resonance = v); + }, + ) + .width(Pixels(172.0)) + .height(Pixels(54.0)) + } +} + +impl View for FilterResponseDisplay { + fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) { + let b = cx.bounds(); + let mut p = vg::Paint::default(); + + p.set_color(vg::Color::from_argb(255, 10, 10, 20)); + canvas.draw_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), &p); + + p.set_style(vg::PaintStyle::Stroke); + p.set_stroke_width(0.5); + p.set_color(vg::Color::from_argb(35, 180, 180, 200)); + { + let y0 = db_to_y(0.0, b.y, b.h); + let mut gl = vg::Path::new(); + gl.move_to((b.x, y0)); + gl.line_to((b.x + b.w, y0)); + canvas.draw_path(&gl, &p); + } + + const N: usize = 200; + let fc = 20.0f32 * (1000.0f32).powf(self.cutoff); + let q = 0.5 + self.resonance * 14.5; + + let mut fill = vg::Path::new(); + let mut stroke = vg::Path::new(); + + for i in 0..N { + let t = i as f32 / (N - 1) as f32; + let freq = 20.0f32 * (1000.0f32).powf(t); + let w = freq / fc; + let denom = ((1.0 - w * w).powi(2) + (w / q).powi(2)).sqrt().max(1e-9); + let db = 20.0 * (1.0 / denom).log10(); + let x = b.x + t * b.w; + let y = db_to_y(db, b.y, b.h); + + if i == 0 { + fill.move_to((x, b.y + b.h)); + fill.line_to((x, y)); + stroke.move_to((x, y)); + } else { + fill.line_to((x, y)); + stroke.line_to((x, y)); + } + } + fill.line_to((b.x + b.w, b.y + b.h)); + + let mut fp = vg::Paint::default(); + fp.set_style(vg::PaintStyle::Fill); + fp.set_color(vg::Color::from_argb(28, 255, 120, 48)); + fp.set_anti_alias(true); + canvas.draw_path(&fill, &fp); + + p.set_style(vg::PaintStyle::Stroke); + p.set_stroke_width(1.5); + p.set_stroke_cap(vg::paint::Cap::Round); + p.set_stroke_join(vg::paint::Join::Round); + p.set_color(vg::Color::from_argb(255, 255, 120, 48)); + p.set_anti_alias(true); + canvas.draw_path(&stroke, &p); + } +} + +#[inline] +fn db_to_y(db: f32, top: f32, height: f32) -> f32 { + const DB_MIN: f32 = -42.0; + const DB_MAX: f32 = 14.0; + let t = 1.0 - (db.clamp(DB_MIN, DB_MAX) - DB_MIN) / (DB_MAX - DB_MIN); + top + t * height +} diff --git a/crates/ui/src/widgets/mod.rs b/crates/ui/src/widgets/mod.rs index 630438f..7e358e4 100644 --- a/crates/ui/src/widgets/mod.rs +++ b/crates/ui/src/widgets/mod.rs @@ -1,4 +1,6 @@ pub mod env_display; +pub mod filter_response; pub mod knob; -pub mod wavetable_display; pub mod piano; +pub mod spectrum; +pub mod wavetable_display; diff --git a/crates/ui/src/widgets/spectrum.rs b/crates/ui/src/widgets/spectrum.rs new file mode 100644 index 0000000..d065fe3 --- /dev/null +++ b/crates/ui/src/widgets/spectrum.rs @@ -0,0 +1,45 @@ +use vizia::prelude::*; +use vizia::vg; + +pub struct SpectrumWidget { + pub magnitudes: Vec, +} + +impl SpectrumWidget { + pub fn new(cx: &mut Context, bins: usize) -> Handle { + Self { + magnitudes: vec![0.0; bins], + } + .build(cx, |_| {}) + } +} + +impl View for SpectrumWidget { + fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) { + let b = cx.bounds(); + let mut p = vg::Paint::default(); + + p.set_color(vg::Color::from_argb(255, 10, 10, 20)); + canvas.draw_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), &p); + + let n = self.magnitudes.len(); + if n == 0 || b.w < 1.0 { + return; + } + let bw = (b.w / n as f32).max(1.0); + p.set_style(vg::PaintStyle::Fill); + p.set_anti_alias(false); + + for (i, &m) in self.magnitudes.iter().enumerate() { + let mh = m.clamp(0.0, 1.0) * b.h; + if mh < 0.5 { + continue; + } + let x = b.x + i as f32 * bw; + let y = b.y + b.h - mh; + let alpha = ((m * 0.65 + 0.2) * 255.0).clamp(0.0, 255.0) as u8; + p.set_color(vg::Color::from_argb(alpha, 255, 120, 48)); + canvas.draw_rect(vg::Rect::from_xywh(x, y, (bw - 1.0).max(1.0), mh), &p); + } + } +} diff --git a/crates/ui/src/widgets/wavetable_display.rs b/crates/ui/src/widgets/wavetable_display.rs index 98dc6dc..df8c11a 100644 --- a/crates/ui/src/widgets/wavetable_display.rs +++ b/crates/ui/src/widgets/wavetable_display.rs @@ -1,3 +1,4 @@ +use params::ParamId; use vizia::prelude::*; use vizia::vg; @@ -12,19 +13,39 @@ impl WavetableDisplay { .width(Pixels(150.0)) .height(Pixels(68.0)) } + + pub fn with_live_param(cx: &mut Context, wave_pos_param: ParamId) -> Handle { + let init = cx + .data::() + .map(|d| d.params[wave_pos_param as usize]) + .unwrap_or(0.0); + Self { + samples: gen_wave(init), + } + .build(cx, |_| {}) + .bind( + crate::app::AppData::params.idx(wave_pos_param as usize), + |h, l| { + let v = l.get(&h); + h.modify(|d: &mut WavetableDisplay| d.samples = gen_wave(v)); + }, + ) + .width(Pixels(150.0)) + .height(Pixels(68.0)) + } } impl View for WavetableDisplay { fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) { let b = cx.bounds(); - let mut p = vg::Paint::default(); + p.set_color(vg::Color::from_argb(255, 18, 18, 30)); canvas.draw_rect(vg::Rect::from_xywh(b.x, b.y, b.w, b.h), &p); - p.set_color(vg::Color::from_argb(40, 255, 255, 255)); p.set_style(vg::PaintStyle::Stroke); p.set_stroke_width(1.0); + p.set_color(vg::Color::from_argb(30, 255, 255, 255)); let mid_y = b.y + b.h * 0.5; let mut zl = vg::Path::new(); zl.move_to((b.x, mid_y)); @@ -35,7 +56,6 @@ impl View for WavetableDisplay { return; } let n = self.samples.len() as f32; - let mut wave = vg::Path::new(); for (i, &s) in self.samples.iter().enumerate() { let x = b.x + (i as f32 / n) * b.w; @@ -48,7 +68,46 @@ impl View for WavetableDisplay { } p.set_color(vg::Color::from_argb(255, 255, 120, 48)); p.set_stroke_width(1.5); + p.set_stroke_cap(vg::paint::Cap::Round); p.set_anti_alias(true); canvas.draw_path(&wave, &p); } } + +pub fn gen_wave(wave_pos: f32) -> Vec { + const N: usize = 128; + let pos = wave_pos.clamp(0.0, 1.0) * 3.0; + let lo = pos as usize; + let hi = (lo + 1).min(3); + let morph = pos - lo as f32; + (0..N) + .map(|i| { + let ph = i as f32 / N as f32; + let a = table_sample(ph, lo); + let b = table_sample(ph, hi); + a + (b - a) * morph + }) + .collect() +} + +#[inline] +fn table_sample(ph: f32, t: usize) -> f32 { + match t { + 0 => (ph * std::f32::consts::TAU).sin(), + 1 => { + if ph < 0.5 { + 4.0 * ph - 1.0 + } else { + 3.0 - 4.0 * ph + } + } + 2 => 2.0 * ph - 1.0, + _ => { + if ph < 0.5 { + 1.0 + } else { + -1.0 + } + } + } +}