redesign
This commit is contained in:
@@ -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<u8>,
|
||||
pub octave: i32,
|
||||
pub mod_depths: Vec<f32>,
|
||||
pub fx_enabled: Vec<bool>,
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
3
crates/ui/src/panels/advanced.rs
Normal file
3
crates/ui/src/panels/advanced.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
use vizia::prelude::*;
|
||||
|
||||
pub fn build(_cx: &mut Context) {}
|
||||
169
crates/ui/src/panels/env_lfo_sidebar.rs
Normal file
169
crates/ui/src/panels/env_lfo_sidebar.rs
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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 m in ["SERIAL", "PARALLEL"] {
|
||||
Label::new(cx, m).class("tab").padding(Pixels(5.0));
|
||||
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));
|
||||
});
|
||||
}
|
||||
})
|
||||
.bottom(Pixels(8.0));
|
||||
.horizontal_gap(Pixels(4.0))
|
||||
.padding(Pixels(6.0));
|
||||
});
|
||||
|
||||
for (i, knobs) in FILTER_KNOBS.iter().enumerate() {
|
||||
HStack::new(cx, |cx| {
|
||||
for slot in 0..2usize {
|
||||
filter_slot(cx, slot);
|
||||
}
|
||||
})
|
||||
.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"));
|
||||
}
|
||||
|
||||
fn filter_slot(cx: &mut Context, slot: usize) {
|
||||
let type_id = TYPE_PARAMS[slot];
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, format!("FILTER {}", i + 1)).class("knob-label");
|
||||
HStack::new(cx, |cx| {
|
||||
for t in TYPES {
|
||||
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 (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));
|
||||
.horizontal_gap(Pixels(2.0));
|
||||
},
|
||||
);
|
||||
})
|
||||
.width(Pixels(178.0));
|
||||
.height(Pixels(22.0));
|
||||
|
||||
FilterResponseDisplay::new(cx, slot)
|
||||
.width(Stretch(1.0))
|
||||
.height(Stretch(1.0));
|
||||
|
||||
HStack::new(cx, |cx| {
|
||||
for &p in knobs.iter() {
|
||||
for &p in FILTER_KNOBS[slot].iter() {
|
||||
labeled_knob(cx, p);
|
||||
}
|
||||
})
|
||||
.horizontal_gap(Pixels(2.0))
|
||||
.left(Pixels(10.0));
|
||||
.horizontal_gap(Pixels(4.0))
|
||||
.height(Pixels(60.0))
|
||||
.padding_top(Pixels(4.0));
|
||||
})
|
||||
.background_color(Color::from("#1a1a28"))
|
||||
.background_color(Color::from("#111119"))
|
||||
.corner_radius(Pixels(4.0))
|
||||
.padding(Pixels(8.0))
|
||||
.height(Pixels(120.0));
|
||||
}
|
||||
})
|
||||
.vertical_gap(Pixels(8.0))
|
||||
.padding(Pixels(12.0));
|
||||
.width(Stretch(1.0))
|
||||
.vertical_gap(Pixels(4.0));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
HStack::new(cx, |cx| {
|
||||
VStack::new(cx, |cx| {
|
||||
Label::new(cx, "FX CHAIN")
|
||||
Label::new(cx, "EFFECTS")
|
||||
.class("section-title")
|
||||
.bottom(Pixels(6.0));
|
||||
for slot in CHAIN {
|
||||
fx_row(cx, slot);
|
||||
.padding(Pixels(10.0))
|
||||
.padding_bottom(Pixels(4.0));
|
||||
|
||||
for (i, slot) in CHAIN.iter().enumerate() {
|
||||
fx_list_item(cx, i, slot.name);
|
||||
}
|
||||
})
|
||||
.vertical_gap(Pixels(5.0))
|
||||
.padding(Pixels(12.0));
|
||||
.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);
|
||||
});
|
||||
})
|
||||
.width(Stretch(1.0))
|
||||
.height(Stretch(1.0));
|
||||
}
|
||||
|
||||
fn fx_row(cx: &mut Context, slot: &'static Slot) {
|
||||
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| {
|
||||
Element::new(cx)
|
||||
.width(Pixels(10.0))
|
||||
.height(Pixels(10.0))
|
||||
.background_color(Color::from("#ff7830"))
|
||||
.corner_radius(Pixels(5.0));
|
||||
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)
|
||||
.color(Color::from("#d8d8e8"))
|
||||
.width(Pixels(55.0))
|
||||
.left(Pixels(8.0));
|
||||
.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"));
|
||||
}
|
||||
|
||||
@@ -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<Target = String>) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<f32> = (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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f32>,
|
||||
drag_v: f32,
|
||||
}
|
||||
|
||||
impl ModCell {
|
||||
fn new(cx: &mut Context, src: usize, dst: usize) -> Handle<Self> {
|
||||
let cell_idx = src * MOD_NDST + dst;
|
||||
let init = cx
|
||||
.data::<AppData>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f32> = (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));
|
||||
.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| {
|
||||
for &p in knobs {
|
||||
labeled_knob(cx, p);
|
||||
}
|
||||
labeled_knob(cx, knobs[4]);
|
||||
labeled_knob(cx, knobs[5]);
|
||||
labeled_knob(cx, knobs[6]);
|
||||
})
|
||||
.horizontal_gap(Pixels(2.0))
|
||||
.left(Pixels(10.0));
|
||||
.horizontal_gap(Pixels(2.0));
|
||||
})
|
||||
.background_color(Color::from("#1a1a28"))
|
||||
.corner_radius(Pixels(4.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));
|
||||
})
|
||||
.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));
|
||||
}
|
||||
|
||||
10
crates/ui/src/panels/voice.rs
Normal file
10
crates/ui/src/panels/voice.rs
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
114
crates/ui/src/widgets/filter_response.rs
Normal file
114
crates/ui/src/widgets/filter_response.rs
Normal file
@@ -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<Self> {
|
||||
let (cut_id, res_id) = if filter_idx == 0 {
|
||||
(ParamId::Filter1Cutoff, ParamId::Filter1Resonance)
|
||||
} else {
|
||||
(ParamId::Filter2Cutoff, ParamId::Filter2Resonance)
|
||||
};
|
||||
let g = |id: ParamId| {
|
||||
cx.data::<crate::app::AppData>()
|
||||
.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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
45
crates/ui/src/widgets/spectrum.rs
Normal file
45
crates/ui/src/widgets/spectrum.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use vizia::prelude::*;
|
||||
use vizia::vg;
|
||||
|
||||
pub struct SpectrumWidget {
|
||||
pub magnitudes: Vec<f32>,
|
||||
}
|
||||
|
||||
impl SpectrumWidget {
|
||||
pub fn new(cx: &mut Context, bins: usize) -> Handle<Self> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
let init = cx
|
||||
.data::<crate::app::AppData>()
|
||||
.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<f32> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user