diff --git a/README.md b/README.md index 5d5e3a7bc..c7c127baa 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, ring modulator, - 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 + saturator, lo-fi, 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 92d6b06e8..928fde16e 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-19 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** trivial (Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **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-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change. - **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. 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. @@ -166,6 +167,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-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged | | 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged | | 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | diff --git a/crates/dsp/src/plugins/harmonic.rs b/crates/dsp/src/plugins/harmonic.rs index acc3f3b50..4d2ca2431 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 97d672e92..fe1b5abeb 100644 --- a/crates/dsp/src/plugins/mod.rs +++ b/crates/dsp/src/plugins/mod.rs @@ -33,6 +33,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, @@ -66,6 +67,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 ceb68c092..35b41cd1c 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 8725f9e73..941cd52ce 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-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously 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 minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously 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 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`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation) | Tremolo, auto-pan, frequency shift | -| 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) | **25 processors + 3 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) | **26 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`); built-in count 26 processors + 3 instruments | | 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu | | 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) | | 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |