diff --git a/Cargo.lock b/Cargo.lock index 69d4f6c..ac439ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,7 +214,7 @@ dependencies = [ [[package]] name = "patina-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "rustfft", "serde", @@ -223,7 +223,7 @@ dependencies = [ [[package]] name = "patina-render" -version = "0.1.0" +version = "0.2.0" dependencies = [ "patina-core", "serde_json", @@ -231,7 +231,7 @@ dependencies = [ [[package]] name = "patina-ui" -version = "0.1.0" +version = "0.2.0" dependencies = [ "egui", "patina-core", diff --git a/README.md b/README.md index b9bdec8..fc599bb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable load Patina in a mixer effect slot. Normal branch/PR pipelines also provide a combined Windows preview ZIP in the -**windows** job. Releases are separate: `rivet-v0.3.0` and `patina-v0.1.0` +**windows** job. Releases are separate: `rivet-v0.3.0` and `patina-v0.2.0` publish independently. See [release workflow](docs/releases.md). These names replace Motif Storyboard and Resonance Compressor. Their CLAP IDs diff --git a/docs/patina/CHANGELOG.md b/docs/patina/CHANGELOG.md index 52ed31e..90c5c53 100644 --- a/docs/patina/CHANGELOG.md +++ b/docs/patina/CHANGELOG.md @@ -1,5 +1,17 @@ # Patina changelog +## 0.2.0 + +- Rebuild the detector so it finds resonant bumps instead of cutting individual + harmonics: band-averaged power, temporal smoothing, a harmonic-top envelope, + and a robust tilt-aware reference. +- Replace six overlapping controls with Depth, Sharpness, and Selectivity. + Focus points now read the same way everywhere: up cuts more, down protects. +- Halve the analysis window to 2048 samples at 44.1/48 kHz (about 43 ms delay). +- Show the detector envelope and reference on the plot. +- New dark metallic look with a single accent colour, shared with Rivet. +- Breaking: projects saved with 0.1 load the new controls at their defaults. + ## 0.1.0 - Introduce Patina, formerly Resonance Compressor, as an independent mixing effect. diff --git a/docs/patina/README.md b/docs/patina/README.md index 2a9915a..20bcb1e 100644 --- a/docs/patina/README.md +++ b/docs/patina/README.md @@ -1,10 +1,11 @@ # Patina Patina smooths a sound’s rough surface without erasing its character. Formerly -**Resonance Compressor**; current version **0.1.0**. +**Resonance Compressor**; current version **0.2.0**. -A mixing effect for controlling local spectral peaks while retaining the intended -sound. Independently designed in Rust; no claim of matching Soothe or Quance. +A mixing effect that finds harsh, ringing regions as they happen and pulls them +down, while plain harmonic tone passes. Independently designed in Rust; no claim +of matching Soothe or Quance. ![Patina](assets/editor.png) @@ -19,40 +20,60 @@ redistributed packages. ## Work with it -Loop a troublesome passage in context. Restrict Low/High frequency, then adjust -Amount and Max cut. Sensitivity is a prominence threshold: lower catches more. -Neighborhood defines the spectral reference; Cut width smooths the attenuation. -Attack and Release smooth gain changes; Transient guard briefly relaxes cuts. +Three controls decide what happens: -Double-click the plot to add one of four focus points. Positive sensitivity -makes the detector more eager; negative values protect the region by raising -the threshold. Drag a point, wheel to change width, or use its numeric fields. -Range boundary handles constrain where processing may act. Focus is detector -weighting, not a static EQ boost and not an absolute protection guarantee. +- **Depth** is the largest cut, in dB. 0 dB does nothing. +- **Sharpness** sets what counts as a resonance. Low values follow broad, + smooth bumps (honk, boxiness). High values target narrow rings and whistles. +- **Selectivity** sets how far a bump must stand out before it is cut. Low + values treat any bump; high values treat only the worst. + +Start with Depth around 6 dB. Raise Selectivity until only the harsh parts show +cuts, then use Delta to hear what is removed. Restrict Low/High frequency to keep +the source’s body out of reach. Attack and Release set how quickly cuts grow and +recover; Transient guard briefly relaxes cuts on attacks to keep punch. + +Double-click the plot to add one of four focus points. Drag a point up to cut +more in that region, down to protect it. Wheel changes its width. Range handles +on the plot drag the processing limits. + +On the plot, the grey fill is the measured input. The bright line is what the +detector hears (its smoothed spectral envelope); the dim line is what it expects +there. Accent-coloured cuts appear where the bright line rises above the dim one +by more than Selectivity allows. The blue curve is the focus/protect shape. -Cyan shows spectral input energy, coral the attenuation, violet detector focus. -The left scale is calibrated per-bin spectral power; the right scale is cut dB. Delta plays delayed dry minus wet before output trim. Use it to inspect changes, then return to normal monitoring to decide whether the music improved. Mix is a linear blend of aligned signals. Bypass keeps the same latency. Use Output for matched-level comparison. Spectral attenuation is not a limiter: sample peaks can increase even when frequency gains never exceed unity. -Four starting presets and A/B comparison help explore alternatives. All controls -are visible. The instrument-style sidebar holds Amount, Mix, and Output; the -spectrum includes filled energy traces, cut overlays, meter rails, and range -handles, using the plugin’s cyan/coral/violet palette. Host parameters and window -size persist; Delta does not. The editor supports numeric entry, keyboard focus, and direct graph editing. +Four presets and A/B comparison help explore alternatives. Host parameters and +window size persist; Delta does not. + +## How detection works + +1. Each analysis frame is grouped into 512 log-spaced bands. Each band holds the + mean power of the FFT bins it covers, smoothed over time (about 5 ms rise, + 40 ms fall) so cuts hold on a stable ring instead of flickering. +2. Neighbouring peaks are joined in dB, so the gaps between harmonics do not count + as the expected level. A plain harmonic tone reads as a smooth envelope. +3. The envelope is smoothed across frequency by the Sharpness width. +4. The reference is a robust local straight-line fit over 2–3 octaves: spectral + tilt is expected, bumps cannot raise their own reference, and empty regions + (below a fundamental, beyond a filter cutoff) are ignored. +5. Envelope minus reference, above the Selectivity threshold, becomes the cut, + limited by Depth, smoothed with Attack/Release, and linked across stereo. ## Timing and limits -Mixing only: 4096-sample analysis windows at 44.1/48 kHz, 8192 at 88.2/96 kHz, -16384 at 176.4/192 kHz, with eight overlaps. Delay is one window, about 85–93 ms. +Mixing only: 2048-sample analysis windows at 44.1/48 kHz, 4096 at 88.2/96 kHz, +8192 at 176.4/192 kHz, with eight overlaps. Delay is one window, about 43–46 ms. The plugin reports it for host compensation. Supported layouts are mono and linked stereo; supported rates are those six standard rates. The engine uses complex RustFFT with supplied scratch buffers, WOLA reconstruction, -a log-frequency prominence detector, bounded soft-knee cuts, and temporal/frequency +the envelope/reference detector above, bounded soft-knee cuts, and temporal smoothing. Original bin phases are retained. This is not a minimum-phase filter bank, a live monitoring effect, a limiter, or an automatic separation of tone and unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1. @@ -88,15 +109,17 @@ Judge through real patches with matched listening levels: preserve vowel motion, snare punch, bass body, and stereo placement. Synthetic tests do not establish artistic usefulness. -Local benchmark: AMD Ryzen 7 PRO 3700U, optimized build, stereo 48 kHz, FFT -4096/hop 512: five seconds processed in 0.233 seconds (about 4.7% of one -core in that run). This is not a worst-case multi-instance guarantee. +Local benchmark, optimized build, stereo 48 kHz, FFT 2048/hop 256: five seconds +processed in about 0.35–0.45 seconds (7–9% of one core on a laptop CPU). This is +not a worst-case multi-instance guarantee. ## Existing projects and source layout -The CLAP ID `io.storyboard.resonance-compressor`, parameter IDs, and state -format are unchanged. Replace the former plugin file with `Patina.clap` and +The CLAP ID `io.storyboard.resonance-compressor` is unchanged. Version 0.2 +replaces Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with +Depth, Sharpness, and Selectivity, so projects saved with 0.1 load those +controls at their defaults. Replace the former plugin file with `Patina.clap` and rescan; avoid two scanned copies with the same ID. Host project recall still needs verification in FL Studio. diff --git a/docs/patina/assets/editor.png b/docs/patina/assets/editor.png index c122ff8..3a0ea4c 100644 Binary files a/docs/patina/assets/editor.png and b/docs/patina/assets/editor.png differ diff --git a/docs/rivet/CHANGELOG.md b/docs/rivet/CHANGELOG.md index 73f401d..4a76635 100644 --- a/docs/rivet/CHANGELOG.md +++ b/docs/rivet/CHANGELOG.md @@ -1,5 +1,9 @@ # Rivet changelog +## Unreleased + +- New dark metallic look with a single accent colour, shared with Patina. + ## 0.3.0 - Introduce the Rivet name and a dedicated product source/documentation tree. diff --git a/docs/rivet/assets/creative-editor.png b/docs/rivet/assets/creative-editor.png index f4c71d5..13cf336 100644 Binary files a/docs/rivet/assets/creative-editor.png and b/docs/rivet/assets/creative-editor.png differ diff --git a/docs/rivet/assets/editor.png b/docs/rivet/assets/editor.png index 5f0aa31..ae4870c 100644 Binary files a/docs/rivet/assets/editor.png and b/docs/rivet/assets/editor.png differ diff --git a/plugins/patina/core/Cargo.toml b/plugins/patina/core/Cargo.toml index 5ddfcd2..21a25bf 100644 --- a/plugins/patina/core/Cargo.toml +++ b/plugins/patina/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "patina-core" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "ISC" diff --git a/plugins/patina/core/src/lib.rs b/plugins/patina/core/src/lib.rs index 05585d3..3e3f292 100644 --- a/plugins/patina/core/src/lib.rs +++ b/plugins/patina/core/src/lib.rs @@ -5,9 +5,111 @@ pub use settings::{focus_offset, knee, Focus, Settings}; use std::{f32::consts::PI, sync::Arc}; pub const GRID: usize = 512; +/// dB of cut per dB of prominence above the threshold. +const SLOPE: f32 = 1.5; +/// Fixed soft-knee width, in dB of prominence. +const KNEE: f32 = 3.; +/// Neighboring peaks closer than this (Hz, or one octave) are joined by a +/// straight line in dB, so harmonic gaps do not count as expected level. +const JOIN_HZ: f32 = 800.; +/// Detector floor, far below any audible content. +const FLOOR_DB: f32 = -160.; +/// Cells this far below the reference count as empty, not as a dip. +const GAP_DB: f32 = 9.; +/// Envelope is clipped this far above the previous fit on later passes. +const CLIP_DB: f32 = 2.; +/// Cells this far below the loudest nearby envelope are empty, not content. +const EMPTY_DB: f32 = 24.; +/// Steepest spectral tilt the reference follows, in dB per octave. +const MAX_TILT: f32 = 12.; +fn grid_step() -> f32 { + 1000f32.log2() / (GRID - 1) as f32 +} +/// Box radius whose two-pass (triangular) response has the given FWHM in octaves. +fn radius(octaves: f32) -> usize { + (((octaves / grid_step()) - 1.) * 0.5) + .round() + .clamp(0., (GRID / 2) as f32) as usize +} +/// Two box passes over non-negative values (power, or dB above the floor). f64 prefix sums keep quiet cells exact +/// next to loud ones. Edges average only the cells that exist. +fn smooth(src: &[f32; GRID], dst: &mut [f32; GRID], radius: usize, passes: usize) { + let mut prefix = [0f64; GRID + 1]; + *dst = *src; + for _ in 0..passes { + for i in 0..GRID { + prefix[i + 1] = prefix[i] + dst[i] as f64; + } + for (i, value) in dst.iter_mut().enumerate() { + let low = i.saturating_sub(radius); + let high = (i + radius + 1).min(GRID); + *value = ((prefix[high] - prefix[low]) / (high - low) as f64).max(0.) as f32; + } + } +} +/// Maximum over `i - radius..=i + radius` for each cell, in O(GRID) with a +/// monotonic queue of candidate indices. +fn local_max(y: &[f32; GRID], radius: usize, out: &mut [f32; GRID]) { + let mut queue = [0usize; GRID]; + let (mut head, mut tail) = (0, 0); + let mut next = 0; + for (i, value) in out.iter_mut().enumerate() { + while next < GRID && next <= i + radius { + while tail > head && y[queue[tail - 1]] <= y[next] { + tail -= 1; + } + queue[tail] = next; + tail += 1; + next += 1; + } + while queue[head] + radius < i { + head += 1; + } + *value = y[queue[head]]; + } +} +/// Weighted least-squares line through each cell's neighborhood, evaluated at +/// that cell. The slope is limited to `MAX_TILT`, so an edge of the content +/// (a steep rise below a fundamental, a filter cutoff) is not taken as tilt. +fn fit(y: &[f32; GRID], w: &[f32; GRID], radius: usize, out: &mut [f32; GRID]) { + let max_slope = (MAX_TILT * grid_step()) as f64; + let mut sums = [[0f64; 5]; GRID + 1]; + for j in 0..GRID { + let (x, wj, yj) = (j as f64, w[j] as f64, y[j] as f64); + let s = sums[j]; + sums[j + 1] = [ + s[0] + wj, + s[1] + wj * x, + s[2] + wj * x * x, + s[3] + wj * yj, + s[4] + wj * x * yj, + ]; + } + for (i, value) in out.iter_mut().enumerate() { + let low = i.saturating_sub(radius); + let high = (i + radius + 1).min(GRID); + let [sw, sx, sxx, sy, sxy] = std::array::from_fn(|k| sums[high][k] - sums[low][k]); + if sw < 1. { + *value = y[i]; + continue; + } + let det = sw * sxx - sx * sx; + let mean = sy / sw; + let slope = if det > 1e-6 * sw * sw { + ((sw * sxy - sx * sy) / det).clamp(-max_slope, max_slope) + } else { + 0. + }; + *value = (mean + slope * (i as f64 - sx / sw)) as f32; + } +} #[derive(Clone, Copy, Debug)] pub struct AnalyzerFrame { pub input: [f32; GRID], + /// What the detector compares: the smoothed spectral envelope, in dB. + pub envelope: [f32; GRID], + /// Level the envelope is compared with, in dB. + pub reference: [f32; GRID], pub reduction: [f32; GRID], pub focus: [f32; GRID], pub input_peak: f32, @@ -21,13 +123,15 @@ impl Default for AnalyzerFrame { fn default() -> Self { Self { input: [-120.; GRID], + envelope: [-120.; GRID], + reference: [-120.; GRID], reduction: [0.; GRID], focus: [0.; GRID], input_peak: 0., output_peak: 0., guard: 0., sample_rate: 48000., - latency: 4096, + latency: 2048, channels: 2, } } @@ -54,15 +158,20 @@ pub struct Engine { scratch: Vec, forward: Arc>, inverse: Arc>, - levels: [[f32; GRID]; 2], + /// Per-cell power, smoothed over frames. + power: [[f32; GRID]; 2], target: [f32; GRID], reduction: [f32; GRID], curve: [f32; GRID], mask: [f32; GRID], - map: [(usize, f32); GRID], + /// Per cell: FFT bin range `start..end`, or interpolation at `start + frac` + /// when the cell is narrower than one bin. + bands: [(usize, usize, f32); GRID], bin_map: Vec<(usize, f32)>, - kernel: [f32; 65], - radius: usize, + /// Per cell: how many cells away the next peak may be and still be joined. + join: [usize; GRID], + envelope_radius: usize, + reference_radius: usize, settings: Settings, frame: AnalyzerFrame, fresh: bool, @@ -93,7 +202,7 @@ impl Engine { } else { 1 }; - let n = 4096 * scale; + let n = 2048 * scale; let hop = n / 8; let mut planner = FftPlanner::new(); let forward = planner.plan_fft_forward(n); @@ -110,9 +219,17 @@ impl Engine { .collect(); let power_scale = 2. / window.iter().sum::().powi(2); let nyquist = rate * 0.5; - let map = std::array::from_fn(|i| { - let bin = (frequency(i).min(nyquist) * n as f32 / rate).min((n / 2 - 1) as f32); - (bin.floor() as usize, bin.fract()) + let half_step = 2f32.powf(grid_step() * 0.5); + let bands = std::array::from_fn(|i| { + let to_bin = |f: f32| (f.min(nyquist) * n as f32 / rate).min((n / 2 - 1) as f32); + let start = to_bin(frequency(i) / half_step).ceil() as usize; + let end = to_bin(frequency(i) * half_step).floor() as usize + 1; + if end > start + 1 { + (start, end, 0.) + } else { + let bin = to_bin(frequency(i)); + (bin.floor() as usize, bin.floor() as usize, bin.fract()) + } }); let bin_map = (0..=n / 2) .map(|k| { @@ -129,6 +246,11 @@ impl Engine { ) }) .collect(); + let join = std::array::from_fn(|i| { + let f = frequency(i); + let octaves = ((f + JOIN_HZ) / f).log2().max(1.); + (octaves / grid_step()).round() as usize + }); let mut engine = Self { rate, channels, @@ -143,15 +265,16 @@ impl Engine { scratch: vec![Complex32::default(); scratch_len], forward, inverse, - levels: [[-120.; GRID]; 2], + power: [[0.; GRID]; 2], target: [0.; GRID], reduction: [0.; GRID], curve: [0.; GRID], mask: [0.; GRID], - map, + bands, bin_map, - kernel: [0.; 65], - radius: 0, + join, + envelope_radius: 0, + reference_radius: 0, settings: Settings::default(), frame: AnalyzerFrame::default(), fresh: false, @@ -186,7 +309,7 @@ impl Engine { let rebuild = settings.focus != self.settings.focus || settings.low != self.settings.low || settings.high != self.settings.high - || settings.cut_width != self.settings.cut_width; + || settings.sharpness != self.settings.sharpness; self.settings = settings; if rebuild { self.rebuild(); @@ -201,12 +324,8 @@ impl Engine { Ok(()) } fn rebuild(&mut self) { - let step = 1000f32.log2() / (GRID - 1) as f32; - self.radius = ((self.settings.cut_width / step * 1.3).ceil() as usize).clamp(1, 64); - let sigma = self.settings.cut_width / step / 2.355; - for j in 0..=self.radius { - self.kernel[j] = (-0.5 * (j as f32 / sigma).powi(2)).exp(); - } + self.envelope_radius = radius(self.settings.envelope_width()); + self.reference_radius = radius(self.settings.reference_width()); for i in 0..GRID { let f = frequency(i); self.curve[i] = focus_offset(f, &self.settings.focus); @@ -224,6 +343,7 @@ impl Engine { channel.fill(0.); } self.reduction.fill(0.); + self.power = [[0.; GRID]; 2]; self.pos = 0; self.hop_pos = 0; self.fast = 0.; @@ -291,7 +411,28 @@ impl Engine { } result } + // Per-cell loops read several parallel spectra; indices are clearer here. + #[allow(clippy::needless_range_loop)] fn process_window(&mut self) { + let frame_time = self.hop as f32 / self.rate; + // Detector power follows rises in about 5 ms and decays in about 40 ms, + // so cuts hold on a stable ring instead of flickering with each frame. + let rise = 1. - (-frame_time / 0.005).exp(); + let fall = 1. - (-frame_time / 0.04).exp(); + let awake = self.sample_power / self.hop as f32 > 1e-9; + self.sample_power = 0.; + let threshold = self.settings.threshold(); + self.target.fill(0.); + self.frame.input.fill(-200.); + self.frame.envelope.fill(-200.); + self.frame.reference.fill(-200.); + let mut levels = [0f32; GRID]; + let mut tops; + let mut envelope = [0f32; GRID]; + let mut reference = [0f32; GRID]; + let mut clipped = [0f32; GRID]; + let mut weight = [0f32; GRID]; + let mut content = [false; GRID]; for c in 0..self.channels { for i in 0..self.n { self.spectra[c][i] = @@ -300,53 +441,104 @@ impl Engine { self.forward .process_with_scratch(&mut self.spectra[c], &mut self.scratch); for i in 0..GRID { - let (k, w) = self.map[i]; - let power = self.spectra[c][k].norm_sqr() * (1. - w) - + self.spectra[c][k + 1].norm_sqr() * w; - self.levels[c][i] = 10. * (power * self.power_scale).max(1e-20).log10(); + let (start, end, w) = self.bands[i]; + let power = if end > start { + self.spectra[c][start..end] + .iter() + .map(|z| z.norm_sqr()) + .sum::() + / (end - start) as f32 + } else { + self.spectra[c][start].norm_sqr() * (1. - w) + + self.spectra[c][start + 1].norm_sqr() * w + } * self.power_scale; + let held = &mut self.power[c][i]; + *held += (power - *held) * if power > *held { rise } else { fall }; + if *held < 1e-30 { + *held = 0.; + } + self.frame.input[i] = self.frame.input[i].max(10. * power.max(1e-20).log10()); } - } - let awake = self.sample_power / self.hop as f32 > 1e-9; - self.sample_power = 0.; - let step = 1000f32.log2() / (GRID - 1) as f32; - let radius = (self.settings.neighborhood / step * 0.5).round() as usize; - self.target.fill(0.); - for c in 0..self.channels { - let mut prefix = [0.; GRID + 1]; + // 1. Upper envelope: join neighboring peaks (harmonic tops) with + // straight lines in dB, so the gaps between harmonics do not count + // as "expected level". Regions without nearby peaks keep their level. for i in 0..GRID { - prefix[i + 1] = prefix[i] + self.levels[c][i]; + levels[i] = (10. * self.power[c][i].max(1e-30).log10()).max(FLOOR_DB); + } + tops = levels; + let mut last: Option = None; + for i in 1..GRID - 1 { + if levels[i] >= levels[i - 1] && levels[i] > levels[i + 1] { + if let Some(a) = last.filter(|a| i - a <= self.join[*a]) { + let span = (i - a) as f32; + for j in a + 1..i { + let t = (j - a) as f32 / span; + let line = levels[a] + (levels[i] - levels[a]) * t; + tops[j] = tops[j].max(line); + } + } + last = Some(i); + } + } + for value in &mut tops { + *value = 10f32.powf(*value / 10.); + } + // 2. Smooth it across Sharpness width: this is what the detector "hears". + smooth(&tops, &mut envelope, self.envelope_radius, 2); + for value in &mut envelope { + *value = (10. * value.max(1e-30).log10()).max(FLOOR_DB); + } + // 3. Robust reference: a local straight-line fit in dB, so spectral + // tilt is expected rather than prominent. Each pass clips the + // envelope to the previous fit, so a bump cannot raise its own + // reference. Cells far below the fit (empty regions, filter slopes, + // below the fundamental) are left out instead of dragging it down. + local_max(&envelope, self.reference_radius, &mut clipped); + for i in 0..GRID { + content[i] = envelope[i] > clipped[i] - EMPTY_DB; + weight[i] = f32::from(content[i]); + } + fit(&envelope, &weight, self.reference_radius, &mut reference); + for pass in 0..8 { + for i in 0..GRID { + weight[i] = f32::from(content[i] && envelope[i] > reference[i] - GAP_DB); + // First find the content, then stop bumps from lifting the line. + clipped[i] = if pass < 2 { + envelope[i] + } else { + envelope[i].min(reference[i] + CLIP_DB) + }; + } + let previous = reference; + fit(&clipped, &weight, self.reference_radius, &mut reference); + for i in 0..GRID { + if !reference[i].is_finite() { + reference[i] = previous[i]; + } + } } for i in 0..GRID { - let low = i.saturating_sub(radius); - let high = (i + radius + 1).min(GRID); - let e0 = i.saturating_sub(1).max(low); - let e1 = (i + 2).min(high); - let count = (high - low) - (e1 - e0); - let baseline = - (prefix[high] - prefix[low] - (prefix[e1] - prefix[e0])) / count.max(1) as f32; - let excess = - self.levels[c][i] - baseline + self.curve[i] - self.settings.sensitivity; - let target = if awake { - knee(excess, self.settings.knee) * self.settings.amount * 0.02 + let level = envelope[i]; + self.frame.envelope[i] = self.frame.envelope[i].max(level); + self.frame.reference[i] = self.frame.reference[i].max(reference[i]); + let excess = level - reference[i] + self.curve[i] - threshold; + // Only real content can ring: leakage far below nearby + // material is never cut. + let target = if awake && content[i] && level > -130. { + (knee(excess, KNEE) * SLOPE).min(self.settings.depth) } else { 0. }; - self.target[i] = self.target[i].max(target.min(self.settings.max_cut)); + // Linked stereo: the stronger channel decides. + self.target[i] = self.target[i].max(target); } } + let cut = self.target; + smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1); let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp(); let release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp(); for i in 0..GRID { - let low = i.saturating_sub(self.radius); - let high = (i + self.radius + 1).min(GRID); - let mut sum = 0.; - let mut weight = 0.; - for j in low..high { - let w = self.kernel[i.abs_diff(j)]; - sum += self.target[j] * w; - weight += w; - } - let target = sum / weight; + let target = self.target[i]; let alpha = if target > self.reduction[i] { attack } else { @@ -359,13 +551,8 @@ impl Engine { self.frame.reduction[i] = (self.reduction[i] * self.mask[i] * (1. - self.protect * self.settings.transient_guard / 100.)) - .clamp(0., self.settings.max_cut); + .clamp(0., self.settings.depth); self.frame.focus[i] = self.curve[i]; - self.frame.input[i] = self.levels[0][i].max(if self.channels == 2 { - self.levels[1][i] - } else { - -200. - }); } for c in 0..self.channels { for k in 0..=self.n / 2 { diff --git a/plugins/patina/core/src/settings.rs b/plugins/patina/core/src/settings.rs index 1eee50f..72a457f 100644 --- a/plugins/patina/core/src/settings.rs +++ b/plugins/patina/core/src/settings.rs @@ -22,12 +22,12 @@ impl Default for Focus { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct Settings { - pub amount: f32, - pub sensitivity: f32, - pub max_cut: f32, - pub neighborhood: f32, - pub cut_width: f32, - pub knee: f32, + /// Largest cut in dB. Prominent bumps are flattened toward the threshold, never deeper. + pub depth: f32, + /// 0 % follows broad tonal bumps, 100 % targets narrow rings. + pub sharpness: f32, + /// 0 % treats any bump; 100 % treats only the most prominent bumps. + pub selectivity: f32, pub attack: f32, pub release: f32, pub transient_guard: f32, @@ -41,17 +41,14 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { Self { - amount: 35., - sensitivity: 6., - max_cut: 3., - neighborhood: 1., - cut_width: 0.08, - knee: 3., - attack: 15., - release: 120., - transient_guard: 50., - low: 120., - high: 12000., + depth: 6., + sharpness: 40., + selectivity: 50., + attack: 5., + release: 80., + transient_guard: 30., + low: 200., + high: 16000., mix: 100., output: 0., bypass: false, @@ -62,12 +59,9 @@ impl Default for Settings { impl Settings { pub fn validate(&self) -> Result<(), &'static str> { for (v, low, high) in [ - (self.amount, 0., 100.), - (self.sensitivity, 0., 24.), - (self.max_cut, 0., 18.), - (self.neighborhood, 0.25, 2.), - (self.cut_width, 0.03, 0.5), - (self.knee, 0., 12.), + (self.depth, 0., 20.), + (self.sharpness, 0., 100.), + (self.selectivity, 0., 100.), (self.attack, 1., 200.), (self.release, 10., 1000.), (self.transient_guard, 0., 100.), @@ -96,37 +90,51 @@ impl Settings { } Ok(()) } + /// Detector envelope width in octaves (FWHM), from Sharpness. + pub fn envelope_width(&self) -> f32 { + (1. / 3.) * (1f32 / 8.).powf(self.sharpness / 100.) + } + /// Local reference width in octaves (FWHM). Much wider than the envelope. + pub fn reference_width(&self) -> f32 { + (self.envelope_width() * 16.).clamp(2., 3.) + } + /// Prominence threshold in dB above the local reference, from Selectivity. + pub fn threshold(&self) -> f32 { + 0.5 + self.selectivity * 0.05 + } pub const PRESETS: [&'static str; 4] = [ "Gentle cleanup", - "Bass whistle", - "Snare tail", - "Bright melodic layer", + "Vocal harshness", + "Snare ring", + "Bright synth", ]; pub fn preset(index: usize) -> Self { let mut s = Self::default(); match index { 1 => { - s.low = 700.; - s.high = 10000.; - s.sensitivity = 5.; - s.max_cut = 6.; - s.cut_width = 0.05; - s.attack = 8.; + s.low = 1500.; + s.high = 12000.; + s.depth = 8.; + s.sharpness = 55.; + s.selectivity = 40.; + s.attack = 3.; + s.release = 60.; } 2 => { s.low = 250.; s.high = 6000.; - s.attack = 25.; - s.release = 180.; - s.transient_guard = 85.; - s.max_cut = 5.; + s.depth = 8.; + s.sharpness = 75.; + s.selectivity = 35.; + s.attack = 10.; + s.release = 150.; + s.transient_guard = 80.; } 3 => { - s.low = 1800.; - s.high = 15000.; - s.sensitivity = 4.; - s.max_cut = 4.; - s.cut_width = 0.14; + s.low = 2000.; + s.high = 16000.; + s.sharpness = 25.; + s.selectivity = 45.; } _ => {} } diff --git a/plugins/patina/core/tests/dsp.rs b/plugins/patina/core/tests/dsp.rs index 3da233e..720b82a 100644 --- a/plugins/patina/core/tests/dsp.rs +++ b/plugins/patina/core/tests/dsp.rs @@ -12,7 +12,7 @@ fn delayed_identity_and_all_mix_positions_at_all_rates() { for mix in [0., 37., 100.] { let mut e = Engine::new(rate, 2).unwrap(); e.set_settings(Settings { - amount: 0., + depth: 0., mix, ..Default::default() }) @@ -43,7 +43,7 @@ fn delayed_identity_and_all_mix_positions_at_all_rates() { fn impulse_latency_delta_and_reset() { let mut e = Engine::new(48000., 1).unwrap(); e.set_settings(Settings { - amount: 0., + depth: 0., ..Default::default() }) .unwrap(); @@ -57,44 +57,203 @@ fn impulse_latency_delta_and_reset() { assert_eq!(e.process_sample([0., 0.], Monitor { delta: true }), [0.; 2]); } } -#[test] -fn resonance_is_local_bounded_and_recovers_in_silence() { - let mut e = Engine::new(48000., 2).unwrap(); - e.set_settings(Settings { - amount: 100., - sensitivity: 2., - max_cut: 6., - transient_guard: 0., - attack: 1., - release: 20., - low: 500., - high: 9000., - ..Default::default() - }) - .unwrap(); +/// Band-limited sawtooth: every harmonic up to 18 kHz, amplitude 1/k. +fn saw(f0: f32, i: usize, rate: f32) -> f32 { + let t = i as f32 / rate; + (1..) + .map(|k| k as f32 * f0) + .take_while(|f| *f < 18000.) + .enumerate() + .map(|(k, f)| (TAU * f * t).sin() / (k + 1) as f32) + .sum::() + * 0.2 +} +/// RBJ peaking biquad, used to put a known resonance on a test signal. +struct Peak { + b: [f32; 3], + a: [f32; 2], + z: [f32; 2], +} +impl Peak { + fn new(f: f32, q: f32, gain: f32, rate: f32) -> Self { + let a = 10f32.powf(gain / 40.); + let w = TAU * f / rate; + let alpha = w.sin() / (2. * q); + let a0 = 1. + alpha / a; + Self { + b: [ + (1. + alpha * a) / a0, + -2. * w.cos() / a0, + (1. - alpha * a) / a0, + ], + a: [-2. * w.cos() / a0, (1. - alpha / a) / a0], + z: [0.; 2], + } + } + fn tick(&mut self, x: f32) -> f32 { + let y = self.b[0] * x + self.z[0]; + self.z[0] = self.b[1] * x - self.a[0] * y + self.z[1]; + self.z[1] = self.b[2] * x - self.a[1] * y; + y + } +} +fn cell(f: f32) -> usize { + (0..GRID) + .min_by(|a, b| { + (frequency(*a) - f) + .abs() + .total_cmp(&(frequency(*b) - f).abs()) + }) + .unwrap() +} +/// Run one second of a signal and return the last analyzer frame. +fn settle(settings: Settings, mut signal: impl FnMut(usize) -> f32) -> AnalyzerFrame { + let mut e = Engine::new(48000., 1).unwrap(); + e.set_settings(settings).unwrap(); let mut frame = AnalyzerFrame::default(); for i in 0..48000 { - let x = 0.3 * (TAU * 3000. * i as f32 / 48000.).sin() + noise(i); + e.process_sample([signal(i), 0.], Monitor::default()); + if let Some(f) = e.take_analyzer() { + frame = f; + } + } + frame +} +fn peak_reduction(frame: &AnalyzerFrame, low: f32, high: f32) -> f32 { + (0..GRID) + .filter(|i| (low..=high).contains(&frequency(*i))) + .map(|i| frame.reduction[i]) + .fold(0., f32::max) +} +#[test] +fn plain_harmonic_tone_is_left_alone() { + for f0 in [110., 220., 440.] { + let frame = settle(Settings::default(), |i| saw(f0, i, 48000.)); + let peak = peak_reduction(&frame, 20., 20000.); + assert!(peak < 1., "{f0} Hz sawtooth cut by {peak} dB"); + } +} +#[test] +fn resonant_bump_is_cut_where_it_is_and_nowhere_else() { + for f0 in [110., 220.] { + let mut bump = Peak::new(3000., 4., 12., 48000.); + let frame = settle(Settings::default(), |i| bump.tick(saw(f0, i, 48000.))); + let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26); + assert!(near >= 4., "{f0} Hz: bump cut only {near} dB"); + for f in [1000., 8000.] { + let away = frame.reduction[cell(f)]; + assert!(away < 1., "{f0} Hz: {f} Hz cut by {away} dB"); + } + assert!(peak_reduction(&frame, 20., 20000.) <= Settings::default().depth); + } +} +#[test] +fn depth_selectivity_and_focus_each_do_one_thing() { + let run = |s: Settings| { + let mut bump = Peak::new(3000., 4., 12., 48000.); + let frame = settle(s, |i| bump.tick(saw(220., i, 48000.))); + frame.reduction[cell(3000.)] + }; + let base = run(Settings::default()); + let shallow = run(Settings { + depth: 2., + ..Default::default() + }); + let picky = run(Settings { + selectivity: 100., + ..Default::default() + }); + let mut protect = Settings::default(); + protect.focus[0] = Focus { + enabled: true, + frequency: 3000., + width: 0.5, + sensitivity: -12., + }; + let protected = run(protect); + assert!(shallow <= 2.001 && shallow < base, "{shallow} vs {base}"); + assert!(picky < base - 1., "{picky} vs {base}"); + assert!(protected < base - 2., "{protected} vs {base}"); +} +#[test] +fn detection_is_level_independent() { + let s = Settings { + transient_guard: 0., + ..Default::default() + }; + let run = |gain: f32| { + let mut bump = Peak::new(3000., 4., 12., 48000.); + settle(s, |i| bump.tick(saw(220., i, 48000.)) * gain) + }; + let loud = run(1.); + let quiet = run(0.03); + for i in 0..GRID { + assert!( + (loud.reduction[i] - quiet.reduction[i]).abs() < 0.5, + "{} Hz: {} vs {}", + frequency(i), + loud.reduction[i], + quiet.reduction[i] + ); + } +} +#[test] +fn noise_does_not_cause_flickering_cuts() { + let mut e = Engine::new(48000., 1).unwrap(); + e.set_settings(Settings::default()).unwrap(); + let mut frames = Vec::new(); + for i in 0..96000 { + e.process_sample([noise(i) * 3., 0.], Monitor::default()); + if let Some(f) = e.take_analyzer() { + if i > 24000 { + frames.push(f); + } + } + } + let mean = frames + .iter() + .map(|f| f.reduction.iter().sum::() / GRID as f32) + .sum::() + / frames.len() as f32; + let jitter = frames + .windows(2) + .map(|w| { + (0..GRID) + .map(|i| (w[1].reduction[i] - w[0].reduction[i]).abs()) + .sum::() + / GRID as f32 + }) + .sum::() + / (frames.len() - 1) as f32; + assert!(mean < 0.5, "white noise mean cut {mean} dB"); + assert!(jitter < 0.1, "frame-to-frame change {jitter} dB"); +} +#[test] +fn processing_range_is_respected_and_cuts_recover_in_silence() { + let s = Settings { + low: 500., + high: 9000., + transient_guard: 0., + release: 20., + ..Default::default() + }; + let mut e = Engine::new(48000., 2).unwrap(); + e.set_settings(s).unwrap(); + let mut bump = Peak::new(3000., 4., 12., 48000.); + let mut frame = AnalyzerFrame::default(); + for i in 0..48000 { + let x = bump.tick(saw(220., i, 48000.)); e.process_sample([x, -x], Monitor::default()); if let Some(f) = e.take_analyzer() { frame = f; } } - let peak = frame.reduction.iter().copied().fold(0f32, f32::max); - assert!(peak > 3. && peak <= 6.); + assert!(frame.reduction[cell(3000.)] > 3.); for i in 0..GRID { if frequency(i) < 500. || frequency(i) > 9000. { assert_eq!(frame.reduction[i], 0.); } } - let ring = (0..GRID) - .min_by(|a, b| { - (frequency(*a) - 3000.) - .abs() - .total_cmp(&(frequency(*b) - 3000.).abs()) - }) - .unwrap(); - assert!(frame.reduction[ring] > 3.); for _ in 0..48000 { e.process_sample([0.; 2], Monitor::default()); } @@ -135,50 +294,6 @@ fn soft_knee_and_presets_are_valid() { assert_eq!(focus_offset(2000., &[p; 4]), -48.); } -#[test] -fn relative_detector_scales_and_manual_protection_reduces_false_positives() { - let s = Settings { - amount: 80., - sensitivity: 3., - max_cut: 12., - attack: 1., - transient_guard: 0., - ..Default::default() - }; - let mut loud = Engine::new(48000., 1).unwrap(); - loud.set_settings(s).unwrap(); - let mut quiet = Engine::new(48000., 1).unwrap(); - quiet.set_settings(s).unwrap(); - let mut protected = Engine::new(48000., 1).unwrap(); - let mut p = s; - p.focus[0] = Focus { - enabled: true, - frequency: 3000., - width: 0.5, - sensitivity: -18., - }; - protected.set_settings(p).unwrap(); - for i in 0..24000 { - let x = 0.3 * (TAU * 3000. * i as f32 / 48000.).sin() + noise(i); - loud.process_sample([x, 0.], Monitor::default()); - quiet.process_sample([x * 0.1, 0.], Monitor::default()); - protected.process_sample([x, 0.], Monitor::default()); - } - let a = loud.take_analyzer().unwrap(); - let b = quiet.take_analyzer().unwrap(); - let c = protected.take_analyzer().unwrap(); - for i in 0..GRID { - assert!((a.reduction[i] - b.reduction[i]).abs() < 0.02); - } - let k = (0..GRID) - .min_by(|a, b| { - (frequency(*a) - 3000.) - .abs() - .total_cmp(&(frequency(*b) - 3000.).abs()) - }) - .unwrap(); - assert!(c.reduction[k] < a.reduction[k] - 1.); -} #[test] fn settled_delta_and_bypass_have_precise_taps() { let s = Settings { @@ -205,3 +320,42 @@ fn settled_delta_and_bypass_have_precise_taps() { } } } +#[test] +fn narrow_ring_on_noise_is_cut_and_sharpness_narrows_the_cut() { + let width = |sharpness: f32| { + let s = Settings { + sharpness, + ..Default::default() + }; + let frame = settle(s, |i| { + 0.05 * (TAU * 5000. * i as f32 / 48000.).sin() + noise(i) * 3. + }); + let peak = frame.reduction[cell(5000.)]; + let wide = (0..GRID) + .filter(|i| frame.reduction[*i] > peak * 0.5) + .count(); + (peak, wide) + }; + let (soft, soft_width) = width(0.); + let (sharp, sharp_width) = width(100.); + assert!(soft > 3. && sharp > 3., "{soft} / {sharp}"); + assert!(sharp_width < soft_width, "{sharp_width} vs {soft_width}"); +} +#[test] +fn high_sample_rates_detect_the_same_bump() { + for rate in [96000., 192000.] { + let mut e = Engine::new(rate, 1).unwrap(); + e.set_settings(Settings::default()).unwrap(); + let mut bump = Peak::new(3000., 4., 12., rate); + let mut frame = AnalyzerFrame::default(); + for i in 0..rate as usize { + e.process_sample([bump.tick(saw(220., i, rate)), 0.], Monitor::default()); + if let Some(f) = e.take_analyzer() { + frame = f; + } + } + let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26); + let away = peak_reduction(&frame, 20., 1500.); + assert!(near >= 4. && away < 1., "{rate}: {near} / {away}"); + } +} diff --git a/plugins/patina/plugin/Cargo.lock b/plugins/patina/plugin/Cargo.lock index 7e14d18..8b8ca14 100644 --- a/plugins/patina/plugin/Cargo.lock +++ b/plugins/patina/plugin/Cargo.lock @@ -1412,7 +1412,7 @@ dependencies = [ [[package]] name = "patina" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap-sys", "crossbeam-queue", @@ -1425,7 +1425,7 @@ dependencies = [ [[package]] name = "patina-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "rustfft", "serde", @@ -1433,7 +1433,7 @@ dependencies = [ [[package]] name = "patina-ui" -version = "0.1.0" +version = "0.2.0" dependencies = [ "egui", "patina-core", diff --git a/plugins/patina/plugin/Cargo.toml b/plugins/patina/plugin/Cargo.toml index 0f224e4..4505a80 100644 --- a/plugins/patina/plugin/Cargo.toml +++ b/plugins/patina/plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "patina" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "ISC" diff --git a/plugins/patina/plugin/src/lib.rs b/plugins/patina/plugin/src/lib.rs index 54c08be..2c284f7 100644 --- a/plugins/patina/plugin/src/lib.rs +++ b/plugins/patina/plugin/src/lib.rs @@ -3,7 +3,7 @@ mod params; use crossbeam_queue::ArrayQueue; use nih_plug::prelude::*; use nih_plug_egui::{create_egui_editor, egui}; -use params::{values, PatinaParams}; +use params::{values, PatinaParams, COUNT}; use patina_core::{AnalyzerFrame, Engine, Monitor}; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -92,7 +92,7 @@ impl Plugin for Patina { let old = values(&before); let new = values(&edited); let floats = params.floats(); - for i in 0..29 { + for i in 0..COUNT { if new[i] != old[i] { if !state.gestures[i] { setter.begin_set_parameter(floats[i]); diff --git a/plugins/patina/plugin/src/params.rs b/plugins/patina/plugin/src/params.rs index ba2763f..94c432e 100644 --- a/plugins/patina/plugin/src/params.rs +++ b/plugins/patina/plugin/src/params.rs @@ -42,18 +42,12 @@ impl Default for FocusParams { pub struct PatinaParams { #[persist = "editor"] pub editor: Arc, - #[id = "amount"] - pub amount: FloatParam, - #[id = "sensitivity"] - pub sensitivity: FloatParam, - #[id = "max-cut"] - pub max_cut: FloatParam, - #[id = "neighborhood"] - pub neighborhood: FloatParam, - #[id = "cut-width"] - pub cut_width: FloatParam, - #[id = "knee"] - pub knee: FloatParam, + #[id = "depth"] + pub depth: FloatParam, + #[id = "sharpness"] + pub sharpness: FloatParam, + #[id = "selectivity"] + pub selectivity: FloatParam, #[id = "attack"] pub attack: FloatParam, #[id = "release"] @@ -75,19 +69,17 @@ pub struct PatinaParams { } impl Default for PatinaParams { fn default() -> Self { + let d = Settings::default(); Self { editor: EguiState::from_size(1180, 760), - amount: linear("Amount", 35., 0., 100., " %"), - sensitivity: linear("Sensitivity", 6., 0., 24., " dB"), - max_cut: linear("Max cut", 3., 0., 18., " dB"), - neighborhood: linear("Neighborhood", 1., 0.25, 2., " oct"), - cut_width: linear("Cut width", 0.08, 0.03, 0.5, " oct"), - knee: linear("Knee", 3., 0., 12., " dB"), - attack: log("Attack", 15., 1., 200., " ms"), - release: log("Release", 120., 10., 1000., " ms"), - transient_guard: linear("Transient guard", 50., 0., 100., " %"), - low: log("Low frequency", 120., 20., 19999., " Hz"), - high: log("High frequency", 12000., 21., 20000., " Hz"), + depth: linear("Depth", d.depth, 0., 20., " dB"), + sharpness: linear("Sharpness", d.sharpness, 0., 100., " %"), + selectivity: linear("Selectivity", d.selectivity, 0., 100., " %"), + attack: log("Attack", d.attack, 1., 200., " ms"), + release: log("Release", d.release, 10., 1000., " ms"), + transient_guard: linear("Transient guard", d.transient_guard, 0., 100., " %"), + low: log("Low frequency", d.low, 20., 19999., " Hz"), + high: log("High frequency", d.high, 21., 20000., " Hz"), mix: linear("Mix", 100., 0., 100., " %"), output: linear("Output", 0., -12., 12., " dB"), bypass: BoolParam::new("Bypass", false).make_bypass(), @@ -96,14 +88,11 @@ impl Default for PatinaParams { } } impl PatinaParams { - pub fn floats(&self) -> [&FloatParam; 29] { + pub fn floats(&self) -> [&FloatParam; COUNT] { [ - &self.amount, - &self.sensitivity, - &self.max_cut, - &self.neighborhood, - &self.cut_width, - &self.knee, + &self.depth, + &self.sharpness, + &self.selectivity, &self.attack, &self.release, &self.transient_guard, @@ -131,12 +120,9 @@ impl PatinaParams { } pub fn settings(&self) -> Settings { let mut s = Settings { - amount: self.amount.value(), - sensitivity: self.sensitivity.value(), - max_cut: self.max_cut.value(), - neighborhood: self.neighborhood.value(), - cut_width: self.cut_width.value(), - knee: self.knee.value(), + depth: self.depth.value(), + sharpness: self.sharpness.value(), + selectivity: self.selectivity.value(), attack: self.attack.value(), release: self.release.value(), transient_guard: self.transient_guard.value(), @@ -159,15 +145,14 @@ impl PatinaParams { s } } -pub fn values(s: &Settings) -> [f32; 29] { - let mut v = [0.; 29]; - v[..13].copy_from_slice(&[ - s.amount, - s.sensitivity, - s.max_cut, - s.neighborhood, - s.cut_width, - s.knee, +/// Host float parameters: ten main controls, then four per focus point. +pub const COUNT: usize = 26; +pub fn values(s: &Settings) -> [f32; COUNT] { + let mut v = [0.; COUNT]; + v[..10].copy_from_slice(&[ + s.depth, + s.sharpness, + s.selectivity, s.attack, s.release, s.transient_guard, @@ -177,7 +162,7 @@ pub fn values(s: &Settings) -> [f32; 29] { s.output, ]); for i in 0..4 { - v[13 + i * 4..17 + i * 4].copy_from_slice(&[ + v[10 + i * 4..14 + i * 4].copy_from_slice(&[ f32::from(s.focus[i].enabled), s.focus[i].frequency, s.focus[i].width, diff --git a/plugins/patina/plugin/tests/host.rs b/plugins/patina/plugin/tests/host.rs index 24c039d..d80fa75 100644 --- a/plugins/patina/plugin/tests/host.rs +++ b/plugins/patina/plugin/tests/host.rs @@ -104,7 +104,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() { let mut envelope: nih_plug::prelude::PluginState = serde_json::from_slice(&save()[8..]).unwrap(); use nih_plug::wrapper::state::ParamValue; - envelope.params.insert("amount".into(), ParamValue::F32(0.)); + envelope.params.insert("depth".into(), ParamValue::F32(0.)); envelope .params .insert("frequency_1".into(), ParamValue::F32(2300.)); @@ -133,7 +133,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() { for rate in [44100., 48000., 96000.] { assert!(p.activate.unwrap()(plugin, rate, 1, 512)); let delay = (*latency).get.unwrap()(plugin) as usize; - assert_eq!(delay, if rate > 48000. { 8192 } else { 4096 }); + assert_eq!(delay, if rate > 48000. { 4096 } else { 2048 }); assert!(p.start_processing.unwrap()(plugin)); let mut position = 0; while position < delay + 1024 { diff --git a/plugins/patina/render/Cargo.toml b/plugins/patina/render/Cargo.toml index 3e0b7f1..76570e5 100644 --- a/plugins/patina/render/Cargo.toml +++ b/plugins/patina/render/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "patina-render" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "ISC" diff --git a/plugins/patina/render/src/main.rs b/plugins/patina/render/src/main.rs index 14bbe7f..4a9c222 100644 --- a/plugins/patina/render/src/main.rs +++ b/plugins/patina/render/src/main.rs @@ -27,7 +27,7 @@ fn run() -> Result<(), Box> { let x = (i as f32 * 0.173).sin() * 0.3; std::hint::black_box(e.process_sample([x, -x], Monitor::default())); } - println!("Stereo 48 kHz / FFT 4096 / hop 512 / 5 seconds: {:.3}s ({:.1}% of one core, run --release for meaningful results)",start.elapsed().as_secs_f64(),start.elapsed().as_secs_f64()/5.*100.); + println!("Stereo 48 kHz / FFT 2048 / hop 256 / 5 seconds: {:.3}s ({:.1}% of one core, run --release for meaningful results)",start.elapsed().as_secs_f64(),start.elapsed().as_secs_f64()/5.*100.); return Ok(()); } let (rate, channels, input, output) = if args[0] == "--demo" && args.len() == 2 { diff --git a/plugins/patina/ui/Cargo.toml b/plugins/patina/ui/Cargo.toml index a292b1e..cdbc016 100644 --- a/plugins/patina/ui/Cargo.toml +++ b/plugins/patina/ui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "patina-ui" -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "ISC" diff --git a/plugins/patina/ui/src/lib.rs b/plugins/patina/ui/src/lib.rs index 5efbf7f..6dc3ff2 100644 --- a/plugins/patina/ui/src/lib.rs +++ b/plugins/patina/ui/src/lib.rs @@ -3,13 +3,21 @@ use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID}; use std::time::{Duration, Instant}; -const BG: Color32 = Color32::from_rgb(23, 20, 31); -const PANEL: Color32 = Color32::from_rgb(37, 33, 49); -const TEXT: Color32 = Color32::from_rgb(238, 234, 245); -const MUTED: Color32 = Color32::from_rgb(161, 151, 179); -const CYAN: Color32 = Color32::from_rgb(102, 215, 230); -const CORAL: Color32 = Color32::from_rgb(255, 136, 125); -const VIOLET: Color32 = Color32::from_rgb(174, 154, 247); +// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces. +const BG: Color32 = Color32::from_rgb(14, 15, 17); +const PANEL: Color32 = Color32::from_rgb(25, 27, 31); +const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41); +const EDGE: Color32 = Color32::from_rgb(44, 47, 53); +const SCREEN: Color32 = Color32::from_rgb(9, 10, 11); +const TEXT: Color32 = Color32::from_rgb(232, 233, 236); +const MUTED: Color32 = Color32::from_rgb(140, 146, 156); +const DIM: Color32 = Color32::from_rgb(70, 74, 82); +/// Signal colour: cuts, active states, value arcs. +const ACCENT: Color32 = Color32::from_rgb(255, 1, 96); +/// Measured input and neutral data. +const STEEL: Color32 = Color32::from_rgb(184, 192, 204); +/// Focus / protect curve, distinct from cuts without competing with them. +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( @@ -36,14 +44,36 @@ pub fn style(ctx: &egui::Context) { ); ctx.set_fonts(fonts); let mut s = (*ctx.style()).clone(); - s.visuals = egui::Visuals::dark(); - s.visuals.panel_fill = BG; - s.visuals.window_fill = PANEL; - s.visuals.override_text_color = Some(TEXT); - s.visuals.selection.bg_fill = Color32::from_rgb(80, 64, 112); - s.visuals.widgets.inactive.bg_fill = PANEL; - s.visuals.widgets.hovered.bg_fill = Color32::from_rgb(62, 50, 80); - s.visuals.widgets.active.bg_fill = Color32::from_rgb(81, 58, 102); + 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 @@ -77,7 +107,7 @@ impl Default for State { gesture_active: false, gestures: [false; 30], last_audio: Instant::now(), - help: "Focus a troublesome region, then compare in the full mix.".into(), + help: "Set Depth, then raise Selectivity until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(), drag: None, } } @@ -87,6 +117,8 @@ impl State { // Display-only smoothing; it never feeds the detector. for i in 0..GRID { self.analyzer.input[i] += 0.45 * (frame.input[i] - self.analyzer.input[i]); + self.analyzer.envelope[i] += 0.45 * (frame.envelope[i] - self.analyzer.envelope[i]); + self.analyzer.reference[i] += 0.45 * (frame.reference[i] - self.analyzer.reference[i]); self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]); } self.analyzer.focus = frame.focus; @@ -107,6 +139,35 @@ impl State { fn db(x: f32) -> f32 { 20. * x.max(1e-6).log10() } +/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above. +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)); +} +/// Shows a help line in the status bar while the knob is hovered or focused. +fn hint(response: egui::Response, state: &mut State, text: &str) { + if response.hovered() || response.has_focus() { + state.help = text.into(); + } +} fn note(ui: &mut egui::Ui, text: &str) { ui.label(RichText::new(text).color(MUTED)); } @@ -122,7 +183,7 @@ fn control( state: &mut State, ) { ui.vertical(|ui| { - ui.label(RichText::new(name).color(TEXT)); + caption(ui, &name.to_uppercase()); let previous = *value; let response = ui.add( egui::Slider::new(value, range) @@ -137,15 +198,9 @@ fn control( if response.double_clicked() { let defaults = Settings::default(); *value = match name { - "Amount" => defaults.amount, - "Sensitivity" => defaults.sensitivity, - "Max cut" => defaults.max_cut, "Transient guard" => defaults.transient_guard, "Attack" => defaults.attack, "Release" => defaults.release, - "Neighborhood" => defaults.neighborhood, - "Cut width" => defaults.cut_width, - "Knee" => defaults.knee, "Low frequency" => defaults.low, "High frequency" => defaults.high, "Mix" => defaults.mix, @@ -167,7 +222,7 @@ fn rotary( 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()); @@ -201,62 +256,71 @@ fn rotary( } } let center = rect.center(); - let radius = size * 0.45; + let radius = size * 0.46; let p = ui.painter(); - p.circle_filled( - center + Vec2::new(0., 3.), - radius, - Color32::from_black_alpha(90), - ); - p.circle_filled(center, radius, Color32::from_rgb(20, 18, 29)); - p.circle_stroke( - center, - radius, - Stroke::new(1.0_f32, Color32::from_rgb(74, 65, 90)), - ); - p.circle_filled(center, radius - 6., Color32::from_rgb(43, 37, 56)); - p.circle_stroke( - center, - radius - 7., - Stroke::new(1.0_f32, Color32::from_rgb(60, 51, 77)), - ); let start = std::f32::consts::PI * 0.75; let span = std::f32::consts::PI * 1.5; - let normalized = (*value - low) / (high - low); + let normalized = ((*value - low) / (high - low)).clamp(0., 1.); + // Scale track and value arc around the knob. for (fraction, stroke) in [ - (1., Stroke::new(3.0_f32, Color32::from_rgb(59, 49, 76))), - (normalized, Stroke::new(3.0_f32, color)), + (1., Stroke::new(2.5_f32, DIM)), + (normalized, Stroke::new(2.5_f32, color)), ] { let points: Vec<_> = (0..=64) .map(|i| { let a = start + span * fraction * i as f32 / 64.; - center + Vec2::angled(a) * (radius - 3.) + 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) * (radius - 14.), - center + Vec2::angled(angle) * (radius - 8.), + center + Vec2::angled(angle) * (cap * 0.35), + center + Vec2::angled(angle) * (cap - 2.), ], - Stroke::new(2.0_f32, color), - ); - p.text( - center, - egui::Align2::CENTER_CENTER, - format!("{:.0}{}", *value, unit), - FontId::proportional(if size > 100. { 25. } else { 18. }), - color, + Stroke::new(2.0_f32, TEXT), ); if response.has_focus() { - p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, TEXT)); + p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color)); } ui.label( - RichText::new(name) - .size(if size > 100. { 20. } else { 17. }) - .color(TEXT), + RichText::new(name.to_uppercase()) + .size(if size > 100. { 14. } else { 12. }) + .color(MUTED), ); ui.add( egui::DragValue::new(value) @@ -264,32 +328,85 @@ fn rotary( .speed(0.1) .suffix(unit), ); + response +} +/// Vertical gradient rectangle, used for panel faces. +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. +fn panel(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 +} +fn caption(ui: &mut egui::Ui, text: &str) { + ui.label(RichText::new(text).size(11.).color(MUTED)); +} +/// Small labelled hardware-style readout. +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.); } pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { ui.spacing_mut().slider_width = ((ui.available_width() - 100.) / 4. - 85.).max(95.); let before = *settings; + let full = ui.max_rect(); + ui.painter() + .add(gradient(full, Color32::from_rgb(20, 21, 24), BG)); + ui.add_space(2.); ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label( - RichText::new("Patina") - .font(FontId::new(30., egui::FontFamily::Name("medium".into()))), - ); - note(ui, "Keep the character. Control the ring."); - }); + let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover()); + ui.painter().rect_filled(mark, 1., ACCENT); + ui.label( + RichText::new("PATINA") + .font(FontId::new(26., egui::FontFamily::Name("medium".into()))) + .color(TEXT), + ); + ui.label(RichText::new("RESONANCE SUPPRESSOR").size(12.).color(MUTED)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut settings.bypass, "Bypass"); if ui - .selectable_label( - state.delta, - RichText::new("Delta").color(if state.delta { CORAL } else { TEXT }), + .selectable_label(settings.bypass, RichText::new("BYPASS").size(13.)) + .on_hover_text("Crossfade to the delayed dry signal.") + .clicked() + { + settings.bypass = !settings.bypass; + } + if ui + .selectable_label(state.delta, RichText::new("DELTA").size(13.)) + .on_hover_text( + "Hear only what is removed: delayed dry minus wet, before output trim.", ) - .on_hover_text("Hear delayed dry minus wet, before output trim.") .clicked() { state.delta = !state.delta; } ui.separator(); - if ui.button("Copy A to B").clicked() { + if ui.button(RichText::new("COPY A TO B").size(13.)).clicked() { state.snapshots[state.active] = *settings; state.snapshots[1] = state.snapshots[0]; if state.active == 1 { @@ -298,7 +415,10 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { } for i in (0..2).rev() { if ui - .selectable_label(state.active == i, if i == 0 { "A" } else { "B" }) + .selectable_label( + state.active == i, + RichText::new(if i == 0 { "A" } else { "B" }).size(13.), + ) .clicked() && state.active != i { @@ -307,11 +427,12 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { state.active = i; } } + ui.separator(); egui::ComboBox::from_id_salt("resonance-preset") .selected_text( (0..4) .find(|i| *settings == Settings::preset(*i)) - .map_or("Custom settings", |i| Settings::PRESETS[i]), + .map_or("Custom", |i| Settings::PRESETS[i]), ) .width(155.) .show_ui(ui, |ui| { @@ -324,117 +445,128 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { }); }); }); - ui.add_space(8.); + ui.add_space(6.); ui.horizontal(|ui| { let input = db(state.analyzer.input_peak); let output = db(state.analyzer.output_peak); - ui.label(RichText::new(format!("Input {input:>5.1} dBFS")).color(CYAN)); - ui.label( - RichText::new(format!("Output {output:>5.1} dBFS")).color(if output >= 0. { - CORAL - } else { - TEXT - }), - ); let max = state .analyzer .reduction .iter() .copied() .fold(0f32, f32::max); - ui.label(RichText::new(format!("Reduction {max:.1} dB")).color(CORAL)); - ui.label( - RichText::new(format!( - "Transient guard {:.0}%", - state.analyzer.guard * 100. - )) - .color(MUTED), + readout(ui, "IN", format!("{input:>6.1} dBFS"), STEEL); + readout( + ui, + "OUT", + format!("{output:>6.1} dBFS"), + if output >= 0. { ACCENT } else { TEXT }, + ); + readout(ui, "CUT", format!("{max:>4.1} dB"), ACCENT); + readout( + ui, + "GUARD", + format!("{:>3.0} %", state.analyzer.guard * 100.), + MUTED, ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - note( + caption( ui, &format!( - "{:.1} ms latency • {}", + "{:.1} ms latency · {}", state.analyzer.latency as f32 / state.analyzer.sample_rate * 1000., if state.analyzer.channels == 1 { "Mono" } else { "Stereo linked" - } + }, ), ); }); }); ui.add_space(6.); - let body_height = (ui.available_height() - 35.).max(500.); + let body_height = (ui.available_height() - 40.).max(480.); ui.horizontal_top(|ui| { - egui::Frame::new() - .fill(PANEL) - .corner_radius(8.) - .inner_margin(14.) - .show(ui, |ui| { - ui.set_width(156.); - ui.set_min_height(body_height - 28.); - ui.vertical_centered(|ui| { - ui.add_space(4.); - rotary( - ui, - "Amount", - &mut settings.amount, - 0. ..=100., - 35., - " %", - 126., - CYAN, - ); - ui.add_space(8.); - ui.separator(); - ui.add_space(12.); - ui.horizontal_top(|ui| { - ui.vertical_centered(|ui| { - ui.set_width(72.); - rotary( - ui, - "Mix", - &mut settings.mix, - 0. ..=100., - 100., - " %", - 64., - VIOLET, - ); - }); - ui.vertical_centered(|ui| { - ui.set_width(72.); - rotary( - ui, - "Output", - &mut settings.output, - -12. ..=12., - 0., - " dB", - 64., - CORAL, - ); - }); + panel(ui, |ui| { + ui.set_width(176.); + ui.set_min_height(body_height - 28.); + ui.vertical_centered(|ui| { + caption(ui, "DETECTION"); + ui.add_space(4.); + let defaults = Settings::default(); + let knob = rotary( + ui, + "Depth", + &mut settings.depth, + 0. ..=20., + defaults.depth, + " dB", + 108., + ACCENT, + ); + hint(knob, state, "Largest cut. 0 dB does nothing."); + ui.add_space(8.); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + let knob = rotary( + ui, + "Sharpness", + &mut settings.sharpness, + 0. ..=100., + defaults.sharpness, + " %", + 58., + ACCENT, + ); + hint(knob, state, "Low: smooth broad bumps. High: narrow rings."); + }); + columns[1].vertical_centered(|ui| { + let knob = rotary( + ui, + "Selectivity", + &mut settings.selectivity, + 0. ..=100., + defaults.selectivity, + " %", + 58., + ACCENT, + ); + hint(knob, state, "Low: treat any bump. High: only the worst."); + }); + }); + ui.add_space(10.); + ui.separator(); + ui.add_space(4.); + caption(ui, "OUTPUT"); + ui.add_space(4.); + ui.columns(2, |columns| { + columns[0].vertical_centered(|ui| { + rotary( + ui, + "Mix", + &mut settings.mix, + 0. ..=100., + 100., + " %", + 58., + STEEL, + ); + }); + columns[1].vertical_centered(|ui| { + rotary( + ui, + "Output", + &mut settings.output, + -12. ..=12., + 0., + " dB", + 58., + STEEL, + ); }); - ui.add_space(10.); - note(ui, "Peak reduction"); - let max = state - .analyzer - .reduction - .iter() - .copied() - .fold(0f32, f32::max); - ui.label(RichText::new(format!("{max:.1} dB")).size(25.).color(CORAL)); - ui.add_space(12.); - ui.label( - RichText::new("Drag to adjust. Shift for fine steps. Double-click resets.") - .size(13.) - .color(MUTED), - ); }); }); + }); ui.add_space(4.); ui.vertical(|ui| { ui.set_width(ui.available_width()); @@ -442,7 +574,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { spectrum(ui, settings, state); ui.add_space(4.); ui.horizontal_wrapped(|ui| { - ui.label(RichText::new("Focus / protect").color(VIOLET)); + ui.label(RichText::new("FOCUS / PROTECT").size(11.).color(FOCUS)); for i in 0..4 { let f = settings.focus[i]; if ui @@ -464,7 +596,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { .range(20. ..=20000.) .speed(5.), ); - ui.label("Sensitivity"); + ui.label("Cut more (+) / protect (−)"); ui.add( egui::DragValue::new(&mut f.sensitivity) .range(-18. ..=18.) @@ -488,59 +620,18 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { } else { note( ui, - "Double-click the spectrum to place a point. Drag to tame or protect.", + "Double-click the spectrum to place a point. Drag up to cut more there, down to protect.", ); } }); - ui.add_space(6.); - ui.separator(); - ui.add_space(4.); + ui.add_space(8.); + panel(ui, |ui| { + ui.set_width(ui.available_width()); egui::Grid::new("engine-controls") - .num_columns(4) + .num_columns(3) .spacing([16., 12.]) - .min_col_width((ui.available_width() - 48.) / 4.) + .min_col_width((ui.available_width() - 32.) / 3.) .show(ui, |ui| { - control( - ui, - "Sensitivity", - &mut settings.sensitivity, - 0. ..=24., - " dB", - false, - "Lower catches more peaks. Measured relative to the local spectral reference.", - state, - ); - control( - ui, - "Max cut", - &mut settings.max_cut, - 0. ..=18., - " dB", - false, - "Upper bound on spectral attenuation. This is not a limiter.", - state, - ); - control( - ui, - "Transient guard", - &mut settings.transient_guard, - 0. ..=100., - " %", - false, - "Relaxes reduction during attacks. Turn down to catch short resonances.", - state, - ); - control( - ui, - "Knee", - &mut settings.knee, - 0. ..=12., - " dB", - false, - "Softness of the transition into prominence reduction.", - state, - ); - ui.end_row(); control( ui, "Attack", @@ -548,7 +639,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { 1. ..=200., " ms", true, - "Attenuation rise time, in addition to window and detection delay.", + "How fast cuts grow. Short catches brief whistles.", state, ); control( @@ -558,27 +649,17 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { 10. ..=1000., " ms", true, - "Recovery time after a resonance subsides.", + "How fast cuts recover after a resonance stops.", state, ); control( ui, - "Neighborhood", - &mut settings.neighborhood, - 0.25..=2., - " oct", + "Transient guard", + &mut settings.transient_guard, + 0. ..=100., + " %", false, - "Width of the surrounding spectral reference. Wider includes more context.", - state, - ); - control( - ui, - "Cut width", - &mut settings.cut_width, - 0.03..=0.5, - " oct", - false, - "Frequency smoothing width of the attenuation surface.", + "Relaxes cuts on attacks to keep punch. Turn down to catch short rings.", state, ); ui.end_row(); @@ -609,6 +690,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { state, ); }); + }); }); }); ui.separator(); @@ -626,42 +708,48 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { } } fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { - let height = (ui.available_height() - 360.).clamp(180., 480.); + let height = (ui.available_height() - 290.).clamp(180., 540.); let (outer, response) = ui.allocate_exact_size( Vec2::new(ui.available_width(), height), Sense::click_and_drag(), ); let p = ui.painter(); - p.rect_filled(outer, 6., Color32::from_rgb(15, 13, 22)); + p.rect_filled(outer, 3., SCREEN); + p.rect_stroke( + outer, + 3., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); let r = Rect::from_min_max( outer.min + Vec2::new(46., 26.), outer.max - Vec2::new(42., 26.), ); let x = |f: f32| r.left() + ((f.max(20.) / 20.).ln() / 1000f32.ln()).clamp(0., 1.) * r.width(); let y = |d: f32| r.bottom() - (d + 100.).clamp(0., 100.) / 100. * r.height(); - let red_y = |d: f32| r.top() + d / 18. * r.height() * 0.75; + let red_y = |d: f32| r.top() + d / 20. * r.height() * 0.75; for (xx, level, color) in [ - (outer.left() + 5., state.analyzer.input_peak, CYAN), + (outer.left() + 5., state.analyzer.input_peak, STEEL), ( outer.right() - 9., state.analyzer.output_peak, if state.analyzer.output_peak >= 1. { - CORAL + ACCENT } else { - VIOLET + TEXT }, ), ] { let rail = Rect::from_min_max(Pos2::new(xx, r.top()), Pos2::new(xx + 4., r.bottom())); - p.rect_filled(rail, 2., Color32::from_rgb(38, 33, 48)); + p.rect_filled(rail, 1., Color32::from_rgb(26, 28, 32)); let height = ((db(level) + 72.) / 72.).clamp(0., 1.) * rail.height(); p.rect_filled( Rect::from_min_max(Pos2::new(xx, rail.bottom() - height), rail.max), 2., - color.gamma_multiply(0.65), + color.gamma_multiply(0.75), ); } - let color_grid = Color32::from_rgb(58, 51, 70); + let color_grid = Color32::from_rgb(30, 32, 36); for f in [ 20., 50., 100., 200., 500., 1000., 2000., 5000., 10000., 20000., ] { @@ -696,13 +784,13 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { MUTED, ); } - for d in [0., 6., 12., 18.] { + for d in [0., 5., 10., 15., 20.] { p.text( Pos2::new(r.right() + 7., red_y(d)), egui::Align2::LEFT_CENTER, format!("−{d:.0}"), FontId::proportional(12.), - CORAL, + ACCENT, ); } p.text( @@ -717,17 +805,17 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { egui::Align2::RIGHT_TOP, "Cut dB", FontId::proportional(12.), - CORAL, + ACCENT, ); p.rect_filled( Rect::from_min_max(r.min, Pos2::new(x(settings.low), r.bottom())), 0., - Color32::from_black_alpha(90), + Color32::from_black_alpha(120), ); p.rect_filled( Rect::from_min_max(Pos2::new(x(settings.high), r.top()), r.max), 0., - Color32::from_black_alpha(90), + Color32::from_black_alpha(120), ); let signal: Vec<_> = (0..GRID) .map(|i| Pos2::new(x(frequency(i)), y(state.analyzer.input[i]))) @@ -736,9 +824,9 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { for (i, point) in signal.iter().enumerate() { source_fill.colored_vertex( Pos2::new(point.x, r.bottom()), - Color32::from_rgba_unmultiplied(102, 215, 230, 7), + Color32::from_rgba_unmultiplied(184, 192, 204, 4), ); - source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(102, 215, 230, 60)); + source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(184, 192, 204, 34)); if i > 0 { let j = (i * 2) as u32; source_fill.add_triangle(j - 2, j - 1, j); @@ -746,7 +834,21 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { } } p.add(egui::Shape::mesh(source_fill)); - p.add(egui::Shape::line(signal, Stroke::new(1.5_f32, CYAN))); + p.add(egui::Shape::line( + signal, + Stroke::new(1.2_f32, STEEL.gamma_multiply(0.7)), + )); + // What the detector hears (envelope) and what it expects (reference). + // Accent cuts appear where the envelope rises above the reference. + for (values, color, width) in [ + (&state.analyzer.reference, DIM, 1.0_f32), + (&state.analyzer.envelope, TEXT.gamma_multiply(0.8), 1.2_f32), + ] { + let line: Vec<_> = (0..GRID) + .map(|i| Pos2::new(x(frequency(i)), y(values[i]))) + .collect(); + p.add(egui::Shape::line(line, Stroke::new(width, color))); + } let mut fill = Vec::with_capacity(GRID + 2); fill.push(r.left_top()); for i in 0..GRID { @@ -763,11 +865,11 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let yy = red_y(state.analyzer.reduction[i]); mesh.colored_vertex( Pos2::new(xx, r.top()), - Color32::from_rgba_unmultiplied(255, 136, 125, 8), + Color32::from_rgba_unmultiplied(255, 1, 96, 6), ); mesh.colored_vertex( Pos2::new(xx, yy), - Color32::from_rgba_unmultiplied(255, 136, 125, 90), + Color32::from_rgba_unmultiplied(255, 1, 96, 100), ); if i > 0 { let j = (i * 2) as u32; @@ -778,7 +880,7 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { p.add(egui::Shape::mesh(mesh)); p.add(egui::Shape::line( fill[1..=GRID].to_vec(), - Stroke::new(1.6_f32, CORAL), + Stroke::new(1.6_f32, ACCENT), )); let center = r.center().y; let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65; @@ -792,12 +894,15 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { .collect(); p.line_segment( [Pos2::new(r.left(), center), Pos2::new(r.right(), center)], - Stroke::new(0.7_f32, VIOLET.gamma_multiply(0.35)), + Stroke::new(0.7_f32, FOCUS.gamma_multiply(0.3)), ); - p.add(egui::Shape::line(points, Stroke::new(1.3_f32, VIOLET))); + p.add(egui::Shape::line(points, Stroke::new(1.3_f32, FOCUS))); for (i, f) in settings.focus.iter().enumerate().filter(|(_, f)| f.enabled) { let pos = Pos2::new(x(f.frequency), focus_y(f.sensitivity)); - p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, VIOLET); + p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, FOCUS); + if state.selected == Some(i) { + p.circle_stroke(pos, 10., Stroke::new(1.0_f32, FOCUS.gamma_multiply(0.6))); + } p.text( pos - Vec2::new(0., 12.), egui::Align2::CENTER_BOTTOM, @@ -892,14 +997,14 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { for f in [settings.low, settings.high] { p.line_segment( [Pos2::new(x(f), r.top()), Pos2::new(x(f), r.bottom())], - Stroke::new(2.0_f32, MUTED), + Stroke::new(1.0_f32, MUTED.gamma_multiply(0.7)), ); let handle = Rect::from_center_size(Pos2::new(x(f), r.center().y), Vec2::new(12., 30.)); - p.rect_filled(handle, 6., Color32::from_rgb(65, 77, 96)); + p.rect_filled(handle, 2., Color32::from_rgb(46, 49, 55)); p.rect_stroke( handle, 6., - Stroke::new(1.0_f32, CYAN.gamma_multiply(0.65)), + Stroke::new(1.0_f32, STEEL.gamma_multiply(0.5)), egui::StrokeKind::Inside, ); } diff --git a/plugins/rivet/plugin/src/creative_editor.rs b/plugins/rivet/plugin/src/creative_editor.rs index 07df939..9c80f90 100644 --- a/plugins/rivet/plugin/src/creative_editor.rs +++ b/plugins/rivet/plugin/src/creative_editor.rs @@ -1,4 +1,4 @@ -use super::{egui, report, start, EditorState, LOCK, MUTED, NOTE}; +use super::{egui, report, start, EditorState, LOCK, MUTED, NOTE, ROLE, SURFACE}; use rivet_app::{Output, Shared}; use rivet_core::{CreativeMove, PerformanceSettings, Result, RoleMode, Session}; use std::sync::Arc; @@ -181,9 +181,9 @@ pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) { }); if curve.enabled { let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 58.), egui::Sense::hover()); - let painter = ui.painter(); painter.rect_filled(rect, 3., egui::Color32::from_rgb(30,43,63)); + let painter = ui.painter(); painter.rect_filled(rect, 3., SURFACE); let points: Vec<_> = curve.points.iter().map(|p| egui::pos2(rect.left() + p.position as f32 / 1000. * rect.width(), rect.bottom() - p.value as f32 / 127. * rect.height())).collect(); - for pair in points.windows(2) { painter.line_segment([pair[0],pair[1]], egui::Stroke::new(1.5_f32, if role == 0 { NOTE } else { egui::Color32::from_rgb(107,201,194) })); } + for pair in points.windows(2) { painter.line_segment([pair[0],pair[1]], egui::Stroke::new(1.5_f32, if role == 0 { NOTE } else { ROLE })); } egui::ScrollArea::vertical().id_salt("curve-points").max_height(115.).show(ui, |ui| { let mut remove = None; for index in 0..curve.points.len() { diff --git a/plugins/rivet/plugin/src/editor.rs b/plugins/rivet/plugin/src/editor.rs index c720b81..423e28a 100644 --- a/plugins/rivet/plugin/src/editor.rs +++ b/plugins/rivet/plugin/src/editor.rs @@ -11,12 +11,21 @@ mod creative_editor; use rivet_core::{ Audition, ChordQuality, Ending, Error, Harmony, Phrase, Result, Scale, Session, Task, BAR, }; -const INK: Color32 = Color32::from_rgb(225, 231, 243); -const MUTED: Color32 = Color32::from_rgb(154, 172, 196); -const NOTE: Color32 = Color32::from_rgb(178, 164, 237); -const LOCK: Color32 = Color32::from_rgb(241, 192, 109); -const SURFACE: Color32 = Color32::from_rgb(30, 43, 63); -const GRID: Color32 = Color32::from_rgb(56, 72, 95); +// Dark anodized hardware, shared with Patina: graphite, one signal colour, steel. +const INK: Color32 = Color32::from_rgb(232, 233, 236); +const MUTED: Color32 = Color32::from_rgb(140, 146, 156); +/// Ordinary notes and neutral data. +const NOTE: Color32 = Color32::from_rgb(184, 192, 204); +/// Signal colour: anchors, pins, active state, errors. +const LOCK: Color32 = Color32::from_rgb(255, 1, 96); +/// Second sound role. +const ROLE: Color32 = Color32::from_rgb(126, 167, 216); +/// Recessed screens: piano rolls and curves. +const SURFACE: Color32 = Color32::from_rgb(10, 11, 12); +const GRID: Color32 = Color32::from_rgb(36, 39, 44); +const BG: Color32 = Color32::from_rgb(14, 15, 17); +const PANEL: Color32 = Color32::from_rgb(25, 27, 31); +const EDGE: Color32 = Color32::from_rgb(44, 47, 53); const KEYS: [&str; 12] = [ "C", "C♯", "D", "E♭", "E", "F", "F♯", "G", "A♭", "A", "B♭", "B", ]; @@ -54,13 +63,46 @@ impl Default for EditorState { } pub fn style(ctx: &egui::Context) { let mut visuals = egui::Visuals::dark(); - visuals.panel_fill = Color32::from_rgb(23, 34, 51); - visuals.window_fill = Color32::from_rgb(30, 43, 63); + visuals.panel_fill = BG; + visuals.window_fill = PANEL; + visuals.extreme_bg_color = SURFACE; + visuals.faint_bg_color = PANEL; visuals.override_text_color = Some(INK); - visuals.selection.bg_fill = Color32::from_rgb(82, 72, 125); - visuals.widgets.inactive.bg_fill = Color32::from_rgb(48, 64, 87); - visuals.widgets.hovered.bg_fill = Color32::from_rgb(68, 83, 108); - visuals.widgets.active.bg_fill = Color32::from_rgb(91, 78, 135); + visuals.selection.bg_fill = Color32::from_rgb(110, 6, 46); + visuals.selection.stroke = Stroke::new(1.0_f32, LOCK); + visuals.hyperlink_color = LOCK; + visuals.window_stroke = Stroke::new(1.0_f32, EDGE); + for (w, fill, stroke) in [ + (&mut visuals.widgets.noninteractive, PANEL, EDGE), + ( + &mut visuals.widgets.inactive, + Color32::from_rgb(35, 38, 43), + EDGE, + ), + ( + &mut visuals.widgets.hovered, + Color32::from_rgb(45, 48, 54), + Color32::from_rgb(78, 82, 90), + ), + ( + &mut visuals.widgets.active, + Color32::from_rgb(58, 18, 34), + LOCK, + ), + ( + &mut visuals.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(); + } + visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, INK); + visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, INK); + visuals.slider_trailing_fill = true; ctx.set_visuals(visuals); let mut style = (*ctx.style_of(egui::Theme::Dark)).clone(); style.spacing.item_spacing = Vec2::new(10., 8.); @@ -349,7 +391,10 @@ pub fn show(ui: &mut egui::Ui, shared: &Arc, state: &mut EditorState) { }); egui::ScrollArea::vertical().show(ui,|ui|{ ui.horizontal(|ui|{ - ui.label(RichText::new("Rivet").size(28.).strong()); + let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover()); + ui.painter().rect_filled(mark, 1., LOCK); + ui.label(RichText::new("RIVET").size(26.).strong()); + ui.label(RichText::new("MOTIF STORYBOARD").size(12.).color(MUTED)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center),|ui|{ if ui.add_enabled(!busy,egui::Button::new("Import motif…")).clicked(){ import(shared,state,None,false); @@ -376,7 +421,7 @@ pub fn show(ui: &mut egui::Ui, shared: &Arc, state: &mut EditorState) { ui.label("Phrase"); ui.selectable_value(&mut session.settings.bars,4,"4 bars"); ui.selectable_value(&mut session.settings.bars,8,"8 bars"); - if ui.add_enabled(session.source.is_some(),egui::Button::new("Explore alternatives").fill(Color32::from_rgb(83,70,127))).clicked(){ + if ui.add_enabled(session.source.is_some(),egui::Button::new(RichText::new("Explore alternatives").color(Color32::WHITE)).fill(LOCK)).clicked(){ generate=true; } }); @@ -504,7 +549,7 @@ pub fn show(ui: &mut egui::Ui, shared: &Arc, state: &mut EditorState) { } else{ "Pin" },section*2+1,section*2+2)).fill(if pinned{ - Color32::from_rgb(99,77,45) + Color32::from_rgb(58,18,34) } else{ SURFACE } @@ -732,7 +777,13 @@ fn roll( }, ); let painter = ui.painter(); - painter.rect_filled(rect, 4., SURFACE); + painter.rect_filled(rect, 3., SURFACE); + painter.rect_stroke( + rect, + 3., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); let plot = Rect::from_min_max( Pos2::new(rect.left() + 32., rect.top() + 17.), Pos2::new(rect.right() - 5., rect.bottom() - 5.), @@ -792,7 +843,7 @@ fn roll( if n.is_anchor() { LOCK } else if style.roles.role(i, n) == 1 { - Color32::from_rgb(107, 201, 194) + ROLE } else { NOTE }, diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock index a537c6d..7e6260f 100644 --- a/tools/editor-check/Cargo.lock +++ b/tools/editor-check/Cargo.lock @@ -317,7 +317,7 @@ dependencies = [ [[package]] name = "patina-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "rustfft", "serde", @@ -325,7 +325,7 @@ dependencies = [ [[package]] name = "patina-ui" -version = "0.1.0" +version = "0.2.0" dependencies = [ "egui", "patina-core", diff --git a/tools/editor-check/src/main.rs b/tools/editor-check/src/main.rs index 3ad77f5..bd11036 100644 --- a/tools/editor-check/src/main.rs +++ b/tools/editor-check/src/main.rs @@ -48,9 +48,15 @@ fn main() { engine.set_settings(patina_settings).unwrap(); for i in 0..24000 { let t = i as f32 / 48000.; - let x = (std::f32::consts::TAU * 3000. * t).sin() * 0.28 - + (std::f32::consts::TAU * 240. * t).sin() * 0.15 - + (std::f32::consts::TAU * 1200. * t).sin() * 0.08; + // A 220 Hz harmonic tone with a ring around 3 kHz. + let x = (1..80) + .map(|k| { + let f = 220. * k as f32; + let ring = 1. + 5. * (-((f / 3000.).log2() / 0.12).powi(2)).exp(); + (std::f32::consts::TAU * f * t).sin() * ring / k as f32 + }) + .sum::() + * 0.12; engine.process_sample([x, x * 0.85], patina_core::Monitor::default()); if let Some(frame) = engine.take_analyzer() { patina_state.accept_frame(frame);