Check and Windows preview / check (anvil) (push) Successful in 2m41s
Check and Windows preview / check (crucible) (push) Successful in 2m7s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m33s
Check and Windows preview / previews (push) Successful in 1m10s
Check and Windows preview / windows (push) Successful in 2m2s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m3s
Two new CLAP effects aimed at riddim and dubstep production, plus shared crates so all four plugins keep one look and reuse DSP. Anvil 0.1.0 (io.storyboard.sub-anchor): - Replaces the band below Split with a clean mono sine. Every note gets the same peak level and restarts at the same phase, so switch-ups keep their weight and kick hits line up. - Pitch and note starts come from the audio (YIN, 80 ms lookahead reported to the host) or from MIDI notes sent to the effect. - Dynamics, Drive, Octave, Glide, Phase, Gate and Keep; Solo sub monitor, note readout, level meters and a low-band oscilloscope. Crucible 0.1.0 (io.storyboard.resampler): - Records its input continuously in a lock-free ring with host transport markers. Capture takes the newest 1-16 complete bars on the bar grid, following tempo changes, or the newest audio when stopped. - Grid (straight and triplet) or transient slicing, manual cuts, slice audition without audio-thread allocation. - Export writes the loop, trimmed, declicked and normalised slices named and tagged with their root note, and an optional 2048-sample wavetable for Serum and Vital. Shared: - common-ui holds the palette, fonts, knobs and panels taken from Patina; Patina renders identically. Rotary knobs at their minimum no longer tessellate a zero-length arc into NaN vertices. - common-dsp holds biquads, a Linkwitz-Riley crossover, YIN, note helpers and a float WAV writer with sampler root and Serum frame chunks. - Catalog, CI matrix, release tags, editor previews and docs cover both plugins; a script test keeps the catalog and workflows in step.
207 lines
7.6 KiB
Rust
207 lines
7.6 KiB
Rust
//! Lock-free history of the input plus the host transport, shared by the audio and GUI threads.
|
|
//!
|
|
//! The audio thread writes samples with relaxed stores and publishes the frame count with a
|
|
//! release store. The GUI thread reads the count with acquire, copies, then checks the count
|
|
//! again: anything the writer may have overwritten during the copy is reported, never returned.
|
|
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
|
|
|
/// Transport markers kept for alignment; one is written at least every half second.
|
|
pub const MARKERS: usize = 512;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Transport {
|
|
pub playing: bool,
|
|
pub tempo: f64,
|
|
/// Song position in quarter notes at the first sample of the block.
|
|
pub beats: f64,
|
|
pub numerator: u32,
|
|
pub denominator: u32,
|
|
}
|
|
impl Transport {
|
|
/// Bar length in quarter notes.
|
|
pub fn bar(&self) -> f64 {
|
|
self.numerator.max(1) as f64 * 4. / self.denominator.max(1) as f64
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Marker {
|
|
/// Absolute recorded frame index.
|
|
pub frame: u64,
|
|
pub transport: Transport,
|
|
}
|
|
impl Marker {
|
|
/// Song position at `frame`, extrapolated at this marker's tempo.
|
|
pub fn beats_at(&self, frame: f64, rate: f64) -> f64 {
|
|
self.transport.beats + (frame - self.frame as f64) / rate * self.transport.tempo / 60.
|
|
}
|
|
pub fn frame_at(&self, beats: f64, rate: f64) -> f64 {
|
|
self.frame as f64 + (beats - self.transport.beats) * 60. / self.transport.tempo * rate
|
|
}
|
|
}
|
|
|
|
pub struct Ring {
|
|
rate: f32,
|
|
frames: usize,
|
|
data: Box<[AtomicU32]>,
|
|
written: AtomicU64,
|
|
markers: Box<[[AtomicU64; 4]]>,
|
|
marker_count: AtomicU64,
|
|
peak: AtomicU32,
|
|
}
|
|
impl Ring {
|
|
/// Stereo history of `seconds` at `rate`.
|
|
pub fn new(rate: f32, seconds: f32) -> Self {
|
|
let frames = (rate * seconds).ceil().max(1.) as usize;
|
|
Self {
|
|
rate,
|
|
frames,
|
|
data: (0..frames * 2).map(|_| AtomicU32::new(0)).collect(),
|
|
written: AtomicU64::new(0),
|
|
markers: (0..MARKERS)
|
|
.map(|_| std::array::from_fn(|_| AtomicU64::new(0)))
|
|
.collect(),
|
|
marker_count: AtomicU64::new(0),
|
|
peak: AtomicU32::new(0),
|
|
}
|
|
}
|
|
pub fn rate(&self) -> f32 {
|
|
self.rate
|
|
}
|
|
pub fn capacity(&self) -> usize {
|
|
self.frames
|
|
}
|
|
pub fn written(&self) -> u64 {
|
|
self.written.load(Ordering::Acquire)
|
|
}
|
|
/// Input peak since the last call, for the editor meter.
|
|
pub fn take_peak(&self) -> f32 {
|
|
f32::from_bits(self.peak.swap(0, Ordering::AcqRel))
|
|
}
|
|
/// Markers that are certainly intact, oldest first.
|
|
pub fn markers(&self) -> Vec<Marker> {
|
|
let count = self.marker_count.load(Ordering::Acquire);
|
|
// A margin keeps slots the writer may be refilling during this read out of the result.
|
|
let first = count.saturating_sub(MARKERS as u64 - 8);
|
|
let mut out: Vec<Marker> = (first..count)
|
|
.map(|i| {
|
|
let s = &self.markers[(i % MARKERS as u64) as usize];
|
|
let meta = s[3].load(Ordering::Relaxed);
|
|
Marker {
|
|
frame: s[0].load(Ordering::Relaxed),
|
|
transport: Transport {
|
|
beats: f64::from_bits(s[1].load(Ordering::Relaxed)),
|
|
tempo: f64::from_bits(s[2].load(Ordering::Relaxed)),
|
|
numerator: (meta & 0xffff) as u32,
|
|
denominator: ((meta >> 16) & 0xffff) as u32,
|
|
playing: meta >> 32 & 1 == 1,
|
|
},
|
|
}
|
|
})
|
|
.collect();
|
|
let after = self.marker_count.load(Ordering::Acquire);
|
|
let safe = after.saturating_sub(MARKERS as u64 - 8);
|
|
out.drain(..(safe.saturating_sub(first) as usize).min(out.len()));
|
|
out
|
|
}
|
|
/// Copies frames `[start, end)` as interleaved stereo. Fails if any were overwritten.
|
|
pub fn copy(&self, start: u64, end: u64) -> Result<Vec<f32>, &'static str> {
|
|
let written = self.written();
|
|
if end > written || start > end {
|
|
return Err("Capture range is not recorded yet.");
|
|
}
|
|
if written - start > self.frames as u64 {
|
|
return Err("Capture is longer than the recording buffer.");
|
|
}
|
|
let mut out = Vec::with_capacity(((end - start) * 2) as usize);
|
|
for f in start..end {
|
|
let at = (f % self.frames as u64) as usize * 2;
|
|
out.push(f32::from_bits(self.data[at].load(Ordering::Relaxed)));
|
|
out.push(f32::from_bits(self.data[at + 1].load(Ordering::Relaxed)));
|
|
}
|
|
if self.written() - start > self.frames as u64 {
|
|
return Err("Capture was overwritten while copying. Try again.");
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
/// Audio-thread writer. Never allocates.
|
|
pub struct Recorder {
|
|
last: Option<Marker>,
|
|
frame: u64,
|
|
since_marker: u64,
|
|
peak: f32,
|
|
}
|
|
impl Default for Recorder {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
impl Recorder {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
last: None,
|
|
frame: 0,
|
|
since_marker: 0,
|
|
peak: 0.,
|
|
}
|
|
}
|
|
/// Call at the start of every block with the host transport.
|
|
pub fn block(&mut self, ring: &Ring, transport: Option<Transport>) {
|
|
let Some(t) =
|
|
transport.filter(|t| t.tempo.is_finite() && t.tempo > 1. && t.beats.is_finite())
|
|
else {
|
|
return;
|
|
};
|
|
let rate = ring.rate as f64;
|
|
let changed = match self.last {
|
|
None => true,
|
|
Some(m) => {
|
|
let expected = m.beats_at(self.frame as f64, rate);
|
|
m.transport.playing != t.playing
|
|
|| (m.transport.tempo - t.tempo).abs() > 1e-6
|
|
|| m.transport.numerator != t.numerator
|
|
|| m.transport.denominator != t.denominator
|
|
|| (t.playing && (expected - t.beats).abs() > 0.01)
|
|
}
|
|
};
|
|
if changed || self.since_marker as f64 >= rate * 0.5 {
|
|
let m = Marker {
|
|
frame: self.frame,
|
|
transport: t,
|
|
};
|
|
let count = ring.marker_count.load(Ordering::Relaxed);
|
|
let s = &ring.markers[(count % MARKERS as u64) as usize];
|
|
s[0].store(m.frame, Ordering::Relaxed);
|
|
s[1].store(t.beats.to_bits(), Ordering::Relaxed);
|
|
s[2].store(t.tempo.to_bits(), Ordering::Relaxed);
|
|
let meta = (t.numerator as u64 & 0xffff)
|
|
| (t.denominator as u64 & 0xffff) << 16
|
|
| (t.playing as u64) << 32;
|
|
s[3].store(meta, Ordering::Relaxed);
|
|
ring.marker_count.store(count + 1, Ordering::Release);
|
|
self.last = Some(m);
|
|
self.since_marker = 0;
|
|
}
|
|
}
|
|
#[inline]
|
|
pub fn push(&mut self, ring: &Ring, frame: [f32; 2]) {
|
|
let at = (self.frame % ring.frames as u64) as usize * 2;
|
|
ring.data[at].store(frame[0].to_bits(), Ordering::Relaxed);
|
|
ring.data[at + 1].store(frame[1].to_bits(), Ordering::Relaxed);
|
|
self.frame += 1;
|
|
self.since_marker += 1;
|
|
self.peak = self.peak.max(frame[0].abs()).max(frame[1].abs());
|
|
}
|
|
/// Makes the frames pushed so far visible to the GUI thread. Call at the end of a block.
|
|
pub fn publish(&mut self, ring: &Ring) {
|
|
ring.written.store(self.frame, Ordering::Release);
|
|
let old = f32::from_bits(ring.peak.load(Ordering::Relaxed));
|
|
if self.peak > old {
|
|
ring.peak.store(self.peak.to_bits(), Ordering::Relaxed);
|
|
}
|
|
self.peak = 0.;
|
|
}
|
|
}
|