Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ where things are.
Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
keyboard), all original. **CLAP plugins** load too. Any processor
rectifier, subharmonic, pitch shifter, time shift, gain, trim, invert, DC removal, signal
generator, dither, a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC,
MP3 or OGG across the keyboard), all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
Expand Down
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)

> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** trivial (Subharmonic plugin) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
Expand Down Expand Up @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track

## Progress log

- **2026-10-24:** A built-in Subharmonic (`subharmonic`) adds the octave below a lower and an upper band of the input, chosen from four frequency ranges, with a direct bass band, a gain and a solo for each band, Drive with six characters, HP and LP filters, and a Mix that keeps one path at full level. No readiness percentage change.
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
Expand Down Expand Up @@ -161,6 +162,7 @@ mutated and truncated movies. Field reports from the first public week are track

| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | trivial | Progress log: Subharmonic plugin; readiness estimates unchanged |
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
Expand Down
294 changes: 291 additions & 3 deletions crates/dsp/src/plugins/harmonic.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier.
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
//! Subharmonic (octave-down bass synthesis).

use crate::params::{Params, choice, lin, log, param_plumbing};
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr};
use crate::biquad::{Biquad, FilterType};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};

static SAT_PARAMS: [ParamInfo; 5] = [
Expand Down Expand Up @@ -307,3 +309,289 @@ impl Plugin for Rectifier {
}
}
}

// ---------------------------------------------------------------------------------------------

const SUB_RANGES: &[&str] = &["120-180 Hz", "80-120 Hz", "60-90 Hz", "40-60 Hz"];
/// Lower and upper band frequency of each range.
const SUB_RANGE_HZ: [(f32, f32); 4] = [(120.0, 180.0), (80.0, 120.0), (60.0, 90.0), (40.0, 60.0)];
const SUB_CHARACTERS: &[&str] = &["Clean I", "Clean II", "Resonant I", "Resonant II", "Distort I", "Distort II"];
/// Band gains at or below this are off.
const SUB_OFF_DB: f32 = -120.0;
/// Q of the band-pass that picks each band out of the input.
const SUB_BAND_Q: f32 = 2.0;
/// Cutoff of the low-pass after each divider, as a fraction of the band frequency.
const SUB_SMOOTH: f32 = 0.75;
/// Zero-crossing hysteresis, as a fraction of the band's envelope.
const SUB_HYSTERESIS: f32 = 0.1;
/// Release of the band envelope that sets the hysteresis.
const SUB_ENV_RELEASE_MS: f32 = 50.0;
/// Bias of the asymmetric Distort I curve.
const SUB_ASYM: f32 = 0.25;

static SUB_PARAMS: [ParamInfo; 17] = [
lin("input", "Input", -18.0, 18.0, 0.0, Unit::Db),
choice("range", "Range", SUB_RANGES, 2),
lin("lower_gain", "Lower Gain", SUB_OFF_DB, 24.0, 0.0, Unit::Db),
lin("upper_gain", "Upper Gain", SUB_OFF_DB, 24.0, -6.0, Unit::Db),
lin("direct_gain", "Direct Gain", SUB_OFF_DB, 24.0, SUB_OFF_DB, Unit::Db),
toggle("lower_solo", "Lower Solo", false),
toggle("upper_solo", "Upper Solo", false),
toggle("direct_solo", "Direct Solo", false),
lin("drive", "Drive", -18.0, 18.0, 0.0, Unit::Db),
choice("character", "Character", SUB_CHARACTERS, 0),
log("hp_freq", "HP Freq", 5.0, 1000.0, 20.0, Unit::Hz),
log("hp_q", "HP Q", 0.1, 5.0, 0.707, Unit::None),
log("lp_freq", "LP Freq", 5.0, 1000.0, 200.0, Unit::Hz),
log("lp_q", "LP Q", 0.1, 5.0, 0.707, Unit::None),
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
toggle("wet_solo", "Wet Solo", false),
lin("output", "Output", -18.0, 18.0, 0.0, Unit::Db),
];

pub static SUB_INFO: PluginInfo = PluginInfo {
id: "subharmonic",
name: "Subharmonic",
short_name: "SubHarm",
category: Category::Harmonic,
params: &SUB_PARAMS,
is_instrument: false,
audiosuite: true,
};

/// One synthesized band: a band-pass, a divide-by-two flip-flop and a smoothing low-pass.
#[derive(Debug, Clone, Copy)]
struct SubBand {
band: Biquad,
smooth: Biquad,
/// Divider output: ±1, flipped on every rising zero crossing of the band.
flip: f32,
/// The band has gone below the negative threshold since the last flip.
armed: bool,
env: f32,
}

impl SubBand {
fn new() -> Self {
Self { band: Biquad::default(), smooth: Biquad::default(), flip: 1.0, armed: false, env: 0.0 }
}

fn reset(&mut self) {
self.band.reset();
self.smooth.reset();
self.flip = 1.0;
self.armed = false;
self.env = 0.0;
}

/// The octave below the band's part of `x`; `rel` is the envelope's release coefficient.
#[inline]
fn process(&mut self, x: f32, rel: f32) -> f32 {
let b = self.band.process_sample(x);
let a = b.abs();
self.env = flush(if a > self.env { a } else { self.env * rel });
let th = self.env * SUB_HYSTERESIS;
if b < -th {
self.armed = true;
} else if self.armed && b > th {
self.armed = false;
self.flip = -self.flip;
}
self.smooth.process_sample(b * self.flip)
}
}

/// Filter and divider state of one channel.
#[derive(Debug, Clone, Copy)]
struct SubChannel {
/// The lower and the upper band.
bands: [SubBand; 2],
/// Low-pass that takes the direct bass out of the input.
direct: Biquad,
hp: Biquad,
lp: Biquad,
dc: DcBlocker,
}

impl SubChannel {
fn new(sr: f32) -> Self {
Self { bands: [SubBand::new(); 2], direct: Biquad::default(), hp: Biquad::default(), lp: Biquad::default(), dc: DcBlocker::new(5.0, sr) }
}

fn reset(&mut self) {
self.bands.iter_mut().for_each(SubBand::reset);
self.direct.reset();
self.hp.reset();
self.lp.reset();
self.dc.reset();
}
}

/// Subharmonic synthesizer. The Range sets a lower and an upper band frequency. Each band
/// band-passes the input at its frequency, and a divide-by-two flip-flop on the band's rising zero
/// crossings multiplies the band, which moves it an octave down at a level that follows the band.
/// The direct band is the input low-passed at the upper band frequency.
///
/// - The three bands are summed with their gains, raised by Drive and saturated, then
/// shaped by the HP and LP filters. A soloed band plays alone and mutes the dry signal.
/// - Clean I saturates with tanh and Clean II with a softer algebraic curve. Resonant I and II use
/// tanh and raise the Q of the low-pass after each divider to 2 and 4. Distort I bends the curve
/// asymmetrically (even harmonics, DC removed) and Distort II clips hard at full scale.
/// - Below 50 % Mix the dry signal stays at full level and the wet level falls; above 50 % the wet
/// signal stays at full level and the dry level falls. Wet Solo mutes the dry signal.
pub struct SubHarmonic {
p: Params,
sr: f32,
ch: Vec<SubChannel>,
input: Smoother,
bands: [Smoother; 3],
drive: Smoother,
dry: Smoother,
wet: Smoother,
out: Smoother,
env_rel: f32,
}

impl Default for SubHarmonic {
fn default() -> Self {
Self::new()
}
}

impl SubHarmonic {
pub fn new() -> Self {
let mut s = Self {
p: Params::new(&SUB_PARAMS),
sr: 48_000.0,
ch: Vec::new(),
input: Smoother::new(1.0),
bands: [Smoother::new(0.0); 3],
drive: Smoother::new(1.0),
dry: Smoother::new(1.0),
wet: Smoother::new(1.0),
out: Smoother::new(1.0),
env_rel: 0.0,
};
s.update();
s
}

/// The lower and upper band frequencies of the current range.
fn range_hz(&self) -> (f32, f32) {
SUB_RANGE_HZ.get(self.p.choice(1)).copied().unwrap_or((60.0, 90.0))
}

/// Q of the low-pass after each divider for the current Drive Character.
fn smooth_q(&self) -> f32 {
match self.p.choice(9) {
2 => 2.0,
3 => 4.0,
_ => std::f32::consts::FRAC_1_SQRT_2,
}
}

fn update(&mut self) {
let gain = |db: f32| if db <= SUB_OFF_DB { 0.0 } else { db_to_gain(db) };
let solo = [self.p.on(5), self.p.on(6), self.p.on(7)];
let any_solo = solo.contains(&true);
for (k, s) in self.bands.iter_mut().enumerate() {
let heard = !any_solo || solo.get(k).copied().unwrap_or(false);
s.set(if heard { gain(self.p.v(2 + k)) } else { 0.0 });
}
self.input.set(db_to_gain(self.p.v(0)));
self.drive.set(db_to_gain(self.p.v(8)));
self.out.set(db_to_gain(self.p.v(16)));
let m = self.p.v(14) / 100.0;
self.wet.set((2.0 * m).min(1.0));
self.dry.set(if any_solo || self.p.on(15) { 0.0 } else { (2.0 * (1.0 - m)).min(1.0) });
self.env_rel = time_coef(SUB_ENV_RELEASE_MS, self.sr);

let (lo, hi) = self.range_hz();
let sq = self.smooth_q();
let sr = self.sr;
let (hp_f, hp_q, lp_f, lp_q) = (self.p.v(10), self.p.v(11), self.p.v(12), self.p.v(13));
for c in &mut self.ch {
for (b, f) in c.bands.iter_mut().zip([lo, hi]) {
b.band.set(FilterType::BandPass, f, SUB_BAND_Q, 0.0, sr);
b.smooth.set(FilterType::LowPass, f * SUB_SMOOTH, sq, 0.0, sr);
}
c.direct.set(FilterType::LowPass, hi, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
c.hp.set(FilterType::HighPass, hp_f, hp_q, 0.0, sr);
c.lp.set(FilterType::LowPass, lp_f, lp_q, 0.0, sr);
}
}

fn smoothers(&mut self) -> impl Iterator<Item = &mut Smoother> {
[&mut self.input, &mut self.drive, &mut self.dry, &mut self.wet, &mut self.out].into_iter().chain(self.bands.iter_mut())
}
}

/// Saturation curve of each Drive Character (index into `SUB_CHARACTERS`).
fn sub_saturate(character: usize, x: f32) -> f32 {
match character {
1 => x / (1.0 + x * x).sqrt(),
4 => ((x + SUB_ASYM).tanh() - SUB_ASYM.tanh()) / (1.0 + SUB_ASYM.tanh()),
5 => x.clamp(-1.0, 1.0),
_ => x.tanh(),
}
}

impl Plugin for SubHarmonic {
param_plumbing!(SUB_INFO);

fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = vec![SubChannel::new(self.sr); sane_channels(channels)];
let sr = self.sr;
for s in self.smoothers() {
s.set_time(20.0, sr);
}
self.update();
self.reset();
}

fn reset(&mut self) {
self.ch.iter_mut().for_each(SubChannel::reset);
for s in self.smoothers() {
s.snap();
}
}

fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.len().min(io.len());
let frames = frames_in(io, frames, ch);
let character = self.p.choice(9);
let rel = self.env_rel;
for n in 0..frames {
let gin = self.input.next();
let g = [self.bands[0].next(), self.bands[1].next(), self.bands[2].next()];
let drive = self.drive.next();
let dg = self.dry.next();
let wg = self.wet.next();
let o = self.out.next();
for c in 0..ch {
let (Some(buf), Some(st)) = (io.get_mut(c), self.ch.get_mut(c)) else { continue };
let Some(x) = buf.get_mut(n) else { continue };
let dry = if x.is_finite() { *x * gin } else { 0.0 };
let mut sum = st.direct.process_sample(dry) * g[2];
for (band, gain) in st.bands.iter_mut().zip(g) {
sum += band.process(dry, rel) * gain;
}
let mut y = sub_saturate(character, sum * drive);
if character == 4 {
y = st.dc.process(y);
}
let wet = st.lp.process_sample(st.hp.process_sample(y));
*x = (dry * dg + wet * wg) * o;
}
}
}

fn tail_samples(&self) -> usize {
// Time for the slowest, most resonant filter to ring down by 60 dB (6.9 time constants).
let (lo, _) = self.range_hz();
let f = self.p.v(10).min(self.p.v(12)).min(lo * SUB_SMOOTH).max(1.0);
let q = self.p.v(11).max(self.p.v(13)).max(self.smooth_q()).max(SUB_BAND_Q);
(6.9 * q / (std::f32::consts::PI * f) * self.sr) as usize
}
}
2 changes: 2 additions & 0 deletions crates/dsp/src/plugins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&harmonic::SAT_INFO,
&harmonic::LOFI_INFO,
&harmonic::RECT_INFO,
&harmonic::SUB_INFO,
&pitch::PITCH_INFO,
&utility::SHIFT_INFO,
&utility::GAIN_INFO,
Expand Down Expand Up @@ -64,6 +65,7 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"saturator" => Box::new(harmonic::Saturator::new()),
"lofi" => Box::new(harmonic::LoFi::new()),
"rectifier" => Box::new(harmonic::Rectifier::new()),
"subharmonic" => Box::new(harmonic::SubHarmonic::new()),
"pitch_shifter" => Box::new(pitch::PitchShifter::new()),
"time_shift" => Box::new(utility::TimeShift::new()),
"gain" => Box::new(utility::Gain::new()),
Expand Down
Loading