Check and Windows preview / check (patina) (push) Successful in 1m48s
Check and Windows preview / check (rivet) (push) Successful in 1m26s
Check and Windows preview / previews (push) Successful in 55s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 1m41s
Independent plugin release / build (push) Successful in 2m28s
Independent plugin release / publish (push) Successful in 7s
Patina 0.2.0. The old detector compared raw FFT bins with a one-octave dB mean, so every harmonic of a tonal sound looked like a resonance and real harsh bumps were barely touched. The new detector: - averages power over each log band and smooths it over time - joins harmonic tops into an envelope, smoothed by Sharpness - compares it with a robust, tilt-aware local line fit that ignores empty regions and cannot be lifted by the bump itself Six overlapping controls become Depth, Sharpness and Selectivity; focus points now read "up cuts more, down protects". The analysis window is halved to 2048 samples (about 43 ms delay). Behaviour tests cover plain harmonic tones, resonant bumps, narrow rings, level independence, flicker on noise and high sample rates. Both editors get a dark metallic look with #FF0160 as the single accent colour; screenshots and docs are updated. Projects saved with Patina 0.1 load the new controls at their defaults.
196 lines
7.2 KiB
Rust
196 lines
7.2 KiB
Rust
//! Bounded offline evaluation: PCM16/24/32 and IEEE float32 RIFF/WAVE input.
|
|
use patina_core::{Engine, Monitor, Settings};
|
|
use std::{
|
|
env, fs,
|
|
io::{Read, Write},
|
|
path::Path,
|
|
time::Instant,
|
|
};
|
|
fn main() {
|
|
if let Err(e) = run() {
|
|
eprintln!("{e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|
let args: Vec<_> = env::args().skip(1).collect();
|
|
if args.is_empty() || args[0] == "--help" {
|
|
println!("patina-render <input.wav> <output.wav> [settings.json] [--delta]\npatina-render --demo <output.wav>\npatina-render --benchmark\nOutput is float32 WAV, latency-trimmed with original length. Existing files are refused.");
|
|
return Ok(());
|
|
}
|
|
if args[0] == "--benchmark" {
|
|
let mut e = Engine::new(48000., 2)?;
|
|
e.set_settings(Settings::preset(1))?;
|
|
let frames = 48000 * 5;
|
|
let start = Instant::now();
|
|
for i in 0..frames {
|
|
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 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 {
|
|
let samples = (0..48000 * 3)
|
|
.map(|i| {
|
|
let t = i as f32 / 48000.;
|
|
let envelope = if t % 0.5 < 0.3 { 1. } else { 0.2 };
|
|
[((t * 440. * std::f32::consts::TAU).sin() * 0.14
|
|
+ (t * 3000. * std::f32::consts::TAU).sin() * 0.28)
|
|
* envelope; 2]
|
|
})
|
|
.collect();
|
|
(48000., 2, samples, args[1].clone())
|
|
} else if args.len() >= 2 {
|
|
let (r, c, x) = read_wave(Path::new(&args[0]))?;
|
|
(r, c, x, args[1].clone())
|
|
} else {
|
|
return Err("Use --help for usage".into());
|
|
};
|
|
let settings = if args.get(2).is_some_and(|s| !s.starts_with("--")) {
|
|
let bytes = read_limit(Path::new(&args[2]), 64 * 1024)?;
|
|
serde_json::from_slice::<Settings>(&bytes)?
|
|
} else {
|
|
Settings::default()
|
|
};
|
|
settings.validate()?;
|
|
let mut engine = Engine::new(rate, channels)?;
|
|
engine.set_settings(settings)?;
|
|
let latency = engine.latency_samples() as usize;
|
|
let start = Instant::now();
|
|
let mut rendered = Vec::with_capacity(input.len());
|
|
let monitor = Monitor {
|
|
delta: args.iter().any(|a| a == "--delta"),
|
|
};
|
|
for i in 0..input.len() + latency {
|
|
let sample = input.get(i).copied().unwrap_or([0.; 2]);
|
|
let out = engine.process_sample(sample, monitor);
|
|
if i >= latency {
|
|
rendered.push(out);
|
|
}
|
|
}
|
|
write_wave(Path::new(&output), rate as u32, channels, &rendered)?;
|
|
println!(
|
|
"Rendered {} frames, {} channel(s), {} Hz in {:.3}s; compensated {} samples → {}",
|
|
rendered.len(),
|
|
channels,
|
|
rate,
|
|
start.elapsed().as_secs_f64(),
|
|
latency,
|
|
output
|
|
);
|
|
Ok(())
|
|
}
|
|
fn read_limit(path: &Path, limit: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
|
let mut bytes = Vec::new();
|
|
fs::File::open(path)?
|
|
.take(limit as u64 + 1)
|
|
.read_to_end(&mut bytes)?;
|
|
if bytes.len() > limit {
|
|
return Err("Input exceeds size limit".into());
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
type Wave = (f32, usize, Vec<[f32; 2]>);
|
|
fn read_wave(path: &Path) -> Result<Wave, Box<dyn std::error::Error>> {
|
|
let b = read_limit(path, 256 * 1024 * 1024)?;
|
|
if b.len() < 12 || &b[..4] != b"RIFF" || &b[8..12] != b"WAVE" {
|
|
return Err("Expected RIFF/WAVE".into());
|
|
}
|
|
let u16_at = |p| u16::from_le_bytes([b[p], b[p + 1]]);
|
|
let u32_at = |p| u32::from_le_bytes([b[p], b[p + 1], b[p + 2], b[p + 3]]);
|
|
let end = (u32_at(4) as usize)
|
|
.checked_add(8)
|
|
.ok_or("Invalid RIFF length")?;
|
|
if end > b.len() {
|
|
return Err("Truncated RIFF container".into());
|
|
}
|
|
let mut pos = 12;
|
|
let mut format = None;
|
|
let mut data = None;
|
|
while pos + 8 <= end {
|
|
let len = u32_at(pos + 4) as usize;
|
|
let start = pos + 8;
|
|
let stop = start.checked_add(len).ok_or("Invalid chunk length")?;
|
|
if stop > end {
|
|
return Err("Truncated WAV chunk".into());
|
|
}
|
|
match &b[pos..pos + 4] {
|
|
b"fmt " if len >= 16 => {
|
|
format = Some((
|
|
u16_at(start),
|
|
u16_at(start + 2) as usize,
|
|
u32_at(start + 4),
|
|
u16_at(start + 12) as usize,
|
|
u16_at(start + 14) as usize,
|
|
))
|
|
}
|
|
b"data" => data = Some((start, stop)),
|
|
_ => {}
|
|
}
|
|
pos = stop + (len % 2);
|
|
}
|
|
let (tag, channels, rate, align, bits) = format.ok_or("Missing WAV format")?;
|
|
let (start, stop) = data.ok_or("Missing WAV data")?;
|
|
if !(1..=2).contains(&channels) || !matches!((tag, bits), (1, 16) | (1, 24) | (1, 32) | (3, 32))
|
|
{
|
|
return Err("Use mono/stereo PCM16/24/32 or float32 WAV".into());
|
|
}
|
|
let size = bits / 8;
|
|
if align != channels * size || (stop - start) % align != 0 {
|
|
return Err("Invalid WAV block alignment".into());
|
|
}
|
|
let mut samples = Vec::with_capacity((stop - start) / align);
|
|
for p in (start..stop).step_by(align) {
|
|
let mut frame = [0.; 2];
|
|
for (c, value) in frame.iter_mut().enumerate().take(channels) {
|
|
let q = p + c * size;
|
|
*value = match (tag, bits) {
|
|
(3, 32) => f32::from_le_bytes(b[q..q + 4].try_into()?),
|
|
(1, 16) => i16::from_le_bytes(b[q..q + 2].try_into()?) as f32 / 32768.,
|
|
(1, 24) => {
|
|
((b[q] as i32 | ((b[q + 1] as i32) << 8) | ((b[q + 2] as i32) << 16)) << 8 >> 8)
|
|
as f32
|
|
/ 8388608.
|
|
}
|
|
_ => i32::from_le_bytes(b[q..q + 4].try_into()?) as f32 / 2147483648.,
|
|
};
|
|
if !value.is_finite() {
|
|
return Err("Non-finite WAV samples".into());
|
|
}
|
|
}
|
|
samples.push(frame);
|
|
}
|
|
Ok((rate as f32, channels, samples))
|
|
}
|
|
fn write_wave(
|
|
path: &Path,
|
|
rate: u32,
|
|
channels: usize,
|
|
samples: &[[f32; 2]],
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let bytes = (samples.len() * channels * 4) as u32;
|
|
let mut f = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)?;
|
|
f.write_all(b"RIFF")?;
|
|
f.write_all(&(36 + bytes).to_le_bytes())?;
|
|
f.write_all(b"WAVEfmt ")?;
|
|
f.write_all(&16u32.to_le_bytes())?;
|
|
f.write_all(&3u16.to_le_bytes())?;
|
|
f.write_all(&(channels as u16).to_le_bytes())?;
|
|
f.write_all(&rate.to_le_bytes())?;
|
|
f.write_all(&(rate * channels as u32 * 4).to_le_bytes())?;
|
|
f.write_all(&(channels as u16 * 4).to_le_bytes())?;
|
|
f.write_all(&32u16.to_le_bytes())?;
|
|
f.write_all(b"data")?;
|
|
f.write_all(&bytes.to_le_bytes())?;
|
|
for frame in samples {
|
|
for sample in frame.iter().take(channels) {
|
|
f.write_all(&sample.to_le_bytes())?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|