Skip to content

Waveshaper: document the clipper's own headroom knee, with a trait test (#99) - #100

Merged
bdbarnett merged 1 commit into
mainfrom
fix/waveshaper-headroom
Sep 18, 2026
Merged

bdbarnett merged 1 commit into
mainfrom
fix/waveshaper-headroom

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

Closes #99. Documentation and a trait test only, no DSP change: the decimated sample is scaled by post_gain after the half-band and clipped once to int16, so a curve that reaches the rails re-clips its own overshoot above about 0.74–0.80 of full scale. Measured table on the twin in docs/upstream-diff.md (x4: flat to 0.80, −41.2 at 0.90, −36.4 at 1.00; x1 control −34.9 throughout). CLIP_HEADROOM = 0.74 on the CPython twin, documentation only, the same footing as GROUP_DELAY_SAMPLES. 37 audioshaper traits OK; the new test fails at a 20 dB bar (quoted in the commit).

…M (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.
@bdbarnett
bdbarnett merged commit ec0f9e4 into main Sep 18, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Waveshaper: a hard-clipped curve overshoots the rails after the half-band, so post_gain above ~0.74 saturates the node's own output — undocumented

1 participant