Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17c6fe3ab9 |
No files matched your search
Generated
+3
-3
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+50
-27
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 415 KiB |
@@ -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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 655 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 645 KiB |
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "patina-core"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
+248
-61
@@ -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<Complex32>,
|
||||
forward: Arc<dyn Fft<f32>>,
|
||||
inverse: Arc<dyn Fft<f32>>,
|
||||
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::<f32>().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::<f32>()
|
||||
/ (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<usize> = 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 {
|
||||
|
||||
@@ -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.;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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::<f32>()
|
||||
* 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::<f32>() / GRID as f32)
|
||||
.sum::<f32>()
|
||||
/ 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::<f32>()
|
||||
/ GRID as f32
|
||||
})
|
||||
.sum::<f32>()
|
||||
/ (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}");
|
||||
}
|
||||
}
|
||||
Generated
+3
-3
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "patina"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -42,18 +42,12 @@ impl Default for FocusParams {
|
||||
pub struct PatinaParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "patina-render"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "patina-ui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
+356
-251
@@ -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<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
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<Shared>, 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<Shared>, 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<Shared>, 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
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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::<f32>()
|
||||
* 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);
|
||||
|
||||
Reference in new issue
Block a user