feat: add Anvil sub anchor and Crucible resampler
Check and Windows preview / check (anvil) (push) Successful in 2m41s
Check and Windows preview / check (crucible) (push) Successful in 2m7s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m33s
Check and Windows preview / previews (push) Successful in 1m10s
Check and Windows preview / windows (push) Successful in 2m2s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m3s
Check and Windows preview / check (anvil) (push) Successful in 2m41s
Check and Windows preview / check (crucible) (push) Successful in 2m7s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m33s
Check and Windows preview / previews (push) Successful in 1m10s
Check and Windows preview / windows (push) Successful in 2m2s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m3s
Two new CLAP effects aimed at riddim and dubstep production, plus shared crates so all four plugins keep one look and reuse DSP. Anvil 0.1.0 (io.storyboard.sub-anchor): - Replaces the band below Split with a clean mono sine. Every note gets the same peak level and restarts at the same phase, so switch-ups keep their weight and kick hits line up. - Pitch and note starts come from the audio (YIN, 80 ms lookahead reported to the host) or from MIDI notes sent to the effect. - Dynamics, Drive, Octave, Glide, Phase, Gate and Keep; Solo sub monitor, note readout, level meters and a low-band oscilloscope. Crucible 0.1.0 (io.storyboard.resampler): - Records its input continuously in a lock-free ring with host transport markers. Capture takes the newest 1-16 complete bars on the bar grid, following tempo changes, or the newest audio when stopped. - Grid (straight and triplet) or transient slicing, manual cuts, slice audition without audio-thread allocation. - Export writes the loop, trimmed, declicked and normalised slices named and tagged with their root note, and an optional 2048-sample wavetable for Serum and Vital. Shared: - common-ui holds the palette, fonts, knobs and panels taken from Patina; Patina renders identically. Rotary knobs at their minimum no longer tessellate a zero-length arc into NaN vertices. - common-dsp holds biquads, a Linkwitz-Riley crossover, YIN, note helpers and a float WAV writer with sampler root and Serum frame chunks. - Catalog, CI matrix, release tags, editor previews and docs cover both plugins; a script test keeps the catalog and workflows in step.
This commit is contained in:
1 parent
f1366280ff
commit
f36ff0780c
57 files changed
+10373
-285
No files matched your search
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "common-dsp"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files.
|
||||
pub mod wav;
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Transposed direct form II biquad (RBJ cookbook coefficients).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Biquad {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
z1: f32,
|
||||
z2: f32,
|
||||
}
|
||||
impl Biquad {
|
||||
fn from(b: [f32; 3], a: [f32; 3]) -> Self {
|
||||
Self {
|
||||
b0: b[0] / a[0],
|
||||
b1: b[1] / a[0],
|
||||
b2: b[2] / a[0],
|
||||
a1: a[1] / a[0],
|
||||
a2: a[2] / a[0],
|
||||
z1: 0.,
|
||||
z2: 0.,
|
||||
}
|
||||
}
|
||||
pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[(1. - c) / 2., 1. - c, (1. - c) / 2.],
|
||||
[1. + alpha, -2. * c, 1. - alpha],
|
||||
)
|
||||
}
|
||||
pub fn highpass(rate: f32, hz: f32, q: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[(1. + c) / 2., -(1. + c), (1. + c) / 2.],
|
||||
[1. + alpha, -2. * c, 1. - alpha],
|
||||
)
|
||||
}
|
||||
/// Peaking EQ; used by tests to model resonant material.
|
||||
pub fn peak(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let a = 10f32.powf(gain_db / 40.);
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[1. + alpha * a, -2. * c, 1. - alpha * a],
|
||||
[1. + alpha / a, -2. * c, 1. - alpha / a],
|
||||
)
|
||||
}
|
||||
/// Replace the coefficients and keep the filter state, for smooth parameter changes.
|
||||
pub fn retune(&mut self, other: Self) {
|
||||
let (z1, z2) = (self.z1, self.z2);
|
||||
*self = other;
|
||||
self.z1 = z1;
|
||||
self.z2 = z2;
|
||||
}
|
||||
#[inline]
|
||||
pub fn process(&mut self, x: f32) -> f32 {
|
||||
let y = self.b0 * x + self.z1;
|
||||
self.z1 = self.b1 * x - self.a1 * y + self.z2;
|
||||
self.z2 = self.b2 * x - self.a2 * y;
|
||||
y
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.z1 = 0.;
|
||||
self.z2 = 0.;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fourth-order Linkwitz–Riley crossover. Low plus high is an allpass: flat magnitude.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Crossover {
|
||||
low: [Biquad; 2],
|
||||
high: [Biquad; 2],
|
||||
}
|
||||
impl Crossover {
|
||||
pub fn new(rate: f32, hz: f32) -> Self {
|
||||
let q = std::f32::consts::FRAC_1_SQRT_2;
|
||||
Self {
|
||||
low: [Biquad::lowpass(rate, hz, q); 2],
|
||||
high: [Biquad::highpass(rate, hz, q); 2],
|
||||
}
|
||||
}
|
||||
pub fn retune(&mut self, rate: f32, hz: f32) {
|
||||
let fresh = Self::new(rate, hz);
|
||||
for i in 0..2 {
|
||||
self.low[i].retune(fresh.low[i]);
|
||||
self.high[i].retune(fresh.high[i]);
|
||||
}
|
||||
}
|
||||
/// Returns (low, high).
|
||||
#[inline]
|
||||
pub fn process(&mut self, x: f32) -> (f32, f32) {
|
||||
let low = self.low[0].process(x);
|
||||
let low = self.low[1].process(low);
|
||||
let high = self.high[0].process(x);
|
||||
let high = self.high[1].process(high);
|
||||
// LR4 bands are in phase at the crossover point, so they sum to an allpass.
|
||||
(low, high)
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
for f in self.low.iter_mut().chain(self.high.iter_mut()) {
|
||||
f.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// YIN fundamental-frequency estimator with preallocated scratch.
|
||||
pub struct Yin {
|
||||
diff: Vec<f32>,
|
||||
pub threshold: f32,
|
||||
}
|
||||
impl Yin {
|
||||
/// `max_lag` is the longest period in samples that will ever be requested.
|
||||
pub fn new(max_lag: usize) -> Self {
|
||||
Self {
|
||||
diff: vec![0.; max_lag + 2],
|
||||
threshold: 0.15,
|
||||
}
|
||||
}
|
||||
pub fn max_lag(&self) -> usize {
|
||||
self.diff.len() - 2
|
||||
}
|
||||
/// Estimates the fundamental of `x` in Hz with its clarity (1 = perfectly periodic).
|
||||
/// `x` must hold at least twice the longest period. Never allocates.
|
||||
pub fn estimate(
|
||||
&mut self,
|
||||
x: &[f32],
|
||||
rate: f32,
|
||||
min_hz: f32,
|
||||
max_hz: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
let hi = ((rate / min_hz).ceil() as usize).min(self.max_lag());
|
||||
let lo = ((rate / max_hz).floor() as usize).max(2);
|
||||
if hi <= lo + 2 || x.len() < hi + 2 {
|
||||
return None;
|
||||
}
|
||||
let window = x.len() - hi - 1;
|
||||
if window < hi {
|
||||
return None;
|
||||
}
|
||||
let energy: f32 = x[..window].iter().map(|v| v * v).sum();
|
||||
if energy < 1e-9 * window as f32 {
|
||||
return None;
|
||||
}
|
||||
let d = &mut self.diff;
|
||||
d[0] = 1.;
|
||||
let mut running = 0.;
|
||||
for tau in 1..=hi + 1 {
|
||||
let mut s = 0.;
|
||||
for j in 0..window {
|
||||
let e = x[j] - x[j + tau];
|
||||
s += e * e;
|
||||
}
|
||||
running += s;
|
||||
// Cumulative-mean normalisation: suppresses the zero-lag dip.
|
||||
d[tau] = if running > 0. {
|
||||
s * tau as f32 / running
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
}
|
||||
let mut best = None;
|
||||
let mut tau = lo;
|
||||
while tau < hi {
|
||||
if d[tau] < self.threshold {
|
||||
while tau + 1 < hi && d[tau + 1] < d[tau] {
|
||||
tau += 1;
|
||||
}
|
||||
best = Some(tau);
|
||||
break;
|
||||
}
|
||||
tau += 1;
|
||||
}
|
||||
let tau = match best {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let (t, v) = (lo..hi)
|
||||
.map(|t| (t, d[t]))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
if v > 0.35 {
|
||||
return None;
|
||||
}
|
||||
t
|
||||
}
|
||||
};
|
||||
let (a, b, c) = (d[tau - 1], d[tau], d[tau + 1]);
|
||||
let denom = a - 2. * b + c;
|
||||
let shift = if denom.abs() > 1e-12 {
|
||||
(0.5 * (a - c) / denom).clamp(-1., 1.)
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
Some((rate / (tau as f32 + shift), (1. - b).clamp(0., 1.)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn midi_to_hz(note: f32) -> f32 {
|
||||
440. * 2f32.powf((note - 69.) / 12.)
|
||||
}
|
||||
pub fn hz_to_midi(hz: f32) -> f32 {
|
||||
69. + 12. * (hz.max(1e-3) / 440.).log2()
|
||||
}
|
||||
/// Scientific note name: MIDI 60 = C4, 43.65 Hz = F1. FL Studio labels MIDI 60 as C5.
|
||||
pub fn note_name(note: u8) -> String {
|
||||
const NAMES: [&str; 12] = [
|
||||
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
|
||||
];
|
||||
format!("{}{}", NAMES[note as usize % 12], note as i32 / 12 - 1)
|
||||
}
|
||||
pub fn db_to_gain(db: f32) -> f32 {
|
||||
10f32.powf(db / 20.)
|
||||
}
|
||||
pub fn gain_to_db(g: f32) -> f32 {
|
||||
20. * g.abs().max(1e-9).log10()
|
||||
}
|
||||
/// One-pole smoothing coefficient for a time constant in milliseconds.
|
||||
pub fn coefficient(rate: f32, ms: f32) -> f32 {
|
||||
if ms <= 0. {
|
||||
1.
|
||||
} else {
|
||||
1. - (-1. / (rate * ms * 0.001)).exp()
|
||||
}
|
||||
}
|
||||
/// PolyBLEP sawtooth oscillator: cheap and close to band-limited, for tests and previews.
|
||||
pub struct Saw {
|
||||
phase: f32,
|
||||
inc: f32,
|
||||
}
|
||||
impl Saw {
|
||||
pub fn new(rate: f32, hz: f32) -> Self {
|
||||
Self {
|
||||
phase: 0.,
|
||||
inc: hz / rate,
|
||||
}
|
||||
}
|
||||
pub fn sample(&mut self) -> f32 {
|
||||
let t = self.phase;
|
||||
let dt = self.inc;
|
||||
let blep = if t < dt {
|
||||
let x = t / dt;
|
||||
x + x - x * x - 1.
|
||||
} else if t > 1. - dt {
|
||||
let x = (t - 1.) / dt;
|
||||
x * x + x + x + 1.
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
self.phase += dt;
|
||||
if self.phase >= 1. {
|
||||
self.phase -= 1.;
|
||||
}
|
||||
2. * t - 1. - blep
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Minimal RIFF/WAVE writer and reader: 32-bit float data, sampler root key, Serum wavetable tag.
|
||||
use std::{io, path::Path};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Wave {
|
||||
pub channels: u16,
|
||||
pub rate: u32,
|
||||
/// Interleaved samples.
|
||||
pub samples: Vec<f32>,
|
||||
/// MIDI root key, written as a `smpl` chunk that samplers (FL included) read.
|
||||
pub root: Option<u8>,
|
||||
/// Wavetable frame size, written as Serum's `clm ` chunk.
|
||||
pub frame: Option<u32>,
|
||||
}
|
||||
|
||||
fn chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(w: &Wave) -> Vec<u8> {
|
||||
let channels = w.channels.max(1);
|
||||
let frames = w.samples.len() / channels as usize;
|
||||
let mut body = Vec::with_capacity(w.samples.len() * 4 + 256);
|
||||
body.extend_from_slice(b"WAVE");
|
||||
let mut fmt = Vec::new();
|
||||
fmt.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
|
||||
fmt.extend_from_slice(&channels.to_le_bytes());
|
||||
fmt.extend_from_slice(&w.rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&(w.rate * 4 * channels as u32).to_le_bytes());
|
||||
fmt.extend_from_slice(&(4 * channels).to_le_bytes());
|
||||
fmt.extend_from_slice(&32u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&0u16.to_le_bytes());
|
||||
chunk(&mut body, b"fmt ", &fmt);
|
||||
chunk(&mut body, b"fact", &(frames as u32).to_le_bytes());
|
||||
if let Some(frame) = w.frame {
|
||||
// Serum reads the frame size from the "<!>NNNN" prefix.
|
||||
chunk(
|
||||
&mut body,
|
||||
b"clm ",
|
||||
format!("<!>{frame} 00000000 wavetable (Crucible)").as_bytes(),
|
||||
);
|
||||
}
|
||||
if let Some(root) = w.root {
|
||||
let mut smpl = Vec::new();
|
||||
let period = (1e9 / w.rate.max(1) as f64).round() as u32;
|
||||
for v in [0, 0, period, root as u32, 0, 0, 0, 0, 0] {
|
||||
smpl.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
chunk(&mut body, b"smpl", &smpl);
|
||||
}
|
||||
let mut data = Vec::with_capacity(w.samples.len() * 4);
|
||||
for s in &w.samples[..frames * channels as usize] {
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
chunk(&mut body, b"data", &data);
|
||||
let mut out = Vec::with_capacity(body.len() + 8);
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write(path: &Path, w: &Wave) -> io::Result<()> {
|
||||
std::fs::write(path, encode(w))
|
||||
}
|
||||
|
||||
/// Reads float32 or PCM16/24 files written by this module or common tools.
|
||||
pub fn decode(b: &[u8]) -> Result<Wave, &'static str> {
|
||||
if b.len() < 12 || &b[..4] != b"RIFF" || &b[8..12] != b"WAVE" {
|
||||
return Err("expected RIFF/WAVE");
|
||||
}
|
||||
let mut w = Wave::default();
|
||||
let (mut format, mut bits) = (0u16, 0u16);
|
||||
let mut at = 12;
|
||||
let mut data = false;
|
||||
let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||
let u32_at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||
while at + 8 <= b.len() {
|
||||
let id = &b[at..at + 4];
|
||||
let len = u32_at(at + 4) as usize;
|
||||
let start = at + 8;
|
||||
let end = start
|
||||
.checked_add(len)
|
||||
.filter(|e| *e <= b.len())
|
||||
.ok_or("truncated chunk")?;
|
||||
match id {
|
||||
b"fmt " if len >= 16 => {
|
||||
format = u16_at(start);
|
||||
w.channels = u16_at(start + 2);
|
||||
w.rate = u32_at(start + 4);
|
||||
bits = u16_at(start + 14);
|
||||
}
|
||||
b"smpl" if len >= 16 => w.root = Some(u32_at(start + 12) as u8),
|
||||
b"clm " => {
|
||||
let text = std::str::from_utf8(&b[start..end]).map_err(|_| "bad clm chunk")?;
|
||||
w.frame = text
|
||||
.strip_prefix("<!>")
|
||||
.and_then(|t| t.split_whitespace().next())
|
||||
.and_then(|n| n.parse().ok());
|
||||
}
|
||||
b"data" => {
|
||||
data = true;
|
||||
let d = &b[start..end];
|
||||
w.samples = match (format, bits) {
|
||||
(3, 32) => d
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| f32::from_le_bytes(*c))
|
||||
.collect(),
|
||||
(1, 16) => d
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| i16::from_le_bytes(*c) as f32 / 32768.)
|
||||
.collect(),
|
||||
(1, 24) => d
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| (i32::from_le_bytes([0, c[0], c[1], c[2]]) >> 8) as f32 / 8388608.)
|
||||
.collect(),
|
||||
_ => return Err("unsupported sample format"),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
at = end + (len % 2);
|
||||
}
|
||||
if w.channels == 0 || w.rate == 0 {
|
||||
return Err("missing fmt chunk");
|
||||
}
|
||||
if !data {
|
||||
return Err("missing data chunk");
|
||||
}
|
||||
Ok(w)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use common_dsp::{hz_to_midi, midi_to_hz, note_name, wav, Crossover, Saw, Yin};
|
||||
|
||||
fn cents(a: f32, b: f32) -> f32 {
|
||||
1200. * (a / b).log2()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yin_tracks_bass_fundamentals_within_two_cents() {
|
||||
let rate = 48000.;
|
||||
let mut yin = Yin::new(2400);
|
||||
for hz in [43.65, 55., 110., 220.] {
|
||||
let mut saw = Saw::new(rate, hz);
|
||||
let x: Vec<f32> = (0..6000).map(|_| saw.sample()).collect();
|
||||
let (got, clarity) = yin.estimate(&x, rate, 25., 1000.).unwrap();
|
||||
assert!(cents(got, hz).abs() < 2., "{hz}: {got}");
|
||||
assert!(clarity > 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yin_rejects_silence_and_noise() {
|
||||
let mut yin = Yin::new(2400);
|
||||
assert!(yin.estimate(&[0.; 6000], 48000., 25., 1000.).is_none());
|
||||
let mut seed = 1u32;
|
||||
let noise: Vec<f32> = (0..6000)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5
|
||||
})
|
||||
.collect();
|
||||
assert!(yin.estimate(&noise, 48000., 25., 1000.).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossover_bands_sum_to_flat_magnitude() {
|
||||
let rate = 48000.;
|
||||
for hz in [20., 45., 90., 180., 1000., 8000., 18000.] {
|
||||
let mut x = Crossover::new(rate, 90.);
|
||||
let (mut a, mut b) = (0f64, 0f64);
|
||||
let n = 96000;
|
||||
for i in 0..n {
|
||||
let s = (std::f32::consts::TAU * hz * i as f32 / rate).sin();
|
||||
let (l, h) = x.process(s);
|
||||
if i > n / 2 {
|
||||
a += (s as f64).powi(2);
|
||||
b += ((l + h) as f64).powi(2);
|
||||
}
|
||||
}
|
||||
let db = 10. * (b / a).log10();
|
||||
assert!(db.abs() < 0.1, "{hz} Hz: {db} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_round_trip() {
|
||||
assert!((midi_to_hz(29.) - 43.65).abs() < 0.01);
|
||||
assert!((hz_to_midi(440.) - 69.).abs() < 1e-4);
|
||||
assert_eq!(note_name(29), "F1");
|
||||
assert_eq!(note_name(61), "C#4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wave_round_trip_keeps_root_and_wavetable_chunks() {
|
||||
let w = wav::Wave {
|
||||
channels: 2,
|
||||
rate: 44100,
|
||||
samples: (0..999)
|
||||
.map(|i| (i as f32 * 0.01).sin())
|
||||
.collect::<Vec<_>>()[..998]
|
||||
.to_vec(),
|
||||
root: Some(29),
|
||||
frame: Some(2048),
|
||||
};
|
||||
let bytes = wav::encode(&w);
|
||||
assert_eq!(&bytes[..4], b"RIFF");
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize,
|
||||
bytes.len() - 8
|
||||
);
|
||||
assert_eq!(wav::decode(&bytes).unwrap(), w);
|
||||
assert!(wav::decode(&bytes[..40]).is_err());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,320 @@
|
||||
//! Shared hardware look for every plugin editor: palette, fonts, knobs, panels.
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
|
||||
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
|
||||
pub const BG: Color32 = Color32::from_rgb(14, 15, 17);
|
||||
pub const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
|
||||
pub const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
|
||||
pub const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
|
||||
pub const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
|
||||
pub const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
|
||||
pub const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
|
||||
pub const DIM: Color32 = Color32::from_rgb(70, 74, 82);
|
||||
/// Signal colour: cuts, active states, value arcs.
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
|
||||
/// Measured input and neutral data.
|
||||
pub const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
|
||||
/// Focus / protect curve, distinct from cuts without competing with them.
|
||||
pub const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
|
||||
pub fn style(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
"regular".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"medium".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Proportional)
|
||||
.unwrap()
|
||||
.insert(0, "regular".into());
|
||||
fonts.families.insert(
|
||||
egui::FontFamily::Name("medium".into()),
|
||||
vec![
|
||||
"medium".into(),
|
||||
"Ubuntu-Light".into(),
|
||||
"NotoEmoji-Regular".into(),
|
||||
"emoji-icon-font".into(),
|
||||
],
|
||||
);
|
||||
ctx.set_fonts(fonts);
|
||||
let mut s = (*ctx.style()).clone();
|
||||
let v = &mut s.visuals;
|
||||
*v = egui::Visuals::dark();
|
||||
v.panel_fill = BG;
|
||||
v.window_fill = PANEL;
|
||||
v.extreme_bg_color = SCREEN;
|
||||
v.faint_bg_color = PANEL;
|
||||
v.override_text_color = Some(TEXT);
|
||||
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
|
||||
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
v.hyperlink_color = ACCENT;
|
||||
v.window_stroke = Stroke::new(1.0_f32, EDGE);
|
||||
for (w, fill, stroke) in [
|
||||
(&mut v.widgets.noninteractive, PANEL, EDGE),
|
||||
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
(
|
||||
&mut v.widgets.hovered,
|
||||
Color32::from_rgb(45, 48, 54),
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
),
|
||||
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
|
||||
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.bg_stroke = Stroke::new(1.0_f32, stroke);
|
||||
w.corner_radius = 3.into();
|
||||
}
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.slider_trailing_fill = true;
|
||||
s.spacing.item_spacing = Vec2::new(10., 8.);
|
||||
s.spacing.button_padding = Vec2::new(12., 6.);
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Body, FontId::proportional(16.));
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Button, FontId::proportional(16.));
|
||||
ctx.set_style(s);
|
||||
}
|
||||
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
|
||||
pub fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let mix = |t: f32| {
|
||||
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||||
Color32::from_rgb(
|
||||
l(top.r(), bottom.r()),
|
||||
l(top.g(), bottom.g()),
|
||||
l(top.b(), bottom.b()),
|
||||
)
|
||||
};
|
||||
mesh.colored_vertex(center, mix(0.5));
|
||||
let n = 48;
|
||||
for i in 0..=n {
|
||||
let a = std::f32::consts::TAU * i as f32 / n as f32;
|
||||
let point = center + Vec2::angled(a) * radius;
|
||||
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
|
||||
if i > 0 {
|
||||
mesh.add_triangle(0, i, i + 1);
|
||||
}
|
||||
}
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn rotary(
|
||||
ui: &mut egui::Ui,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
unit: &str,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) -> egui::Response {
|
||||
let low = *range.start();
|
||||
let high = *range.end();
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
|
||||
if response.drag_started() {
|
||||
response.request_focus();
|
||||
}
|
||||
if response.dragged() {
|
||||
let delta = ui.input(|i| i.pointer.delta().y);
|
||||
let fine = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
*value = default;
|
||||
}
|
||||
if response.has_focus() {
|
||||
let step = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
|
||||
*value = (*value + step).min(high);
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
|
||||
{
|
||||
*value = (*value - step).max(low);
|
||||
}
|
||||
}
|
||||
let center = rect.center();
|
||||
let radius = size * 0.46;
|
||||
let p = ui.painter();
|
||||
let start = std::f32::consts::PI * 0.75;
|
||||
let span = std::f32::consts::PI * 1.5;
|
||||
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
|
||||
// Scale track and value arc around the knob.
|
||||
for (fraction, stroke) in [
|
||||
(1., Stroke::new(2.5_f32, DIM)),
|
||||
(normalized, Stroke::new(2.5_f32, color)),
|
||||
] {
|
||||
// A zero-length polyline tessellates to NaN vertices.
|
||||
if fraction < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let points: Vec<_> = (0..=64)
|
||||
.map(|i| {
|
||||
let a = start + span * fraction * i as f32 / 64.;
|
||||
center + Vec2::angled(a) * (radius - 1.5)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
// Machined body: drop shadow, brushed gradient skirt, flat cap.
|
||||
let body = radius - 7.;
|
||||
p.circle_filled(
|
||||
center + Vec2::new(0., 2.),
|
||||
body + 1.,
|
||||
Color32::from_black_alpha(140),
|
||||
);
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
body,
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
Color32::from_rgb(28, 30, 34),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
body,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
|
||||
);
|
||||
let cap = body * 0.74;
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
cap,
|
||||
Color32::from_rgb(44, 47, 52),
|
||||
Color32::from_rgb(33, 35, 40),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
cap,
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
|
||||
);
|
||||
let angle = start + span * normalized;
|
||||
p.line_segment(
|
||||
[
|
||||
center + Vec2::angled(angle) * (cap * 0.35),
|
||||
center + Vec2::angled(angle) * (cap - 2.),
|
||||
],
|
||||
Stroke::new(2.0_f32, TEXT),
|
||||
);
|
||||
if response.has_focus() {
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name.to_uppercase())
|
||||
.size(if size > 100. { 14. } else { 12. })
|
||||
.color(MUTED),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
response
|
||||
}
|
||||
/// Vertical gradient rectangle, used for panel faces.
|
||||
pub fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (pos, color) in [
|
||||
(rect.left_top(), top),
|
||||
(rect.right_top(), top),
|
||||
(rect.right_bottom(), bottom),
|
||||
(rect.left_bottom(), bottom),
|
||||
] {
|
||||
mesh.colored_vertex(pos, color);
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
|
||||
pub fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let face = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = egui::Frame::new()
|
||||
.inner_margin(14.)
|
||||
.stroke(Stroke::new(1.0_f32, EDGE))
|
||||
.show(ui, add);
|
||||
let r = inner.response.rect;
|
||||
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
r.left_top() + Vec2::new(1., 1.),
|
||||
r.right_top() + Vec2::new(-1., 1.),
|
||||
],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
|
||||
);
|
||||
inner.inner
|
||||
}
|
||||
pub fn caption(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).size(11.).color(MUTED));
|
||||
}
|
||||
/// Small labelled hardware-style readout.
|
||||
pub fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
|
||||
ui.label(RichText::new(label).size(11.).color(MUTED));
|
||||
ui.label(RichText::new(value).monospace().size(13.).color(color));
|
||||
ui.add_space(10.);
|
||||
}
|
||||
/// Product header: accent mark, product name, subtitle, then right-aligned controls.
|
||||
pub fn header(ui: &mut egui::Ui, name: &str, subtitle: &str, right: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
|
||||
ui.painter().rect_filled(mark, 1., ACCENT);
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.label(RichText::new(subtitle).size(12.).color(MUTED));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
|
||||
});
|
||||
}
|
||||
/// Full-window backdrop gradient used behind every editor.
|
||||
pub fn backdrop(ui: &egui::Ui) {
|
||||
ui.painter()
|
||||
.add(gradient(ui.max_rect(), Color32::from_rgb(20, 21, 24), BG));
|
||||
}
|
||||
/// Horizontal level bar in dBFS (−60..0) with a hairline frame.
|
||||
pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(
|
||||
Vec2::new(70., 16.),
|
||||
egui::Label::new(RichText::new(label).size(11.).color(MUTED)),
|
||||
);
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(width, 10.), Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 1., SCREEN);
|
||||
let t = ((db + 60.) / 60.).clamp(0., 1.);
|
||||
let mut fill = rect;
|
||||
fill.set_width(rect.width() * t);
|
||||
p.rect_filled(fill, 1., color);
|
||||
p.rect_stroke(
|
||||
rect,
|
||||
1.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(if db <= -60. {
|
||||
"−∞".to_string()
|
||||
} else {
|
||||
format!("{db:.1} dB")
|
||||
})
|
||||
.monospace()
|
||||
.size(12.)
|
||||
.color(TEXT),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user