beamer_core/bypass.rs
1//! Bypass handling with smooth crossfading.
2//!
3//! This module provides utilities for implementing soft bypass in audio plugins.
4//! Soft bypass crossfades between wet (processed) and dry (passthrough) signals
5//! to avoid clicks when bypass is toggled.
6//!
7//! # Overview
8//!
9//! - [`BypassState`] - Current bypass state (Active, Bypassed, or transitioning)
10//! - [`CrossfadeCurve`] - Crossfade curve shape (Linear, EqualPower, SCurve)
11//! - [`BypassHandler`] - Main utility for handling bypass with automatic crossfading
12//!
13//! # Example
14//!
15//! ```ignore
16//! use beamer_core::{BypassHandler, CrossfadeCurve, Buffer, AudioProcessor, AuxiliaryBuffers, ProcessContext};
17//!
18//! struct MyPlugin {
19//! bypass_handler: BypassHandler,
20//! // ...
21//! }
22//!
23//! impl AudioProcessor for MyPlugin {
24//! fn setup(&mut self, sample_rate: f64, _max_buffer_size: usize) {
25//! // 10ms crossfade with equal-power curve
26//! let ramp_samples = (sample_rate * 0.01) as u32;
27//! self.bypass_handler = BypassHandler::new(ramp_samples, CrossfadeCurve::EqualPower);
28//! }
29//!
30//! fn process(&mut self, buffer: &mut Buffer, aux: &mut AuxiliaryBuffers, ctx: &ProcessContext) {
31//! let is_bypassed = self.params.bypass_value() > 0.5;
32//!
33//! self.bypass_handler.process(buffer, is_bypassed, |buf| {
34//! // Your DSP code here - only called when processing is needed
35//! self.apply_reverb(buf, aux);
36//! });
37//! }
38//!
39//! fn bypass_ramp_samples(&self) -> u32 {
40//! self.bypass_handler.ramp_samples()
41//! }
42//! }
43//! ```
44
45use crate::buffer::Buffer;
46use crate::sample::Sample;
47
48// =============================================================================
49// BypassState
50// =============================================================================
51
52/// Current state of the bypass handler.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BypassState {
55 /// Plugin is processing normally (not bypassed).
56 Active,
57 /// Transitioning from active to bypassed (crossfading to dry).
58 RampingToBypassed,
59 /// Plugin is fully bypassed (passthrough only).
60 Bypassed,
61 /// Transitioning from bypassed to active (crossfading to wet).
62 RampingToActive,
63}
64
65// =============================================================================
66// CrossfadeCurve
67// =============================================================================
68
69/// Crossfade curve shape for bypass transitions.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
71pub enum CrossfadeCurve {
72 /// Linear crossfade. Simple but may have slight loudness dip at center.
73 /// gain = position (0.0 to 1.0)
74 #[default]
75 Linear,
76
77 /// Equal-power crossfade. Maintains constant loudness during transition.
78 /// gain = sin(position * PI/2) for fade-in, cos(position * PI/2) for fade-out
79 EqualPower,
80
81 /// S-curve crossfade. Attempt at faster start/end, smoother middle.
82 /// gain = 3x^2 - 2x^3 (smoothstep)
83 SCurve,
84}
85
86impl CrossfadeCurve {
87 /// Calculate wet and dry gains for a given ramp position.
88 ///
89 /// # Arguments
90 /// * `t` - Normalized position (0.0 = fully wet, 1.0 = fully dry)
91 ///
92 /// # Returns
93 /// Tuple of (wet_gain, dry_gain) as the specified sample type
94 #[inline]
95 pub fn gains<S: Sample>(&self, t: f64) -> (S, S) {
96 let (wet, dry) = match self {
97 CrossfadeCurve::Linear => (1.0 - t, t),
98 CrossfadeCurve::EqualPower => {
99 let angle = t * std::f64::consts::FRAC_PI_2;
100 (angle.cos(), angle.sin())
101 }
102 CrossfadeCurve::SCurve => {
103 let smooth = t * t * (3.0 - 2.0 * t); // smoothstep
104 (1.0 - smooth, smooth)
105 }
106 };
107 (S::from_f64(wet), S::from_f64(dry))
108 }
109}
110
111// =============================================================================
112// BypassHandler
113// =============================================================================
114
115/// Utility for handling bypass with smooth crossfading.
116///
117/// Maintains bypass state and provides automatic crossfade between
118/// wet (processed) and dry (passthrough) signals when bypass is toggled.
119///
120/// # Sample Type Flexibility
121///
122/// BypassHandler is not generic over sample type - instead, the `process()`
123/// method is generic. This means a single BypassHandler instance can process
124/// both `Buffer<f32>` and `Buffer<f64>` buffers, and plugins don't need
125/// separate handlers for different precision modes.
126///
127/// # Real-Time Safety
128///
129/// This struct performs no heap allocations and is safe to use in
130/// audio processing callbacks.
131///
132/// # Example
133///
134/// ```ignore
135/// use beamer_core::{BypassHandler, CrossfadeCurve, Buffer};
136///
137/// struct MyPlugin {
138/// bypass_handler: BypassHandler,
139/// // ...
140/// }
141///
142/// impl AudioProcessor for MyPlugin {
143/// fn setup(&mut self, sample_rate: f64, _max_buffer_size: usize) {
144/// // 10ms crossfade with equal-power curve
145/// let ramp_samples = (sample_rate * 0.01) as u32;
146/// self.bypass_handler = BypassHandler::new(ramp_samples, CrossfadeCurve::EqualPower);
147/// }
148///
149/// fn process(&mut self, buffer: &mut Buffer, aux: &mut AuxiliaryBuffers, ctx: &ProcessContext) {
150/// let is_bypassed = self.params.bypass_value() > 0.5;
151///
152/// self.bypass_handler.process(buffer, is_bypassed, |buf| {
153/// // Your DSP code here - only called when processing is needed
154/// self.apply_reverb(buf, aux);
155/// });
156/// }
157///
158/// fn bypass_ramp_samples(&self) -> u32 {
159/// self.bypass_handler.ramp_samples()
160/// }
161/// }
162/// ```
163pub struct BypassHandler {
164 /// Current bypass state
165 state: BypassState,
166 /// Current position in ramp (0 = start, ramp_samples = end)
167 ramp_position: u32,
168 /// Total ramp length in samples
169 ramp_samples: u32,
170 /// Crossfade curve to use
171 curve: CrossfadeCurve,
172}
173
174impl BypassHandler {
175 /// Create a new bypass handler.
176 ///
177 /// # Arguments
178 /// * `ramp_samples` - Number of samples for crossfade (0 = instant bypass)
179 /// * `curve` - Crossfade curve shape
180 pub fn new(ramp_samples: u32, curve: CrossfadeCurve) -> Self {
181 Self {
182 state: BypassState::Active,
183 ramp_position: 0,
184 ramp_samples,
185 curve,
186 }
187 }
188
189 /// Get the current bypass state.
190 #[inline]
191 pub fn state(&self) -> BypassState {
192 self.state
193 }
194
195 /// Returns true if currently in a ramping (crossfading) state.
196 #[inline]
197 pub fn is_ramping(&self) -> bool {
198 matches!(
199 self.state,
200 BypassState::RampingToBypassed | BypassState::RampingToActive
201 )
202 }
203
204 /// Returns true if fully bypassed (not ramping).
205 #[inline]
206 pub fn is_bypassed(&self) -> bool {
207 self.state == BypassState::Bypassed
208 }
209
210 /// Returns true if fully active (not ramping, not bypassed).
211 #[inline]
212 pub fn is_active(&self) -> bool {
213 self.state == BypassState::Active
214 }
215
216 /// Get the configured ramp length in samples.
217 #[inline]
218 pub fn ramp_samples(&self) -> u32 {
219 self.ramp_samples
220 }
221
222 /// Set the ramp length. Takes effect on next state transition.
223 pub fn set_ramp_samples(&mut self, samples: u32) {
224 self.ramp_samples = samples;
225 }
226
227 /// Set the crossfade curve. Takes effect on next state transition.
228 pub fn set_curve(&mut self, curve: CrossfadeCurve) {
229 self.curve = curve;
230 }
231
232 /// Update bypass target state.
233 ///
234 /// Call this at the start of each process() with the current bypass parameter value.
235 /// State transitions happen automatically.
236 ///
237 /// When `ramp_samples == 0` (instant bypass), state snaps directly to
238 /// `Bypassed` or `Active` without passing through ramping states.
239 pub fn set_bypass(&mut self, bypassed: bool) {
240 // Handle instant bypass (zero ramp) - snap directly to final state
241 if self.ramp_samples == 0 {
242 let target = if bypassed {
243 BypassState::Bypassed
244 } else {
245 BypassState::Active
246 };
247 if self.state != target {
248 self.state = target;
249 self.ramp_position = 0;
250 }
251 return;
252 }
253
254 match (self.state, bypassed) {
255 // Start ramping to bypassed
256 (BypassState::Active, true) => {
257 self.state = BypassState::RampingToBypassed;
258 self.ramp_position = 0;
259 }
260 // Reverse: was ramping to bypass, now going back to active
261 (BypassState::RampingToBypassed, false) => {
262 self.state = BypassState::RampingToActive;
263 // Keep current ramp_position for smooth reversal
264 }
265 // Start ramping to active
266 (BypassState::Bypassed, false) => {
267 self.state = BypassState::RampingToActive;
268 self.ramp_position = self.ramp_samples;
269 }
270 // Reverse: was ramping to active, now going back to bypass
271 (BypassState::RampingToActive, true) => {
272 self.state = BypassState::RampingToBypassed;
273 // Keep current ramp_position for smooth reversal
274 }
275 // Already in correct stable state, or continuing ramp
276 _ => {}
277 }
278 }
279
280 /// Process audio with bypass handling.
281 ///
282 /// This is the main method plugins should call. It handles:
283 /// - Passthrough when fully bypassed
284 /// - Normal processing when fully active
285 /// - Crossfading during transitions
286 ///
287 /// # Type Parameter
288 ///
289 /// `S` is the sample type (`f32` or `f64`). The same BypassHandler instance
290 /// can process buffers of either precision.
291 ///
292 /// # Arguments
293 /// * `buffer` - Audio buffer to process
294 /// * `bypassed` - Current bypass parameter state (true = bypassed)
295 /// * `process_fn` - Closure that performs DSP (only called when needed)
296 ///
297 /// # Behavior
298 ///
299 /// | State | process_fn called? | Output |
300 /// |-------|-------------------|--------|
301 /// | Active | Yes | Wet signal |
302 /// | RampingToBypassed | Yes | Crossfade wet→dry |
303 /// | Bypassed | No | Dry passthrough |
304 /// | RampingToActive | Yes | Crossfade dry→wet |
305 pub fn process<S: Sample, F>(&mut self, buffer: &mut Buffer<S>, bypassed: bool, process_fn: F)
306 where
307 F: FnOnce(&mut Buffer<S>),
308 {
309 // Update target state
310 self.set_bypass(bypassed);
311
312 let num_samples = buffer.num_samples();
313
314 match self.state {
315 BypassState::Bypassed => {
316 // Fully bypassed: just copy input to output
317 buffer.copy_to_output();
318 }
319
320 BypassState::Active => {
321 // Fully active: just run DSP
322 process_fn(buffer);
323 }
324
325 BypassState::RampingToBypassed | BypassState::RampingToActive => {
326 // Need to crossfade - run DSP first to get wet signal
327 process_fn(buffer);
328
329 // Apply per-sample crossfade
330 self.apply_crossfade(buffer, num_samples);
331 }
332 }
333 }
334
335 fn apply_crossfade<S: Sample>(&mut self, buffer: &mut Buffer<S>, num_samples: usize) {
336 // Guard: instant bypass when ramp_samples is 0
337 if self.ramp_samples == 0 {
338 return;
339 }
340
341 // Guard: no channels to process
342 let num_channels = buffer.num_input_channels().min(buffer.num_output_channels());
343 if num_channels == 0 {
344 return;
345 }
346
347 let ramp_samples_f = self.ramp_samples as f64;
348 let ramping_to_bypass = self.state == BypassState::RampingToBypassed;
349
350 // Process sample by sample
351 for sample_idx in 0..num_samples {
352 // Calculate normalized position (0.0 = wet, 1.0 = dry)
353 let t = (self.ramp_position as f64) / ramp_samples_f;
354 let (wet_gain, dry_gain): (S, S) = self.curve.gains(t);
355
356 // Apply crossfade to all channels for this sample
357 for ch in 0..num_channels {
358 let dry = buffer.input(ch)[sample_idx];
359 let wet = buffer.output(ch)[sample_idx];
360 buffer.output(ch)[sample_idx] = wet * wet_gain + dry * dry_gain;
361 }
362
363 // Advance ramp position (once per sample)
364 if ramping_to_bypass {
365 self.ramp_position = (self.ramp_position + 1).min(self.ramp_samples);
366 } else {
367 self.ramp_position = self.ramp_position.saturating_sub(1);
368 }
369 }
370
371 // Check if ramp complete
372 if ramping_to_bypass && self.ramp_position >= self.ramp_samples {
373 self.state = BypassState::Bypassed;
374 } else if !ramping_to_bypass && self.ramp_position == 0 {
375 self.state = BypassState::Active;
376 }
377 }
378}
379
380impl Default for BypassHandler {
381 /// Create a bypass handler with default settings (64 samples, linear curve).
382 fn default() -> Self {
383 Self::new(64, CrossfadeCurve::Linear)
384 }
385}