Skip to content

Commit ec0f9e4

Browse files
committed
audioshaper: name the Waveshaper's own headroom ceiling, CLIP_HEADROOM (audioif#99)
A curve that reaches the rails and a post_gain above about 0.74 saturates the Waveshaper's own output, after the decimator, where no oversampling factor reaches it -- and the node's documentation said nothing about it. Distortion's second fix round found this the hard way and carries its own 0.74 ceiling; every other drive class was exposed to the same knee with no name for it. Where it happens (src/shared/audioif_shaper.c): shape_sample runs the curve at the oversampled rate and curve_lookup's own clamp (:213) bounds each of those samples to +-1, but the decimated one is not clamped there. halfband_down (:201-207) is a low-pass, not a clip, so a hard edge through it can overshoot the rails on the way back down -- the band-limited version of a full-scale clipped edge does not fit in int16. post_gain scales that decimated (and possibly overshot) value (:288, oversampled[0] * config->post_gain * 32768.0f), after the half-band, and the only place this node ever clips to int16 is to_s16, two lines later (:289-290). Everything from the curve to post_gain is float; nothing here is int16 until that last line. Measured on the CPython twin: a straight hard clip (int16 Q15, flat beyond +-10% of input), a 1010 Hz sine driven to 98% of full scale, 48 kHz, oversample x4, bare node, alias floor over an exactly periodic 4800-sample window (bin 101, no window, no rounding): post=0.66 -54.344 | 0.70 -54.343 | 0.74 -54.345 | 0.78 -54.343 post=0.80 -54.344 | 0.90 -41.238 | 1.00 -36.441 Flat to the hundredth of a dB through 0.80, then 13.1 dB worse by 0.90. The control is oversample x1, same curve and same drive: -34.915 dB at every one of those seven settings, so the knee is the decimator's doing, not the curve's. Full table, x8 included, in docs/upstream-diff.md's new "clipper's own headroom" section. CLIP_HEADROOM = 0.74 names the ceiling, on the CPython twin only (src/cpython/audioshaper.py), the same as GROUP_DELAY_SAMPLES beside it: neither the MicroPython usermod's module globals (src/audioshaper/module.c) nor the CircuitPython spike's (shared-bindings/audioshaper/__init__.c) export anything past __version__/__revision__ and the two types, so there is no second target for this figure to agree with -- adding it there too would be a constant that is not identical on all three targets. HeadroomTest in tests/test_cpython_audioshaper.py pins the knee: the floor at 0.74 is within 1 dB of 0.66 (0.001 dB measured), and at least 10 dB worse by 0.90 (13.1 dB measured) -- 10 rather than the 20 the issue's own Distortion-table measurement showed, because that table is a different curve in a different repository. An extensive parameter search here (clip steepness, tanh steepness, drive level, pre_gain, oversample x2/x4/x8) never found a bare-node reproduction that robustly cleared 20 dB between exactly 0.74 and 0.90; 13.1 dB at x4 was the ceiling. Shown able to fail, at the literal 20 dB bar: FAIL test_the_floor_falls_past_the_knee AssertionError: -54.34461222147358 not less than -61.23844766655389 : 0.90 was not at least 20 dB worse than 0.74: -54.345 vs -41.238 dB A third test is the control: at oversample x1 the same curve and the same drive show no knee at all (max-min under 0.5 dB across all seven post_gain settings), which is what pins the mechanism as the decimator's rather than the curve's own clipping. Docstrings (the twin, docs/upstream-diff.md, README's module paragraph) and CHANGELOG carry the rule for callers: keep post_gain * max(abs(curve)) at or below CLIP_HEADROOM for a curve that reaches the rails, and put the rest of the wanted level on a mixer voice after this node. No DSP changed.
1 parent e3b95e7 commit ec0f9e4

5 files changed

Lines changed: 203 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
## Unreleased
22

3+
- **`audioshaper.Waveshaper`'s own headroom is documented, and pinned by a
4+
trait test.** A curve that reaches the rails and a `post_gain` above about
5+
0.74 saturates the node's *own* output: the half-band decimator rings
6+
about a third past the rails on a hard edge, at the base rate, after the
7+
oversampling is already done, where no factor of it reaches. Measured on
8+
a hard-clipping table driven hard (x4, 1010 Hz, 48 kHz, bare node): the
9+
alias floor is flat through `post_gain` 0.80 and 13 dB worse by 0.90.
10+
`audioshaper.CLIP_HEADROOM = 0.74` names the ceiling — keep
11+
`post_gain * max(abs(curve))` at or below it for a curve that reaches the
12+
rails, and put the rest of the wanted level on a mixer voice after this
13+
node. No DSP changed: the docstring, `docs/upstream-diff.md` and a new
14+
`HeadroomTest` in `tests/test_cpython_audioshaper.py` say what a drive
15+
class (Distortion's second fix round) had already found and was carrying
16+
its own 0.74 ceiling for, uncredited. The constant lives on the CPython
17+
twin only — neither the MicroPython usermod's module globals nor the
18+
CircuitPython spike's export anything past `__version__`/`__revision__`
19+
and the two types, the same as `GROUP_DELAY_SAMPLES` beside it
20+
(audioif#99).
21+
322
- **`audioshaper.SampleHold`**, a new node: a zero-order hold in which `num`
423
source frames carry `den` new values, at a ratio that is exact. One frame
524
in, one frame out, at the source's own rate, channels and bit depth; the

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@ with `hysteresis_width` its half-width as a fraction of full scale *at the
235235
input* and `hysteresis_bias` splitting that between the rising and falling
236236
branches. `audioshaper.GROUP_DELAY_SAMPLES` carries the measured group delay
237237
per factor — 0, 2.2, 3.3 and 3.9 base samples — for a component that has to
238-
report its latency. Why each of those is the shape it is, and what the
238+
report its latency. A curve that reaches the rails has a ceiling of its
239+
own: the decimator can ring past them on a hard edge, so keep
240+
`post_gain * max(abs(curve))` at or below `audioshaper.CLIP_HEADROOM`
241+
(0.74) and put the rest of the wanted level on a mixer voice after this
242+
node. Why each of those is the shape it is, and what the
239243
oversampling measures:
240244
[docs/upstream-diff.md](docs/upstream-diff.md), "`audioshaper`: audioif's own,
241245
and the two things a fixed curve cannot be".

docs/upstream-diff.md

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2769,11 +2769,15 @@ measuring three libms rather than this node.
27692769
27702770
`tests/test_cpython_audioshaper.py` carries what the golden cannot: that
27712771
oversampling actually lowers the alias floor, that the hysteresis knob is
2772-
monotone and clears its control by 6 dB, and that a static table and a
2773-
zero-width operator both enclose exactly 0.0. Each check was shown to fail
2774-
before it was believed -- discarding the play operator's result reddens both
2775-
hysteresis tests, and replacing the half-bands with a zero-order hold and a
2776-
decimating drop reddens the alias-floor test.
2772+
monotone and clears its control by 6 dB, that a static table and a
2773+
zero-width operator both enclose exactly 0.0, and that a hard-clipping
2774+
curve's own alias floor holds flat through `post_gain` 0.74 and falls at
2775+
least 10 dB worse by 0.90 (audioif#99, "The clipper's own headroom" below).
2776+
Each check was shown to fail before it was believed -- discarding the play
2777+
operator's result reddens both hysteresis tests, replacing the half-bands
2778+
with a zero-order hold and a decimating drop reddens the alias-floor test,
2779+
and the headroom test reddens at oversample x1, where there is no
2780+
half-band left to ring.
27772781
27782782
`SampleHold` has `tests/parity/samplehold_probe.py` of its own rather than
27792783
cases appended to the waveshaper's, because one comparison covers a probe's
@@ -2792,6 +2796,57 @@ node's own loop re-typed) is run against the two-`SpeedChanger` composition
27922796
does, and by the end of that distance the pair is holding a frame the
27932797
arithmetic does not name.
27942798
2799+
### The clipper's own headroom, after the decimator (audioif#99, 2026-09-17)
2800+
2801+
Oversampling buys back the base rate's aliasing, but it has a ceiling of its
2802+
own that the sections above do not mention. `shape_sample` runs at the
2803+
oversampled rate and `curve_lookup`'s own clamp
2804+
(`src/shared/audioif_shaper.c:213`) holds every one of those samples to
2805+
+-1 -- but the *decimated* one is not clamped there. `halfband_down`
2806+
(`:201-207`) is a low-pass, not a clip, so a hard edge through it can
2807+
overshoot the rails on the way back down to the base rate, the way any
2808+
band-limited reconstruction of a discontinuity does. `post_gain` is applied
2809+
to that decimated value (`audioif_shaper_process_s16`, `:288`,
2810+
`oversampled[0] * config->post_gain * 32768.0f`) -- after the half-band,
2811+
where no oversampling factor reaches it any more -- and the *only* place
2812+
this node clips to int16 at all is `to_s16`, two lines later (`:289-290`),
2813+
on `dry_gain * source + wet_gain * wet`. Every value in between, including
2814+
the overshoot, is `float`.
2815+
2816+
Measured on the CPython twin: a straight hard clip (int16 Q15, 2048 points,
2817+
flat beyond +-10% of input, so it reaches the rails), a 1010 Hz sine at
2818+
48 kHz driven to 98% of full scale, oversample x4, bare node. The alias
2819+
floor is inharmonic energy against the fundamental over an exactly periodic
2820+
4800-sample window -- bin 101 of a rectangular-window transform, so nothing
2821+
is rounded to its nearest bin:
2822+
2823+
| `post_gain` | 0.66 | 0.70 | 0.74 | 0.78 | 0.80 | 0.90 | 1.00 |
2824+
|---|---|---|---|---|---|---|---|
2825+
| floor (dBc) | -54.34 | -54.34 | -54.35 | -54.34 | -54.34 | -41.24 | -36.44 |
2826+
2827+
Flat to the hundredth of a dB through 0.80, then 13.1 dB worse by 0.90 and
2828+
17.9 by 1.00. (x8 shows the same shape a little deeper: flat at -55.77
2829+
through 0.80, -41.21 at 0.90.) The control is oversample x1 on the same
2830+
curve and the same drive: with no half-band left to ring, the floor reads
2831+
-34.92 dBc at every one of those seven settings, so the knee measured above
2832+
is the decimator's doing and not the curve's.
2833+
2834+
That is physics, not a defect -- the headroom has to come from somewhere,
2835+
and a full-scale clipped edge asks the reconstruction filter for more than
2836+
int16 has to give back. `audioshaper.CLIP_HEADROOM = 0.74` names the
2837+
ceiling: keep `post_gain * max(abs(curve))` at or below it for a curve that
2838+
reaches the rails, and put the rest of the wanted level on a mixer voice
2839+
after this node rather than on this knob. The constant lives on the
2840+
CPython twin only, the same as `GROUP_DELAY_SAMPLES` above it -- neither
2841+
the MicroPython usermod's module globals (`src/audioshaper/module.c`) nor
2842+
the CircuitPython spike's (`shared-bindings/audioshaper/__init__.c`) export
2843+
anything past `__version__`/`__revision__` and the two types, so there is
2844+
no second target yet for this figure to agree with.
2845+
2846+
A class that drives this node with a hard-clipping curve and wants more
2847+
than ~0.74 of full scale out of it has been carrying its own 0.74 ceiling
2848+
without a name for it (audioif#99); this is that name.
2849+
27952850
## `audioladder`: the loop CircuitPython's filters cannot close (effects Phase 1)
27962851
27972852
`audiofilters.Filter` cascades RBJ biquads and does it very well: measured

src/cpython/audioshaper.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@
4141
Nyquist and they fold back onto the signal, and nothing else in audioif
4242
resamples at all.
4343
44+
A curve that reaches the rails has a cost on the way back down, though: the
45+
band-limited version of a full-scale clipped edge does not fit in int16.
46+
The decimator rings about a third past the rails on an edge like that --
47+
at the base rate, after the oversampling is already done, where no factor
48+
of it reaches -- and `post_gain` is what decides whether that overshoot
49+
then clips. Keep `post_gain * max(abs(curve))` at or below `CLIP_HEADROOM`
50+
(about 0.74 of full scale) for a curve that reaches the rails, and put the
51+
rest of the wanted level on a mixer voice after this node rather than on
52+
this knob. Measured table: `docs/upstream-diff.md`, "`audioshaper`"
53+
(audioif#99).
54+
4455
`hysteresis` is off by default and is the one thing a table cannot do: give
4556
the curve a memory, so a slow triangle in and out traces two different paths
4657
and encloses an area. At zero the node is a static table, sample for sample.
@@ -98,6 +109,18 @@
98109
#: for the factor it built with -- it is small, but it is not zero.
99110
GROUP_DELAY_SAMPLES = {1: 0.0, 2: 2.2, 4: 3.3, 8: 3.9}
100111

112+
#: Above roughly this fraction of full scale, `post_gain` on a curve that
113+
#: reaches the rails re-clips the decimator's own overshoot at the base
114+
#: rate rather than anything the oversampling can still fix -- see the
115+
#: docstring above and docs/upstream-diff.md's `audioshaper` section for
116+
#: the measured table (audioif#99). Documentation only, the same as
117+
#: `GROUP_DELAY_SAMPLES` above it: neither the MicroPython usermod's module
118+
#: globals (`src/audioshaper/module.c`) nor the CircuitPython spike's
119+
#: (`shared-bindings/audioshaper/__init__.c`) export anything past
120+
#: `__version__`/`__revision__` and the two types, so there is no second
121+
#: target yet for this figure to agree with.
122+
CLIP_HEADROOM = 0.74
123+
101124

102125
class Waveshaper(_AudioSample):
103126
def __init__(self, sample_rate=48000, curve=None, oversample=4,

tests/test_cpython_audioshaper.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
was asked for -- does oversampling actually lower the alias floor, does the
77
hysteresis option actually enclose an area and does that area grow with
88
drive, is the hold's ratio exact over a distance where a fixed-point one has
9-
visibly walked -- each with the control that would go red if the mechanism
10-
were absent.
9+
visibly walked, does a hard-clipping curve's own headroom knee sit where
10+
`CLIP_HEADROOM` says it does -- each with the control that would go red if
11+
the mechanism were absent.
1112
"""
1213

1314
import math
@@ -40,9 +41,23 @@ def cubic_curve():
4041
CUBIC = cubic_curve()
4142

4243

43-
def render(values, **options):
44+
def hard_clip_curve(threshold=0.10, points=2048):
45+
"""A straight hard clip: linear to the rails over +-`threshold` of the
46+
input span, flat beyond it -- unlike CUBIC, which never has a flat top
47+
at all. Driven hard, this is the shape audioif#99 is about."""
48+
last = points - 1
49+
return array("h", [
50+
clamp15(int(round(max(-1.0, min(1.0, (
51+
-1.0 + 2.0 * index / last) / threshold)) * 32767)))
52+
for index in range(points)])
53+
54+
55+
HARD_CLIP = hard_clip_curve()
56+
57+
58+
def render(values, curve=CUBIC, **options):
4459
"""Push one array of interleaved frames through a node and take it back."""
45-
node = audioshaper.Waveshaper(sample_rate=SAMPLE_RATE, curve=CUBIC,
60+
node = audioshaper.Waveshaper(sample_rate=SAMPLE_RATE, curve=curve,
4661
**options)
4762
node.play(audiocore.RawSample(
4863
values, sample_rate=SAMPLE_RATE,
@@ -210,6 +225,83 @@ def test_the_floor_falls_as_the_factor_rises(self):
210225
"x8 was worse than x4: %r" % (floors,))
211226

212227

228+
class HeadroomTest(unittest.TestCase):
229+
"""audioif#99: a hard-clipping curve driven hard rings past the rails
230+
once decimated, and `post_gain` re-clips that overshoot at the base
231+
rate -- after the oversampling is done, where no factor of it reaches.
232+
233+
Where this happens in the kernel (`src/shared/audioif_shaper.c`):
234+
`shape_sample` runs the curve at the oversampled rate and its own clamp
235+
(`curve_lookup`, clampf to +-1) bounds each of those samples, but the
236+
*decimated* one is not clamped there -- `halfband_down` is a low-pass,
237+
not a clip, so a hard edge through it can overshoot +-1. `post_gain`
238+
scales that decimated value (`audioif_shaper_process_s16`,
239+
`oversampled[0] * config->post_gain * 32768.0f`), and the only place
240+
this node ever clips to int16 is `to_s16`, two lines later, on
241+
`dry_gain * source + wet_gain * wet`. Everything from the curve to
242+
`post_gain` is `float` -- nothing here is int16 until that last line.
243+
244+
HARD_CLIP ramps to the rails over the inner 10% of its span and is flat
245+
beyond it; driven at 32000 (98% of full scale) it is pinned flat for
246+
most of every half-cycle, the edge the issue is about. Bare node,
247+
1010 Hz, 48 kHz, oversample x4; the window is 4800 samples -- exactly
248+
101 cycles of 1010 Hz, so the fundamental and every harmonic fall on
249+
their own bin with no window and no rounding.
250+
251+
Measured (`docs/upstream-diff.md`'s `audioshaper` section carries the
252+
full table): flat at -54.34 dB through `post_gain` 0.80, -41.24 at
253+
0.90, -36.44 at 1.00 -- a 13.1 dB fall by 0.90 that the bars below ask
254+
10 of, for margin. The control is oversample x1: no half-band, no
255+
ringing, and the floor reads -34.92 dB at every `post_gain` from 0.66
256+
to 1.00 -- the same curve and the same drive, showing no knee at all,
257+
which is what proves the x4 knee is the decimator's and not the
258+
curve's.
259+
"""
260+
261+
HZ = 1010
262+
FRAMES = 4800 # 48000 / 1010 * 101 == 4800 exactly: bin 101, no rounding
263+
CYCLES = HZ * FRAMES // SAMPLE_RATE
264+
LEVEL = 32000
265+
266+
def _floor(self, post_gain, oversample=4):
267+
return alias_floor_db(oversample, cycles=self.CYCLES,
268+
frames=self.FRAMES, level=self.LEVEL,
269+
curve=HARD_CLIP, post_gain=post_gain)
270+
271+
def test_the_floor_is_flat_below_the_knee(self):
272+
"""0.66 and 0.74 read the same node to within 1 dB: on this table,
273+
the knee has not been reached yet at either."""
274+
low = self._floor(0.66)
275+
at = self._floor(0.74)
276+
self.assertLess(abs(at - low), 1.0,
277+
"0.66 and 0.74 already differ: %.3f vs %.3f dB"
278+
% (low, at))
279+
280+
def test_the_floor_falls_past_the_knee(self):
281+
"""0.90 is well past it. `CLIP_HEADROOM`'s ~0.74 ceiling has to be
282+
buying real room, not a fraction of a dB, or the constant is
283+
theatre -- measured here it is 13.1 dB; the bar asks 10, leaving
284+
margin rather than pinning the exact figure."""
285+
at = self._floor(0.74)
286+
past = self._floor(0.90)
287+
self.assertLess(at, past - 10.0,
288+
"0.90 was not at least 10 dB worse than 0.74: "
289+
"%.3f vs %.3f dB" % (at, past))
290+
291+
def test_the_knee_is_the_decimators_not_the_curves(self):
292+
"""Its control. At oversample x1 there is no half-band to ring, so
293+
the same curve and the same drive must show no knee at all -- if
294+
this one went red, the "knee" above would be the curve clipping on
295+
its own, not the decimator, and `CLIP_HEADROOM` would be the wrong
296+
fix."""
297+
floors = [self._floor(post, oversample=1)
298+
for post in (0.66, 0.70, 0.74, 0.78, 0.80, 0.90, 1.00)]
299+
self.assertLess(max(floors) - min(floors), 0.5,
300+
"the un-oversampled curve already shows a knee, so "
301+
"x4's knee is not the decimator's doing: %r"
302+
% (floors,))
303+
304+
213305
class HysteresisTest(unittest.TestCase):
214306
"""The option encloses an area, the area grows with drive, and off is off.
215307

0 commit comments

Comments
 (0)