This commit is contained in:
2026-04-20 23:26:43 +03:00
parent 17f3be1676
commit de2021d9b8
19 changed files with 1109 additions and 262 deletions

View File

@@ -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);
});
}

View File

@@ -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))

View File

@@ -0,0 +1,3 @@
use vizia::prelude::*;
pub fn build(_cx: &mut Context) {}

View 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));
}

View File

@@ -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));
}

View File

@@ -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"));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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));
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));
}

View 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));
}

View File

@@ -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;
}

View File

@@ -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);

View 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
}

View File

@@ -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;

View 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);
}
}
}

View File

@@ -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
}
}
}
}