diff --git a/Cargo.lock b/Cargo.lock index b57cafc..664bede 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic_float" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" + [[package]] name = "atspi" version = "0.25.0" @@ -709,6 +715,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -849,6 +864,7 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" name = "engine" version = "0.1.0" dependencies = [ + "crossbeam-channel", "params", ] @@ -2013,6 +2029,9 @@ dependencies = [ [[package]] name = "params" version = "0.1.0" +dependencies = [ + "atomic_float", +] [[package]] name = "parking" diff --git a/Cargo.toml b/Cargo.toml index a35ddc4..72ef720 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,5 @@ params = { path = "crates/params" } engine = { path = "crates/engine" } lv2 = "0.6.0" vizia = "0.3.0" +atomic_float = "1.1.0" +crossbeam-channel = "0.5.15" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a42b23e..8031576 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -4,4 +4,5 @@ version.workspace = true edition.workspace = true [dependencies] +crossbeam-channel.workspace = true params = { workspace = true } diff --git a/crates/engine/src/envelope.rs b/crates/engine/src/envelope.rs new file mode 100644 index 0000000..fe265bf --- /dev/null +++ b/crates/engine/src/envelope.rs @@ -0,0 +1,61 @@ +#[derive(Clone, Copy, PartialEq)] +pub enum Stage { + Idle, + Delay, + Attack, + Hold, + Decay, + Sustain, + Release, +} + +pub struct Dahdsr { + pub stage: Stage, + pub level: f32, + pub delay: f32, + pub attack: f32, + pub hold: f32, + pub decay: f32, + pub sustain: f32, + pub release: f32, + pub attack_curve: f32, + pub decay_curve: f32, + pub release_curve: f32, + pub sample_rate: f32, + stage_samples: u32, +} + +impl Dahdsr { + pub fn new(sr: f32) -> Self { + Self { + stage: Stage::Idle, + level: 0.0, + delay: 0.0, + attack: 0.01, + hold: 0.0, + decay: 0.3, + sustain: 1.0, + release: 0.3, + attack_curve: 0.0, + decay_curve: 0.0, + release_curve: 0.0, + sample_rate: sr, + stage_samples: 0, + } + } + pub fn note_on(&mut self) { + self.stage = Stage::Delay; + self.stage_samples = 0; + } + pub fn note_off(&mut self) { + self.stage = Stage::Release; + self.stage_samples = 0; + } + pub fn is_active(&self) -> bool { + self.stage != Stage::Idle + } + #[inline] + pub fn tick(&mut self) -> f32 { + 0.0 + } +} diff --git a/crates/engine/src/filter.rs b/crates/engine/src/filter.rs new file mode 100644 index 0000000..117e184 --- /dev/null +++ b/crates/engine/src/filter.rs @@ -0,0 +1,38 @@ +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum FilterKind { + Ladder, + Svf, + Comb, + Formant, +} + +pub struct Filter { + pub kind: FilterKind, + pub cutoff: f32, + pub resonance: f32, + pub drive: f32, + pub keytrack: f32, + state: [f32; 4], + sample_rate: f32, +} + +impl Filter { + pub fn new(sr: f32) -> Self { + Self { + kind: FilterKind::Svf, + cutoff: 1.0, + resonance: 0.0, + drive: 0.0, + keytrack: 0.0, + state: [0.0; 4], + sample_rate: sr, + } + } + pub fn reset(&mut self) { + self.state = [0.0; 4]; + } + #[inline] + pub fn process(&mut self, x: f32, _note_freq: f32) -> f32 { + x + } +} diff --git a/crates/engine/src/lfo.rs b/crates/engine/src/lfo.rs new file mode 100644 index 0000000..95d1c44 --- /dev/null +++ b/crates/engine/src/lfo.rs @@ -0,0 +1,38 @@ +#[derive(Clone, Copy)] +pub enum LfoMode { + FreeRun, + BpmSync, + OneShot, + Envelope, +} + +pub struct Lfo { + pub phase: f32, + pub rate: f32, + pub depth: f32, + pub mode: LfoMode, + pub wave_pos: f32, + pub sync: bool, + sample_rate: f32, +} + +impl Lfo { + pub fn new(sr: f32) -> Self { + Self { + phase: 0.0, + rate: 1.0, + depth: 1.0, + mode: LfoMode::FreeRun, + wave_pos: 0.0, + sync: false, + sample_rate: sr, + } + } + pub fn retrigger(&mut self) { + self.phase = 0.0; + } + #[inline] + pub fn tick(&mut self, _host_bpm: f32) -> f32 { + 0.0 + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 015d531..2b64e5d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,29 +1,105 @@ +pub mod envelope; +pub mod filter; +pub mod lfo; +pub mod mod_matrix; +pub mod oscillator; +pub mod voice; + +use crossbeam_channel::{Receiver, Sender, bounded}; +use mod_matrix::ModMatrix; use params::{ParamId, ParamStore}; +use voice::Voice; + +pub const MAX_VOICES: usize = 16; + +pub struct EngineMetrics { + pub voice_count: u8, + pub cpu_load: f32, + pub waveform: [f32; 256], +} pub struct Engine { params: ParamStore, sample_rate: f32, + voices: Box<[Voice; MAX_VOICES]>, + round_robin: usize, + mod_matrix: ModMatrix, + pub metrics_tx: Sender, } impl Engine { - pub fn new(params: ParamStore, sample_rate: f32) -> Self { - Self { - params, - sample_rate, - } + pub fn new(params: ParamStore, sample_rate: f32) -> (Self, Receiver) { + let (tx, rx) = bounded(4); + let voices = Box::new(std::array::from_fn(|_| Voice::new(sample_rate))); + ( + Self { + params, + sample_rate, + voices, + round_robin: 0, + mod_matrix: ModMatrix::new(), + metrics_tx: tx, + }, + rx, + ) + } + + pub fn new_simple(params: ParamStore, sample_rate: f32) -> Self { + let (engine, _rx) = Self::new(params, sample_rate); + engine } pub fn set_sample_rate(&mut self, rate: f32) { self.sample_rate = rate; } + pub fn note_on(&mut self, note: u8, vel: u8) { + let idx = self.round_robin % MAX_VOICES; + let v = &mut self.voices[idx]; + v.active = true; + v.note = note; + v.velocity = vel as f32 / 127.0; + for e in &mut v.envs { + e.note_on(); + } + self.round_robin += 1; + } + + pub fn note_off(&mut self, note: u8) { + for v in self + .voices + .iter_mut() + .filter(|v| v.active && v.note == note) + { + for e in &mut v.envs { + e.note_off(); + } + } + } + + /// No alloc, no lock. Must stay that way. + #[inline] pub fn process(&mut self, out_l: &mut [f32], out_r: &mut [f32]) { debug_assert_eq!(out_l.len(), out_r.len()); out_l.fill(0.0); out_r.fill(0.0); - - let _vol = self.params.get(ParamId::MasterVolume); + let vol = self.params.get(ParamId::MasterVolume); + for v in self.voices.iter_mut().filter(|v| v.active) { + let (vl, vr) = v.process(); + for (o, s) in out_l.iter_mut().zip(std::iter::repeat(vl)) { + *o += s * vol; + } + for (o, s) in out_r.iter_mut().zip(std::iter::repeat(vr)) { + *o += s * vol; + } + } } - pub fn midi_event(&mut self, _data: [u8; 3]) {} + pub fn midi_event(&mut self, data: [u8; 3]) { + match data[0] & 0xF0 { + 0x90 if data[2] > 0 => self.note_on(data[1], data[2]), + 0x80 | 0x90 => self.note_off(data[1]), + _ => {} + } + } } diff --git a/crates/engine/src/mod_matrix.rs b/crates/engine/src/mod_matrix.rs new file mode 100644 index 0000000..fd09bd5 --- /dev/null +++ b/crates/engine/src/mod_matrix.rs @@ -0,0 +1,46 @@ +use params::ParamId; + +#[derive(Clone, Copy)] +pub enum ModSource { + Env(u8), + Lfo(u8), + Velocity, + Note, + Modwheel, + Aftertouch, + Macro(u8), +} + +#[derive(Clone, Copy)] +pub struct ModSlot { + pub active: bool, + pub source: ModSource, + pub dest: ParamId, + pub depth: f32, + pub per_voice: bool, +} + +impl Default for ModSlot { + fn default() -> Self { + Self { + active: false, + source: ModSource::Env(0), + dest: ParamId::Filter1Cutoff, + depth: 0.0, + per_voice: true, + } + } +} + +pub struct ModMatrix { + pub slots: [ModSlot; 64], +} + +impl ModMatrix { + pub fn new() -> Self { + Self { + slots: [ModSlot::default(); 64], + } + } + pub fn apply(&self, _params: ¶ms::ParamStore) {} +} diff --git a/crates/engine/src/oscillator.rs b/crates/engine/src/oscillator.rs new file mode 100644 index 0000000..7a26e9d --- /dev/null +++ b/crates/engine/src/oscillator.rs @@ -0,0 +1,34 @@ +pub const TABLE_SIZE: usize = 2048; +pub type Wavetable = Box<[f32; TABLE_SIZE]>; + +pub struct WavetableOsc { + pub phase: f32, + pub gain: f32, + pub pan: f32, + pub unison_count: u8, + pub unison_detune: f32, + pub unison_spread: f32, + pub wave_pos: f32, + wavetable: Wavetable, +} + +impl WavetableOsc { + pub fn new() -> Self { + Self { + phase: 0.0, + gain: 1.0, + pan: 0.0, + unison_count: 1, + unison_detune: 0.1, + unison_spread: 0.5, + wave_pos: 0.0, + wavetable: Box::new([0.0f32; TABLE_SIZE]), + } + } + + #[inline] + pub fn tick(&mut self, phase_inc: f32) -> (f32, f32) { + self.phase = (self.phase + phase_inc).fract(); + (0.0, 0.0) + } +} diff --git a/crates/engine/src/voice.rs b/crates/engine/src/voice.rs new file mode 100644 index 0000000..69860c4 --- /dev/null +++ b/crates/engine/src/voice.rs @@ -0,0 +1,31 @@ +use crate::{envelope::Dahdsr, filter::Filter, lfo::Lfo, oscillator::WavetableOsc}; + +pub struct Voice { + pub active: bool, + pub note: u8, + pub velocity: f32, + pub oscs: [WavetableOsc; 3], + pub envs: [Dahdsr; 3], + pub lfos: [Lfo; 4], + pub filter1: Filter, + pub filter2: Filter, +} + +impl Voice { + pub fn new(sr: f32) -> Self { + Self { + active: false, + note: 0, + velocity: 0.0, + oscs: std::array::from_fn(|_| WavetableOsc::new()), + envs: std::array::from_fn(|_| Dahdsr::new(sr)), + lfos: std::array::from_fn(|_| Lfo::new(sr)), + filter1: Filter::new(sr), + filter2: Filter::new(sr), + } + } + #[inline] + pub fn process(&mut self) -> (f32, f32) { + (0.0, 0.0) + } +} diff --git a/crates/params/Cargo.toml b/crates/params/Cargo.toml index f9cde08..980cf36 100644 --- a/crates/params/Cargo.toml +++ b/crates/params/Cargo.toml @@ -2,3 +2,6 @@ name = "params" version.workspace = true edition.workspace = true + +[dependencies] +atomic_float.workspace = true diff --git a/crates/params/src/lib.rs b/crates/params/src/lib.rs index 6bf8ce4..def9f5e 100644 --- a/crates/params/src/lib.rs +++ b/crates/params/src/lib.rs @@ -1,114 +1,120 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::Ordering; + +use atomic_float::AtomicF32; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(usize)] #[allow(dead_code)] pub enum ParamId { - // Master - MasterVolume = 0, - MasterPan, - - // Oscillator 1 - Osc1Volume, + // OSC 1-3 + Osc1Gain = 0, Osc1Pan, - Osc1Pitch, + Osc1Semitone, Osc1Fine, - Osc1WavePos, - Osc1UnisonVoices, + Osc1UnisonCount, Osc1UnisonDetune, Osc1UnisonSpread, + Osc1WavePos, - // Oscillator 2 - Osc2Volume, + Osc2Gain, Osc2Pan, - Osc2Pitch, + Osc2Semitone, Osc2Fine, - Osc2WavePos, - Osc2UnisonVoices, + Osc2UnisonCount, Osc2UnisonDetune, Osc2UnisonSpread, + Osc2WavePos, - // Oscillator 3 - Osc3Volume, + Osc3Gain, Osc3Pan, - Osc3Pitch, + Osc3Semitone, Osc3Fine, - Osc3WavePos, - Osc3UnisonVoices, + Osc3UnisonCount, Osc3UnisonDetune, Osc3UnisonSpread, + Osc3WavePos, - // Envelope 1 (amplitude) + // ENV 1-3 Env1Delay, Env1Attack, Env1Hold, Env1Decay, Env1Sustain, Env1Release, + Env1AttackCurve, + Env1DecayCurve, + Env1ReleaseCurve, - // Envelope 2 Env2Delay, Env2Attack, Env2Hold, Env2Decay, Env2Sustain, Env2Release, + Env2AttackCurve, + Env2DecayCurve, + Env2ReleaseCurve, - // Envelope 3 Env3Delay, Env3Attack, Env3Hold, Env3Decay, Env3Sustain, Env3Release, + Env3AttackCurve, + Env3DecayCurve, + Env3ReleaseCurve, - // LFO 1–4 + // LFO 1-4 Lfo1Rate, - Lfo1Depth, Lfo1Phase, + Lfo1Depth, + Lfo1WavePos, + Lfo1Sync, Lfo2Rate, - Lfo2Depth, Lfo2Phase, + Lfo2Depth, + Lfo2WavePos, + Lfo2Sync, Lfo3Rate, - Lfo3Depth, Lfo3Phase, + Lfo3Depth, + Lfo3WavePos, + Lfo3Sync, Lfo4Rate, - Lfo4Depth, Lfo4Phase, + Lfo4Depth, + Lfo4WavePos, + Lfo4Sync, - // Filter 1 + // Filter 1-2 Filter1Cutoff, Filter1Resonance, Filter1Drive, Filter1Keytrack, - - // Filter 2 + Filter1Type, Filter2Cutoff, Filter2Resonance, Filter2Drive, Filter2Keytrack, + Filter2Type, - // FX — Chorus + // FX + DistDrive, + DistType, ChorusRate, ChorusDepth, ChorusMix, - - // FX — Reverb + PhaserRate, + PhaserDepth, + PhaserMix, ReverbSize, ReverbDamping, ReverbMix, - - // FX — Delay DelayTime, DelayFeedback, DelayMix, - - // FX — Distortion - DistortionDrive, - DistortionMix, - - // FX — EQ EqLowGain, EqMidFreq, EqMidGain, @@ -124,83 +130,96 @@ pub enum ParamId { Macro7, Macro8, + // Master + MasterVolume, + MasterPan, + Polyphony, + PortamentoTime, + PortamentoMode, + Count, } -pub const PARAM_COUNT: usize = ParamId::Count as usize; +impl ParamId { + pub const COUNT: usize = ParamId::Count as usize; -pub fn default_value(id: ParamId) -> f32 { - use ParamId::*; - match id { - MasterVolume => 0.8, - MasterPan => 0.5, + pub fn default_value(self) -> f32 { + match self { + Self::Osc1Gain | Self::Osc2Gain | Self::Osc3Gain => 1.0, + Self::MasterVolume => 1.0, + Self::Filter1Cutoff | Self::Filter2Cutoff => 1.0, + Self::Filter1Resonance | Self::Filter2Resonance => 0.0, + Self::Env1Sustain | Self::Env2Sustain | Self::Env3Sustain => 1.0, + Self::Env1Attack | Self::Env2Attack | Self::Env3Attack => 0.01, + Self::Env1Decay | Self::Env2Decay | Self::Env3Decay => 0.3, + Self::Env1Release | Self::Env2Release | Self::Env3Release => 0.3, + Self::Polyphony => 1.0, // normalized: 1.0 = 16 voices + Self::ReverbMix => 0.15, + Self::ChorusMix => 0.0, + _ => 0.0, + } + } - Osc1Volume => 0.8, - Osc2Volume | Osc3Volume => 0.0, // off by default - Osc1Pan | Osc2Pan | Osc3Pan => 0.5, - Osc1Pitch | Osc2Pitch | Osc3Pitch => 0.5, - Osc1Fine | Osc2Fine | Osc3Fine => 0.5, - - Env1Attack | Env2Attack | Env3Attack => 0.02, - Env1Decay | Env2Decay | Env3Decay => 0.30, - Env1Sustain | Env2Sustain | Env3Sustain => 0.70, - Env1Release | Env2Release | Env3Release => 0.35, - - Filter1Cutoff | Filter2Cutoff => 1.0, - Filter1Keytrack | Filter2Keytrack => 0.0, - - Lfo1Rate | Lfo2Rate | Lfo3Rate | Lfo4Rate => 0.3, - - _ => 0.0, + pub fn label(self) -> &'static str { + match self { + Self::Osc1Gain => "Gain", + Self::Osc1Pan => "Pan", + Self::Osc1Semitone => "Semi", + Self::Osc1Fine => "Fine", + Self::Osc1UnisonCount => "Voices", + Self::Osc1UnisonDetune => "Detune", + Self::Osc1UnisonSpread => "Spread", + Self::Osc1WavePos => "Wave", + Self::Filter1Cutoff => "Cutoff", + Self::Filter1Resonance => "Res", + Self::Filter1Drive => "Drive", + Self::Filter1Keytrack => "Key", + Self::Env1Attack => "A", + Self::Env1Decay => "D", + Self::Env1Sustain => "S", + Self::Env1Release => "R", + Self::Lfo1Rate => "Rate", + Self::Lfo1Depth => "Depth", + Self::Macro1 => "M1", + Self::Macro2 => "M2", + Self::Macro3 => "M3", + Self::Macro4 => "M4", + Self::Macro5 => "M5", + Self::Macro6 => "M6", + Self::Macro7 => "M7", + Self::Macro8 => "M8", + Self::MasterVolume => "Vol", + _ => "—", + } } } #[derive(Clone)] pub struct ParamStore { - data: Arc<[AtomicU32]>, + params: Arc>, } impl ParamStore { pub fn new() -> Self { - let data: Arc<[AtomicU32]> = (0..PARAM_COUNT) + let params = (0..ParamId::COUNT) .map(|i| { let id: ParamId = unsafe { std::mem::transmute(i) }; - AtomicU32::new(default_value(id).to_bits()) + AtomicF32::new(id.default_value()) }) .collect::>() - .into(); - Self { data } + .into_boxed_slice(); + Self { + params: Arc::new(params), + } } - #[inline(always)] + #[inline] pub fn get(&self, id: ParamId) -> f32 { - f32::from_bits(self.data[id as usize].load(Ordering::Relaxed)) + self.params[id as usize].load(Ordering::Relaxed) } - - #[inline(always)] - pub fn set(&self, id: ParamId, value: f32) { - debug_assert!((0.0..=1.0).contains(&value), "param out of range: {value}"); - self.data[id as usize].store(value.to_bits(), Ordering::Relaxed); - } - - pub fn reset_to_defaults(&self) { - for i in 0..PARAM_COUNT { - let id: ParamId = unsafe { std::mem::transmute(i) }; - self.data[i].store(default_value(id).to_bits(), Ordering::Relaxed); - } - } - - pub fn snapshot(&self) -> Vec { - (0..PARAM_COUNT) - .map(|i| f32::from_bits(self.data[i].load(Ordering::Relaxed))) - .collect() - } - - pub fn restore(&self, values: &[f32]) { - assert_eq!(values.len(), PARAM_COUNT, "snapshot length mismatch"); - for (i, &v) in values.iter().enumerate() { - self.data[i].store(v.to_bits(), Ordering::Relaxed); - } + #[inline] + pub fn set(&self, id: ParamId, v: f32) { + self.params[id as usize].store(v, Ordering::Relaxed); } } @@ -209,70 +228,3 @@ impl Default for ParamStore { Self::new() } } - -pub struct ParamMeta { - pub name: &'static str, - pub label: &'static str, - pub steps: Option, -} - -pub fn param_meta(id: ParamId) -> ParamMeta { - use ParamId::*; - match id { - MasterVolume => ParamMeta { - name: "Master Volume", - label: "%", - steps: None, - }, - MasterPan => ParamMeta { - name: "Master Pan", - label: "", - steps: None, - }, - Osc1Pitch => ParamMeta { - name: "Osc1 Pitch", - label: "st", - steps: Some(96), - }, - Filter1Cutoff => ParamMeta { - name: "Filter1 Cutoff", - label: "Hz", - steps: None, - }, - Filter1Resonance => ParamMeta { - name: "Filter1 Res", - label: "", - steps: None, - }, - Env1Attack => ParamMeta { - name: "Env1 Attack", - label: "s", - steps: None, - }, - Env1Decay => ParamMeta { - name: "Env1 Decay", - label: "s", - steps: None, - }, - Env1Sustain => ParamMeta { - name: "Env1 Sustain", - label: "%", - steps: None, - }, - Env1Release => ParamMeta { - name: "Env1 Release", - label: "s", - steps: None, - }, - Macro1 => ParamMeta { - name: "Macro 1", - label: "", - steps: None, - }, - _ => ParamMeta { - name: "?", - label: "", - steps: None, - }, - } -} diff --git a/crates/ui/src/app.rs b/crates/ui/src/app.rs new file mode 100644 index 0000000..ef86367 --- /dev/null +++ b/crates/ui/src/app.rs @@ -0,0 +1,136 @@ +use params::{ParamId, ParamStore}; +use vizia::prelude::*; + +#[derive(Clone, Copy, Debug, PartialEq, Data)] +pub enum Panel { + Osc, + Env, + Lfo, + Filter, + Fx, + ModMatrix, +} + +impl Panel { + pub const ALL: &'static [Self] = &[ + Self::Osc, + Self::Env, + Self::Lfo, + Self::Filter, + Self::Fx, + Self::ModMatrix, + ]; + 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", + } + } +} + +#[derive(Lens, Clone)] +pub struct AppData { + pub params: Vec, + pub active_panel: Panel, + pub preset_name: String, + pub voice_count: u8, + pub cpu_load: f32, + pub host_bpm: f32, + #[lens(ignore)] + pub store: ParamStore, +} + +impl AppData { + pub fn new(store: ParamStore) -> Self { + let params = (0..ParamId::COUNT) + .map(|i| store.get(unsafe { std::mem::transmute::(i) })) + .collect(); + Self { + params, + store, + active_panel: Panel::Osc, + preset_name: "Init".into(), + voice_count: 0, + cpu_load: 0.0, + host_bpm: 120.0, + } + } +} + +#[derive(Debug)] +pub enum AppEvent { + SetParam(ParamId, f32), + SetPanel(Panel), + UpdateMetrics { voices: u8, cpu: f32 }, +} + +impl Model for AppData { + fn event(&mut self, _cx: &mut EventContext, event: &mut Event) { + event.map(|e: &AppEvent, _| match e { + AppEvent::SetParam(id, val) => { + let v = val.clamp(0.0, 1.0); + self.params[*id as usize] = v; + self.store.set(*id, v); + } + AppEvent::SetPanel(p) => self.active_panel = *p, + AppEvent::UpdateMetrics { voices, cpu } => { + self.voice_count = *voices; + self.cpu_load = *cpu; + } + }); + } +} + +pub fn build_root(cx: &mut Context) { + VStack::new(cx, |cx| { + 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)); + + 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), + }) + .class("panel") + .width(Stretch(1.0)) + .height(Stretch(1.0)); + }); + + crate::panels::macro_bar::build(cx); + }) + .height(Stretch(1.0)); + }) + .background_color(Color::rgb(18, 18, 28)) + .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/lib.rs b/crates/ui/src/lib.rs index e3ea0ef..92a7be4 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,9 +1,3 @@ -pub mod knob {} -pub mod env_disp {} -pub mod wave_disp {} -pub mod mod_matrix {} -pub mod spectrum {} - -pub fn run_ui(_params: params::ParamStore) { - unimplemented!("stub") -} +pub mod app; +pub mod panels; +pub mod widgets; diff --git a/crates/ui/src/main.rs b/crates/ui/src/main.rs new file mode 100644 index 0000000..b672133 --- /dev/null +++ b/crates/ui/src/main.rs @@ -0,0 +1,20 @@ +mod app; +mod panels; +mod widgets; + +fn main() { + use app::AppData; + use params::ParamStore; + use vizia::prelude::*; + + let _ = Application::new(|cx| { + cx.add_stylesheet(include_str!("theme.css")) + .expect("theme.css"); + AppData::new(ParamStore::new()).build(cx); + app::build_root(cx); + }) + .title("Tenko") + .inner_size((1280, 760)) + .resizable(false) + .run(); +} diff --git a/crates/ui/src/panels/env_panel.rs b/crates/ui/src/panels/env_panel.rs new file mode 100644 index 0000000..89aef2e --- /dev/null +++ b/crates/ui/src/panels/env_panel.rs @@ -0,0 +1,69 @@ +use crate::widgets::{env_display::EnvDisplay, knob::labeled_knob}; +use params::ParamId; +use vizia::prelude::*; + +const ENV_KNOBS: [[ParamId; 9]; 3] = [ + [ + ParamId::Env1Delay, + ParamId::Env1Attack, + ParamId::Env1Hold, + ParamId::Env1Decay, + ParamId::Env1Sustain, + ParamId::Env1Release, + ParamId::Env1AttackCurve, + ParamId::Env1DecayCurve, + ParamId::Env1ReleaseCurve, + ], + [ + ParamId::Env2Delay, + ParamId::Env2Attack, + ParamId::Env2Hold, + ParamId::Env2Decay, + ParamId::Env2Sustain, + ParamId::Env2Release, + ParamId::Env2AttackCurve, + ParamId::Env2DecayCurve, + ParamId::Env2ReleaseCurve, + ], + [ + ParamId::Env3Delay, + ParamId::Env3Attack, + ParamId::Env3Hold, + ParamId::Env3Decay, + ParamId::Env3Sustain, + ParamId::Env3Release, + ParamId::Env3AttackCurve, + ParamId::Env3DecayCurve, + ParamId::Env3ReleaseCurve, + ], +]; + +pub fn build(cx: &mut Context) { + VStack::new(cx, |cx| { + Label::new(cx, "ENVELOPES") + .class("section-title") + .bottom(Pixels(6.0)); + for (i, knobs) in ENV_KNOBS.iter().enumerate() { + HStack::new(cx, |cx| { + VStack::new(cx, |cx| { + Label::new(cx, format!("ENV {}", i + 1)).class("knob-label"); + EnvDisplay::new(cx, i); + }) + .width(Pixels(218.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(108.0)); + } + }) + .vertical_gap(Pixels(8.0)) + .padding(Pixels(12.0)); +} diff --git a/crates/ui/src/panels/filter.rs b/crates/ui/src/panels/filter.rs new file mode 100644 index 0000000..f75ae05 --- /dev/null +++ b/crates/ui/src/panels/filter.rs @@ -0,0 +1,71 @@ +use crate::widgets::knob::labeled_knob; +use params::ParamId; +use vizia::prelude::*; + +const FILTER_KNOBS: [[ParamId; 4]; 2] = [ + [ + ParamId::Filter1Cutoff, + ParamId::Filter1Resonance, + ParamId::Filter1Drive, + ParamId::Filter1Keytrack, + ], + [ + ParamId::Filter2Cutoff, + ParamId::Filter2Resonance, + ParamId::Filter2Drive, + ParamId::Filter2Keytrack, + ], +]; +const TYPES: [&str; 4] = ["LADDER", "SVF", "COMB", "FORMANT"]; + +pub fn build(cx: &mut Context) { + VStack::new(cx, |cx| { + Label::new(cx, "FILTERS") + .class("section-title") + .bottom(Pixels(6.0)); + + HStack::new(cx, |cx| { + for m in ["SERIAL", "PARALLEL"] { + Label::new(cx, m).class("tab").padding(Pixels(5.0)); + } + }) + .bottom(Pixels(8.0)); + + 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"); + HStack::new(cx, |cx| { + for t in TYPES { + Label::new(cx, t) + .class("tab") + .padding(Pixels(3.0)) + .height(Pixels(20.0)); + } + }) + .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)); + } + }) + .vertical_gap(Pixels(8.0)) + .padding(Pixels(12.0)); +} diff --git a/crates/ui/src/panels/fx.rs b/crates/ui/src/panels/fx.rs new file mode 100644 index 0000000..5a6cd4a --- /dev/null +++ b/crates/ui/src/panels/fx.rs @@ -0,0 +1,95 @@ +use crate::widgets::knob::labeled_knob; +use params::ParamId; +use vizia::prelude::*; + +struct Slot { + name: &'static str, + params: &'static [ParamId], +} + +const CHAIN: &[Slot] = &[ + Slot { + name: "DIST", + params: &[ParamId::DistDrive], + }, + Slot { + name: "CHORUS", + params: &[ + ParamId::ChorusRate, + ParamId::ChorusDepth, + ParamId::ChorusMix, + ], + }, + Slot { + name: "PHASER", + params: &[ + ParamId::PhaserRate, + ParamId::PhaserDepth, + ParamId::PhaserMix, + ], + }, + Slot { + name: "REVERB", + params: &[ + ParamId::ReverbSize, + ParamId::ReverbDamping, + ParamId::ReverbMix, + ], + }, + Slot { + name: "DELAY", + params: &[ + ParamId::DelayTime, + ParamId::DelayFeedback, + ParamId::DelayMix, + ], + }, + Slot { + name: "EQ", + params: &[ + ParamId::EqLowGain, + ParamId::EqMidFreq, + ParamId::EqMidGain, + ParamId::EqHighGain, + ], + }, +]; + +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); + } + }) + .vertical_gap(Pixels(5.0)) + .padding(Pixels(12.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)); + HStack::new(cx, |cx| { + for &p in slot.params { + labeled_knob(cx, p); + } + }) + .horizontal_gap(Pixels(2.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)); +} diff --git a/crates/ui/src/panels/header.rs b/crates/ui/src/panels/header.rs new file mode 100644 index 0000000..9c2fb19 --- /dev/null +++ b/crates/ui/src/panels/header.rs @@ -0,0 +1,38 @@ +use crate::app::AppData; +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)); + + Element::new(cx).width(Stretch(1.0)).height(Stretch(1.0)); + + stat_pair(cx, "BPM", AppData::host_bpm.map(|b| format!("{b:.0}"))); + stat_pair(cx, "VOICES", AppData::voice_count.map(|v| format!("{v}"))); + stat_pair( + cx, + "CPU", + AppData::cpu_load.map(|c| format!("{:.0}%", c * 100.0)), + ); + }) + .height(Pixels(34.0)) + .background_color(Color::from("#0e0e1a")) + .padding(Pixels(8.0)) + .padding_top(Stretch(1.0)) + .padding_bottom(Stretch(1.0)) + .horizontal_gap(Pixels(16.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")); + Label::new(cx, val_lens) + .color(Color::from("#ff7830")) + .left(Pixels(4.0)); + }); +} diff --git a/crates/ui/src/panels/lfo.rs b/crates/ui/src/panels/lfo.rs new file mode 100644 index 0000000..65c068d --- /dev/null +++ b/crates/ui/src/panels/lfo.rs @@ -0,0 +1,69 @@ +use crate::widgets::{knob::labeled_knob, wavetable_display::WavetableDisplay}; +use params::ParamId; +use vizia::prelude::*; + +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, + ], +]; + +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)); + } + Button::new(cx, |cx| Label::new(cx, "+ LFO")) + .class("add-btn") + .top(Pixels(6.0)); + }) + .vertical_gap(Pixels(8.0)) + .padding(Pixels(12.0)); +} diff --git a/crates/ui/src/panels/macro_bar.rs b/crates/ui/src/panels/macro_bar.rs new file mode 100644 index 0000000..0d709dd --- /dev/null +++ b/crates/ui/src/panels/macro_bar.rs @@ -0,0 +1,33 @@ +use crate::widgets::knob::labeled_knob; +use params::ParamId; +use vizia::prelude::*; + +const MACROS: [ParamId; 8] = [ + 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)); + for &m in &MACROS { + VStack::new(cx, |cx| { + labeled_knob(cx, m); + }) + .class("macro-knob") + .bottom(Pixels(3.0)); + } + }) + .width(Pixels(68.0)) + .background_color(Color::from("#16162a")) + .padding(Pixels(8.0)) + .vertical_gap(Pixels(2.0)); +} diff --git a/crates/ui/src/panels/mod.rs b/crates/ui/src/panels/mod.rs new file mode 100644 index 0000000..e0438fc --- /dev/null +++ b/crates/ui/src/panels/mod.rs @@ -0,0 +1,8 @@ +pub mod env_panel; +pub mod filter; +pub mod fx; +pub mod header; +pub mod lfo; +pub mod macro_bar; +pub mod mod_matrix; +pub mod osc; diff --git a/crates/ui/src/panels/mod_matrix.rs b/crates/ui/src/panels/mod_matrix.rs new file mode 100644 index 0000000..9510107 --- /dev/null +++ b/crates/ui/src/panels/mod_matrix.rs @@ -0,0 +1,61 @@ +use vizia::prelude::*; + +const SOURCES: &[&str] = &[ + "ENV1", "ENV2", "ENV3", "LFO1", "LFO2", "LFO3", "LFO4", "VEL", "NOTE", "MOD", "AFT", "M1", + "M2", "M3", "M4", "M5", "M6", "M7", "M8", +]; +const DESTS: &[&str] = &[ + "CUT1", "RES1", "CUT2", "RES2", "P1", "P2", "P3", "AMP", "PAN", "LR1", "LR2", +]; + +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") + .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 { + Label::new(cx, d) + .class("knob-label") + .width(Pixels(38.0)) + .text_align(TextAlign::Center); + } + }) + .height(Pixels(20.0)); + + for &src in SOURCES { + HStack::new(cx, |cx| { + Label::new(cx, src).class("knob-label").width(Pixels(48.0)); + for _ in DESTS { + mod_cell(cx); + } + }) + .height(Pixels(34.0)) + .padding_top(Stretch(1.0)) + .padding_bottom(Stretch(1.0)); + } + }); + }); + }) + .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); +} diff --git a/crates/ui/src/panels/osc.rs b/crates/ui/src/panels/osc.rs new file mode 100644 index 0000000..cc669e5 --- /dev/null +++ b/crates/ui/src/panels/osc.rs @@ -0,0 +1,76 @@ +use crate::widgets::{knob::labeled_knob, wavetable_display::WavetableDisplay}; +use params::ParamId; +use vizia::prelude::*; + +const OSC_KNOBS: [[ParamId; 8]; 3] = [ + [ + ParamId::Osc1Gain, + ParamId::Osc1Pan, + ParamId::Osc1Semitone, + ParamId::Osc1Fine, + ParamId::Osc1UnisonCount, + ParamId::Osc1UnisonDetune, + ParamId::Osc1UnisonSpread, + ParamId::Osc1WavePos, + ], + [ + ParamId::Osc2Gain, + ParamId::Osc2Pan, + ParamId::Osc2Semitone, + ParamId::Osc2Fine, + ParamId::Osc2UnisonCount, + ParamId::Osc2UnisonDetune, + ParamId::Osc2UnisonSpread, + ParamId::Osc2WavePos, + ], + [ + ParamId::Osc3Gain, + ParamId::Osc3Pan, + ParamId::Osc3Semitone, + ParamId::Osc3Fine, + ParamId::Osc3UnisonCount, + ParamId::Osc3UnisonDetune, + ParamId::Osc3UnisonSpread, + ParamId::Osc3WavePos, + ], +]; + +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); + } + Button::new(cx, |cx| Label::new(cx, "+ OSC")) + .class("add-btn") + .top(Pixels(6.0)); + }) + .vertical_gap(Pixels(8.0)) + .padding(Pixels(12.0)); +} + +fn osc_row(cx: &mut Context, idx: usize, knobs: &'static [ParamId; 8]) { + HStack::new(cx, |cx| { + 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); + }) + .width(Pixels(158.0)); + HStack::new(cx, |cx| { + for &p in knobs { + 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(98.0)); +} diff --git a/crates/ui/src/theme.css b/crates/ui/src/theme.css new file mode 100644 index 0000000..0460f1c --- /dev/null +++ b/crates/ui/src/theme.css @@ -0,0 +1,49 @@ +:root { + color-scheme: dark; +} + +* { + font-family: "Inter", "Segoe UI", 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; + padding: 8px; +} + +.add-btn { + background-color: #22223a; + color: #888899; + border-radius: 4px; + padding: 4px 10px; +} diff --git a/crates/ui/src/widgets/env_display.rs b/crates/ui/src/widgets/env_display.rs new file mode 100644 index 0000000..cbf7be5 --- /dev/null +++ b/crates/ui/src/widgets/env_display.rs @@ -0,0 +1,111 @@ +use params::ParamId; +use vizia::prelude::*; +use vizia::vg; + +pub struct EnvDisplay { + pub delay: f32, + pub attack: f32, + pub hold: f32, + pub decay: f32, + pub sustain: f32, + pub release: f32, +} + +impl EnvDisplay { + pub fn new(cx: &mut Context, env_idx: usize) -> Handle { + let ids = env_param_ids(env_idx); + let g = |id: ParamId| { + cx.data::() + .map(|d| d.params[id as usize]) + .unwrap_or(0.0) + }; + Self { + delay: g(ids[0]), + attack: g(ids[1]), + hold: g(ids[2]), + decay: g(ids[3]), + sustain: g(ids[4]), + release: g(ids[5]), + } + .build(cx, |_| {}) + .width(Pixels(210.0)) + .height(Pixels(72.0)) + } +} + +fn env_param_ids(idx: usize) -> [ParamId; 6] { + match idx { + 0 => [ + ParamId::Env1Delay, + ParamId::Env1Attack, + ParamId::Env1Hold, + ParamId::Env1Decay, + ParamId::Env1Sustain, + ParamId::Env1Release, + ], + 1 => [ + ParamId::Env2Delay, + ParamId::Env2Attack, + ParamId::Env2Hold, + ParamId::Env2Decay, + ParamId::Env2Sustain, + ParamId::Env2Release, + ], + _ => [ + ParamId::Env3Delay, + ParamId::Env3Attack, + ParamId::Env3Hold, + ParamId::Env3Decay, + ParamId::Env3Sustain, + ParamId::Env3Release, + ], + } +} + +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); + + let total = + (self.delay + self.attack + self.hold + self.decay + 0.25 + self.release).max(0.001); + let sx = |t: f32| b.x + (t / total) * b.w; + let top = b.y + 6.0; + let bot = b.y + b.h - 6.0; + let sy = top + (1.0 - self.sustain) * (bot - top); + + let mut t = 0.0f32; + let x0 = sx(t); + t += self.delay; + let x1 = sx(t); + t += self.attack; + let x2 = sx(t); + t += self.hold; + let x3 = sx(t); + t += self.decay; + let x4 = sx(t); + t += 0.25; + let x5 = sx(t); + t += self.release; + let x6 = sx(t); + + let mut path = vg::Path::new(); + path.move_to((x0, bot)); + path.line_to((x1, bot)); + path.line_to((x2, top)); + path.line_to((x3, top)); + path.line_to((x4, sy)); + path.line_to((x5, sy)); + path.line_to((x6, bot)); + + p.set_style(vg::PaintStyle::Stroke); + p.set_color(vg::Color::from_argb(255, 255, 120, 48)); + p.set_stroke_width(2.0); + p.set_stroke_cap(vg::paint::Cap::Round); + p.set_stroke_join(vg::paint::Join::Round); + p.set_anti_alias(true); + canvas.draw_path(&path, &p); + } +} diff --git a/crates/ui/src/widgets/knob.rs b/crates/ui/src/widgets/knob.rs new file mode 100644 index 0000000..139c517 --- /dev/null +++ b/crates/ui/src/widgets/knob.rs @@ -0,0 +1,135 @@ +use crate::app::AppEvent; +use params::ParamId; +use std::f32::consts::PI; +use vizia::prelude::*; +use vizia::vg; + +const ARC_START: f32 = PI * 0.75; +const ARC_RANGE: f32 = PI * 1.5; + +pub struct TenkoKnob { + pub param: ParamId, + value: f32, + drag_origin_y: Option, + drag_origin_v: f32, +} + +impl TenkoKnob { + pub fn new(cx: &mut Context, param: ParamId) -> Handle { + let init = cx + .data::() + .map(|d| d.params[param as usize]) + .unwrap_or(param.default_value()); + + Self { + param, + value: init, + drag_origin_y: None, + drag_origin_v: 0.0, + } + .build(cx, |_| {}) + .bind( + crate::app::AppData::params.idx(param as usize), + |h, lens| { + let v = lens.get(&h); + h.modify(|k: &mut TenkoKnob| k.value = v); + }, + ) + .width(Pixels(46.0)) + .height(Pixels(46.0)) + .cursor(CursorIcon::NsResize) + } +} + +impl View for TenkoKnob { + fn element(&self) -> Option<&'static str> { + Some("tenko-knob") + } + + fn event(&mut self, cx: &mut EventContext, event: &mut Event) { + event.map(|we: &WindowEvent, _| match we { + WindowEvent::MouseDown(MouseButton::Left) => { + cx.capture(); + self.drag_origin_y = Some(cx.mouse().cursor_y); + self.drag_origin_v = self.value; + } + WindowEvent::MouseUp(MouseButton::Left) => { + cx.release(); + self.drag_origin_y = None; + } + WindowEvent::MouseMove(_, y) => { + if let Some(oy) = self.drag_origin_y { + let sens = if cx.modifiers().shift() { + 0.0015 + } else { + 0.004 + }; + let new_v = (self.drag_origin_v + (oy - y) * sens).clamp(0.0, 1.0); + cx.emit(AppEvent::SetParam(self.param, new_v)); + } + } + WindowEvent::MouseDoubleClick(MouseButton::Left) => { + cx.emit(AppEvent::SetParam(self.param, self.param.default_value())); + } + _ => {} + }); + } + + fn draw(&self, cx: &mut DrawContext, canvas: &Canvas) { + let b = cx.bounds(); + let kx = b.x + b.w * 0.5; + let ky = b.y + b.h * 0.5; + let r = b.w.min(b.h) * 0.5 - 5.0; + let val_end = ARC_START + self.value * ARC_RANGE; + + let mut paint = vg::Paint::default(); + paint.set_anti_alias(true); + paint.set_style(vg::PaintStyle::Stroke); + paint.set_stroke_width(3.5); + paint.set_stroke_cap(vg::paint::Cap::Round); + paint.set_color(vg::Color::from_argb(255, 45, 45, 65)); + + let track_rect = vg::Rect::from_xywh(kx - r, ky - r, r * 2.0, r * 2.0); + let mut path = vg::Path::new(); + path.add_arc(track_rect, ARC_START.to_degrees(), ARC_RANGE.to_degrees()); + canvas.draw_path(&path, &paint); + + if self.value > 0.001 { + paint.set_color(vg::Color::from_argb(255, 255, 120, 48)); + let mut vpath = vg::Path::new(); + vpath.add_arc( + track_rect, + ARC_START.to_degrees(), + (self.value * ARC_RANGE).to_degrees(), + ); + canvas.draw_path(&vpath, &paint); + } + + paint.set_style(vg::PaintStyle::Fill); + paint.set_color(vg::Color::from_argb(255, 30, 30, 48)); + canvas.draw_circle((kx, ky), r - 6.0, &paint); + + let px = kx + val_end.cos() * (r - 9.0); + let py = ky + val_end.sin() * (r - 9.0); + paint.set_style(vg::PaintStyle::Stroke); + paint.set_stroke_width(2.0); + paint.set_color(vg::Color::from_argb(255, 220, 220, 235)); + let mut ptr = vg::Path::new(); + ptr.move_to((kx, ky)); + ptr.line_to((px, py)); + canvas.draw_path(&ptr, &paint); + } +} + +pub fn labeled_knob(cx: &mut Context, param: ParamId) { + VStack::new(cx, |cx| { + TenkoKnob::new(cx, param); + Label::new(cx, param.label()) + .class("knob-label") + .text_align(TextAlign::Center) + .width(Stretch(1.0)); + }) + .width(Pixels(50.0)) + .height(Pixels(62.0)) + .padding(Pixels(2.0)); +} diff --git a/crates/ui/src/widgets/mod.rs b/crates/ui/src/widgets/mod.rs new file mode 100644 index 0000000..a2d15b9 --- /dev/null +++ b/crates/ui/src/widgets/mod.rs @@ -0,0 +1,3 @@ +pub mod env_display; +pub mod knob; +pub mod wavetable_display; diff --git a/crates/ui/src/widgets/wavetable_display.rs b/crates/ui/src/widgets/wavetable_display.rs new file mode 100644 index 0000000..98dc6dc --- /dev/null +++ b/crates/ui/src/widgets/wavetable_display.rs @@ -0,0 +1,54 @@ +use vizia::prelude::*; +use vizia::vg; + +pub struct WavetableDisplay { + pub samples: Vec, +} + +impl WavetableDisplay { + pub fn new(cx: &mut Context, samples: Vec) -> Handle { + Self { samples } + .build(cx, |_| {}) + .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); + let mid_y = b.y + b.h * 0.5; + let mut zl = vg::Path::new(); + zl.move_to((b.x, mid_y)); + zl.line_to((b.x + b.w, mid_y)); + canvas.draw_path(&zl, &p); + + if self.samples.is_empty() { + 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; + let y = mid_y - s * b.h * 0.44; + if i == 0 { + wave.move_to((x, y)); + } else { + wave.line_to((x, y)); + } + } + p.set_color(vg::Color::from_argb(255, 255, 120, 48)); + p.set_stroke_width(1.5); + p.set_anti_alias(true); + canvas.draw_path(&wave, &p); + } +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -}