diff --git a/README.md b/README.md index c31fc87a..101ca792 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/ROADMAP.md b/ROADMAP.md index f214e1e0..94c76e2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 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 @@ -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. @@ -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 | diff --git a/crates/dsp/src/plugins/harmonic.rs b/crates/dsp/src/plugins/harmonic.rs index acc3f3b5..4d2ca243 100644 --- a/crates/dsp/src/plugins/harmonic.rs +++ b/crates/dsp/src/plugins/harmonic.rs @@ -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] = [ @@ -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, + 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 { + [&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], 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 + } +} diff --git a/crates/dsp/src/plugins/mod.rs b/crates/dsp/src/plugins/mod.rs index aef3e39d..6443dc3d 100644 --- a/crates/dsp/src/plugins/mod.rs +++ b/crates/dsp/src/plugins/mod.rs @@ -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, @@ -64,6 +65,7 @@ pub(crate) fn instantiate(id: &str) -> Option> { "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()), diff --git a/crates/dsp/tests/it/plugins.rs b/crates/dsp/tests/it/plugins.rs index ad55d838..71b2bb5f 100644 --- a/crates/dsp/tests/it/plugins.rs +++ b/crates/dsp/tests/it/plugins.rs @@ -513,6 +513,103 @@ fn saturator_adds_harmonics_and_stays_bounded() { assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0); } +/// Amplitude of the `freq` component of `x` (a whole number of cycles keeps it exact). +fn tone_level(x: &[f32], freq: f32) -> f32 { + let (mut re, mut im) = (0.0f64, 0.0f64); + for (i, v) in x.iter().enumerate() { + let w = f64::from(TAU * freq / SR) * i as f64; + re += f64::from(*v) * w.cos(); + im += f64::from(*v) * w.sin(); + } + (2.0 * (re * re + im * im).sqrt() / x.len() as f64) as f32 +} + +/// A Subharmonic with only the wet path and wide filters, so a band's octave reaches the output. +fn subharmonic_wet() -> Box { + let mut p = prepared("subharmonic"); + p.set_param("mix", 100.0); + p.set_param("hp_freq", 5.0); + p.set_param("lp_freq", 1000.0); + p +} + +#[test] +fn subharmonic_adds_the_octave_below_each_range_band() { + for (range, lower, upper) in [(0.0, 120.0, 180.0), (1.0, 80.0, 120.0), (2.0, 60.0, 90.0), (3.0, 40.0, 60.0)] { + for (band, freq) in [("lower", lower), ("upper", upper)] { + let mut p = subharmonic_wet(); + p.set_param("range", range); + p.set_param("lower_gain", if band == "lower" { 0.0 } else { -120.0 }); + p.set_param("upper_gain", if band == "upper" { 0.0 } else { -120.0 }); + let out = run(p.as_mut(), &[sine(freq, 0.5, 96_000)]); + // One second after the filters settle: a whole number of cycles of both tones. + let tail = &out[0][48_000..]; + let sub = tone_level(tail, freq / 2.0); + assert!(sub > 0.2, "range {range} {band} band: {sub} at {} Hz", freq / 2.0); + assert!(sub > 4.0 * tone_level(tail, freq), "range {range} {band} band keeps its input tone"); + } + } +} + +#[test] +fn subharmonic_mix_keeps_one_path_at_full_level() { + // A 1 kHz tone is far above every band, so the output is the dry path alone. + for (mix, dry) in [(0.0, 1.0), (25.0, 1.0), (50.0, 1.0), (75.0, 0.5), (100.0, 0.0)] { + let mut p = prepared("subharmonic"); + p.set_param("mix", mix); + let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]); + let level = tone_level(&out[0][24_000..], 1000.0); + assert!((level - 0.5 * dry).abs() < 0.01, "mix {mix}: dry level {level}"); + } + // The wet path is at full level from 50 % up and at half level at 25 %. + let wet = |mix: f32| { + let mut p = prepared("subharmonic"); + p.set_param("mix", mix); + p.set_param("wet_solo", 1.0); + let out = run(p.as_mut(), &[sine(60.0, 0.5, 96_000)]); + tone_level(&out[0][48_000..], 30.0) + }; + let full = wet(50.0); + assert!(full > 0.1, "wet level {full}"); + assert!((wet(100.0) - full).abs() < 0.01 * full); + assert!((wet(25.0) - 0.5 * full).abs() < 0.01 * full); +} + +#[test] +fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() { + let level = |solo: &str, gain: f32| { + let mut p = prepared("subharmonic"); + p.set_param("lower_gain", gain); + p.set_param(solo, 1.0); + let x = sine(60.0, 0.5, 96_000).iter().zip(sine(1000.0, 0.5, 96_000)).map(|(a, b)| a + b).collect(); + let out = run(p.as_mut(), &[x]); + let tail = &out[0][48_000..]; + (tone_level(tail, 30.0), tone_level(tail, 1000.0)) + }; + let (sub, dry) = level("lower_solo", 0.0); + assert!(sub > 0.1 && dry < 0.01, "lower solo: sub {sub}, dry {dry}"); + // With the soloed band turned off nothing else plays, though the upper band is on. + let (sub, dry) = level("lower_solo", -120.0); + assert!(sub < 0.01 && dry < 0.01, "silent lower solo: sub {sub}, dry {dry}"); + let (sub, dry) = level("upper_solo", -120.0); + assert!(sub > 0.05 && dry < 0.01, "upper solo: sub {sub}, dry {dry}"); +} + +#[test] +fn subharmonic_drive_keeps_every_character_bounded() { + for character in 0..6 { + let mut p = subharmonic_wet(); + p.set_param("input", 18.0); + p.set_param("lower_gain", 24.0); + p.set_param("upper_gain", 24.0); + p.set_param("drive", 18.0); + p.set_param("character", character as f32); + let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]); + let top = peak(&out[0][24_000..]); + assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}"); + } +} + #[test] fn lofi_quantizes() { let mut p = prepared("lofi"); diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md index 6aa796b5..3a90d569 100644 --- a/docs/plugin-parity.md +++ b/docs/plugin-parity.md @@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (Subharmonic; previously trivial: Sampler ping-pong loop phase (#463); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from @@ -18,11 +18,11 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories. | Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor | | Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | | Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation | -| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape | +| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape, subharmonic synthesis | `lofi`, `rectifier`, `saturator`, `subharmonic` (octave-down bass from a lower and an upper band, direct bass, drive characters, HP and LP filters) | Amp and cabinet models, tape; subharmonic MIDI tuning and surround (LFE) version | | Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction | | Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options | -| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265). The sampler's ping-pong loop keeps its phase when a step is wider than the loop (#463). | Sampler (PR #108 open), piano, organ, wavetable, content library | -| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | | +| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`, `sampler`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265). The sampler's ping-pong loop keeps its phase when a step is wider than the loop (#463). | Piano, organ, wavetable, content library | +| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **25 processors + 3 instruments** | | AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in `crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview, @@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Harmonic: Subharmonic (`subharmonic`) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) | | 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) | | 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |