some sort of ui
This commit is contained in:
@@ -4,4 +4,5 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crossbeam-channel.workspace = true
|
||||
params = { workspace = true }
|
||||
|
||||
61
crates/engine/src/envelope.rs
Normal file
61
crates/engine/src/envelope.rs
Normal 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
|
||||
}
|
||||
}
|
||||
38
crates/engine/src/filter.rs
Normal file
38
crates/engine/src/filter.rs
Normal 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
38
crates/engine/src/lfo.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
Self {
|
||||
params,
|
||||
sample_rate,
|
||||
}
|
||||
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]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
crates/engine/src/mod_matrix.rs
Normal file
46
crates/engine/src/mod_matrix.rs
Normal 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: ¶ms::ParamStore) {}
|
||||
}
|
||||
34
crates/engine/src/oscillator.rs
Normal file
34
crates/engine/src/oscillator.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
31
crates/engine/src/voice.rs
Normal file
31
crates/engine/src/voice.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,6 @@
|
||||
name = "params"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
atomic_float.workspace = true
|
||||
|
||||
@@ -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<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))
|
||||
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<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);
|
||||
}
|
||||
#[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
136
crates/ui/src/app.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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
20
crates/ui/src/main.rs
Normal 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();
|
||||
}
|
||||
69
crates/ui/src/panels/env_panel.rs
Normal file
69
crates/ui/src/panels/env_panel.rs
Normal 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));
|
||||
}
|
||||
71
crates/ui/src/panels/filter.rs
Normal file
71
crates/ui/src/panels/filter.rs
Normal 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));
|
||||
}
|
||||
95
crates/ui/src/panels/fx.rs
Normal file
95
crates/ui/src/panels/fx.rs
Normal 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));
|
||||
}
|
||||
38
crates/ui/src/panels/header.rs
Normal file
38
crates/ui/src/panels/header.rs
Normal 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));
|
||||
});
|
||||
}
|
||||
69
crates/ui/src/panels/lfo.rs
Normal file
69
crates/ui/src/panels/lfo.rs
Normal 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));
|
||||
}
|
||||
33
crates/ui/src/panels/macro_bar.rs
Normal file
33
crates/ui/src/panels/macro_bar.rs
Normal 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));
|
||||
}
|
||||
8
crates/ui/src/panels/mod.rs
Normal file
8
crates/ui/src/panels/mod.rs
Normal 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;
|
||||
61
crates/ui/src/panels/mod_matrix.rs
Normal file
61
crates/ui/src/panels/mod_matrix.rs
Normal 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);
|
||||
}
|
||||
76
crates/ui/src/panels/osc.rs
Normal file
76
crates/ui/src/panels/osc.rs
Normal 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
49
crates/ui/src/theme.css
Normal 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;
|
||||
}
|
||||
111
crates/ui/src/widgets/env_display.rs
Normal file
111
crates/ui/src/widgets/env_display.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
135
crates/ui/src/widgets/knob.rs
Normal file
135
crates/ui/src/widgets/knob.rs
Normal 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));
|
||||
}
|
||||
3
crates/ui/src/widgets/mod.rs
Normal file
3
crates/ui/src/widgets/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod env_display;
|
||||
pub mod knob;
|
||||
pub mod wavetable_display;
|
||||
54
crates/ui/src/widgets/wavetable_display.rs
Normal file
54
crates/ui/src/widgets/wavetable_display.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user