fix: bound poweramp saturation, fix crossover distortion, and precompute sag#187
fix: bound poweramp saturation, fix crossover distortion, and precompute sag#187
Conversation
OpenSauce
commented
Feb 14, 2026
- ClassA/ClassB: apply tanh() to both positive and negative halves with asymmetric gain, fixing unbounded negative output (was linear, could reach -3.6 at high drive)
- ClassAB: replace discontinuous 3-region piecewise function with smooth deadzone x³/(x²+dz²) for authentic crossover distortion, eliminating ~0.02 magnitude jumps at ±0.15 thresholds
- Precompute sag envelope exp() coefficients in constructor instead of per-sample, removing ~1.5M redundant exp() calls/sec at 16x oversampling
- Track sag envelope from post-drive signal for consistent behavior across presets regardless of upstream gain staging
- Add denormal protection on sag envelope
- Fix comment: 0.002s time constant is 2ms, not 5ms
…ute sag - ClassA/ClassB: apply tanh() to both positive and negative halves with asymmetric gain, fixing unbounded negative output (was linear, could reach -3.6 at high drive) - ClassAB: replace discontinuous 3-region piecewise function with smooth deadzone x³/(x²+dz²) for authentic crossover distortion, eliminating ~0.02 magnitude jumps at ±0.15 thresholds - Precompute sag envelope exp() coefficients in constructor instead of per-sample, removing ~1.5M redundant exp() calls/sec at 16x oversampling - Track sag envelope from post-drive signal for consistent behavior across presets regardless of upstream gain staging - Add denormal protection on sag envelope - Fix comment: 0.002s time constant is 2ms, not 5ms
There was a problem hiding this comment.
Pull request overview
This PR refines the power amp stage’s DSP to improve numerical stability, realism of crossover distortion, and runtime efficiency in oversampled processing.
Changes:
- Bounds Class A/Class B negative half-wave behavior by applying
tanh()to both polarities with asymmetric gain. - Replaces Class AB’s discontinuous crossover with a smooth deadzone function for continuous crossover distortion.
- Precomputes sag envelope follower coefficients and updates the sag envelope from the post-drive signal (plus adds denormal protection).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Calculate dynamic drive reduction from sag | ||
| let sag_amount = 1.0 - (self.sag * self.sag_envelope * 0.5).min(0.5); | ||
| let dynamic_drive = self.drive * sag_amount; | ||
|
|
||
| // Apply power amp clipping based on type | ||
| // Apply power amp drive | ||
| let driven = input * (1.0 + dynamic_drive * 3.0); | ||
|
|
There was a problem hiding this comment.
sag_amount / dynamic_drive are computed before updating sag_envelope, so sag drive reduction is based on the previous envelope value while the envelope is now derived from the post-drive signal of the current sample. This effectively adds a 1-sample delay and also makes the envelope depend on already sag-reduced drive (feedback), which may not match the intended “track sag from post-drive” behavior. Consider updating the sag envelope first using a post-drive signal that excludes sag reduction (e.g., driven using self.drive only), then compute sag_amount/dynamic_drive from that updated envelope for the same sample.
| match self.amp_type { | ||
| PowerAmpType::ClassA => { | ||
| // Class A - smooth, asymmetric clipping | ||
| if driven > 0.0 { | ||
| // Class A: smooth, asymmetric saturation. | ||
| // Both halves bounded via tanh; negative side has reduced gain | ||
| // for the even-harmonic asymmetry characteristic of single-ended amps. | ||
| if driven >= 0.0 { | ||
| driven.tanh() | ||
| } else { | ||
| driven * 0.8 // Less gain for negative side | ||
| (driven * 0.8).tanh() | ||
| } | ||
| } | ||
| PowerAmpType::ClassAB => { | ||
| // Class AB - asymmetric with crossover characteristics | ||
| if driven > 0.15 { | ||
| // Positive values above crossover | ||
| driven.tanh() | ||
| } else if driven < -0.15 { | ||
| // Negative values below crossover | ||
| 0.9 * driven.tanh() // Slightly less gain on negative | ||
| // Class AB: smooth deadzone around zero for crossover distortion, | ||
| // then asymmetric saturation on both halves. | ||
| // f(x) = x³/(x²+dz²) is C∞ smooth, gain→0 at zero crossing, | ||
| // gain→1 for |x|>>dz — models reduced transconductance near zero. | ||
| let dz: f32 = 0.1; | ||
| let x2 = driven * driven; | ||
| let crossover = driven * x2 / (x2 + dz * dz); | ||
| if crossover >= 0.0 { | ||
| crossover.tanh() | ||
| } else { | ||
| // Crossover region with slight distortion | ||
| driven * (1.0 + 0.2 * driven.abs()) | ||
| (crossover * 0.9).tanh() | ||
| } | ||
| } |
There was a problem hiding this comment.
The new Class A/B bounding and the new Class AB crossover function are significant DSP behavior changes, but poweramp.rs currently has no unit tests. Since other stage implementations include #[test] coverage, it would be good to add tests here (e.g., output always bounded for extreme inputs for each PowerAmpType, and Class AB continuity/no jump around the deadzone).