some sort of ui

This commit is contained in:
2026-04-19 16:20:33 +03:00
parent 4f14980610
commit cdff703f7e
30 changed files with 1504 additions and 184 deletions

19
Cargo.lock generated
View File

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

View File

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

View File

@@ -4,4 +4,5 @@ version.workspace = true
edition.workspace = true
[dependencies]
crossbeam-channel.workspace = true
params = { workspace = true }

View File

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

View File

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

38
crates/engine/src/lfo.rs Normal file
View File

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

View File

@@ -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<EngineMetrics>,
}
impl Engine {
pub fn new(params: ParamStore, sample_rate: f32) -> Self {
pub fn new(params: ParamStore, sample_rate: f32) -> (Self, Receiver<EngineMetrics>) {
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]),
_ => {}
}
}
}

View File

@@ -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: &params::ParamStore) {}
}

View File

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

View File

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

View File

@@ -2,3 +2,6 @@
name = "params"
version.workspace = true
edition.workspace = true
[dependencies]
atomic_float.workspace = true

View File

@@ -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 14
// 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;
pub fn default_value(id: ParamId) -> f32 {
use ParamId::*;
match id {
MasterVolume => 0.8,
MasterPan => 0.5,
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,
impl ParamId {
pub const COUNT: usize = ParamId::Count as usize;
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,
}
}
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<Box<[AtomicF32]>>,
}
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::<Vec<_>>()
.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))
}
#[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<f32> {
(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);
self.params[id as usize].load(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<u32>,
}
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,
},
}
}

136
crates/ui/src/app.rs Normal file
View File

@@ -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<f32>,
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::<usize, ParamId>(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);
});
}

View File

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

20
crates/ui/src/main.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Target = String>) {
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));
});
}

View File

@@ -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<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));
}
Button::new(cx, |cx| Label::new(cx, "+ LFO"))
.class("add-btn")
.top(Pixels(6.0));
})
.vertical_gap(Pixels(8.0))
.padding(Pixels(12.0));
}

View File

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

View File

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

View File

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

View File

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

49
crates/ui/src/theme.css Normal file
View File

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

View File

@@ -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<Self> {
let ids = env_param_ids(env_idx);
let g = |id: ParamId| {
cx.data::<crate::app::AppData>()
.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);
}
}

View File

@@ -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<f32>,
drag_origin_v: f32,
}
impl TenkoKnob {
pub fn new(cx: &mut Context, param: ParamId) -> Handle<Self> {
let init = cx
.data::<crate::app::AppData>()
.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));
}

View File

@@ -0,0 +1,3 @@
pub mod env_display;
pub mod knob;
pub mod wavetable_display;

View File

@@ -0,0 +1,54 @@
use vizia::prelude::*;
use vizia::vg;
pub struct WavetableDisplay {
pub samples: Vec<f32>,
}
impl WavetableDisplay {
pub fn new(cx: &mut Context, samples: Vec<f32>) -> Handle<Self> {
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);
}
}

View File

@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}