The FM pair's fixed 8x oversampling aliases audibly at high depth, ratio and pitch -- and is waste everywhere else #24

Closed
opened 2026-08-25 17:56:23 +00:00 by bcox · 1 comment
Owner

dsp/fm_pair_layer.h runs at a fixed kOversample = 8 with a 513-tap
decimator (plugin/instrument.h:166 and :188). That single number is
simultaneously too small for part of the parameter space and far too large for
most of it.

Found while spiking ADAA as a replacement for the oversampling. ADAA lost
decisively and is not proposed here; this is what the spike found on the way.

The defect

At depth = 8.0 cycles and ratio = 16 — both lanes exactly at the maxima
FmPairLayer::Ranges defines — the shipped engine emits alias louder than
the signal
. Measured from playground::Instrument itself, not from a model
of it: one voice, wave position 0, no formant band, no second modulator. The
figure is the strongest in-band component that is not on the fc + k*fm
lattice, relative to the strongest component that is, judged over
100 Hz - 18 kHz.

note fc shipped 8x same source rebuilt at 16x
C3 131 Hz -93.1 dB -93.1 dB
C4 262 Hz -93.5 dB -93.5 dB
F#4 370 Hz -48.1 dB -93.0 dB
A4 440 Hz -1.1 dB -93.6 dB
C5 523 Hz +6.8 dB -93.1 dB
C6 1047 Hz +7.2 dB +7.2 dB

-93 dB is the measurement floor, not the engine's. The only difference between
those two columns is kOversample 8 -> 16 and kDecimatorTaps 513 -> 1025, so
the cause is the ratio and not lane clipping or quantisation.

Note that nothing about the patch changes down that column. Only the note
does. Depth and ratio are held at maximum throughout.

Why

Alias becomes audible when sidebands fold below the decimator's stopband edge,
which is at 0.5417 of the output rate (26 kHz) at every ratio. Content above
the internal Nyquist folds to 48000*R - f, so the condition is significant
sideband energy above 48000*R - 26000 Hz.

With fm = ratio * fc, beta = 2*pi*depth_cycles and the usual
k_max ~= beta + 2.5*beta^(1/3), the ratio a patch needs is

R > (k_max * fm + 26000) / 48000

fc is a direct multiplier, which is why this is a per-note property and not a
per-patch one.

That rule reproduces every cell measured, including the marginal one: ratio 16
at depth 6 and C5 predicts 16x, and the shipped 8x measures -22.3 dB there.
It is a design tool, not yet a validated derivation.

The other half: 8x is mostly waste

ratio ns/sample cores realtime
2x + 129 3910 0.19 x5.3
4x + 257 5642 0.27 x3.7
8x + 513 (shipped) 9600 0.46 x2.2
16x + 1025 17474 0.84 x1.2
32x + 2049 31878 1.53 x0.7

Eight voices at C2, one second of audio. Applying the rule above to real
settings:

patch needs
ratio 2, depth 2, C4 — an ordinary FM patch 1x, no decimator at all
ratio 4, depth 8, C5 4x
ratio 8, depth 8, C5 8x, what ships
ratio 16, depth 8, C5 16x
ratio 16, depth 8, C6 32x

Proposal

Derive the FM pair's ratio from the patch and the note, the way
VaLayer::DerivedOversampleRatio already derives the VA layer's from the
patch. Same mechanism, already proven in this tree, and it fixes both halves
at once: the corner rises, and ordinary patches fall below what ships.

Two differences from the VA layer's version, both worth deciding before
building:

  • VaLayer::DerivedOversampleRatio takes const Patch& and nothing else. This
    one needs the key too. That is fine mechanically — both engines decide at
    note-on and hold for the voice's life — but two simultaneously sounding
    voices of the same patch can then run at different ratios, so the latency
    split has to hold across them. kAlignmentDelay already solves exactly this
    for the VA layer by keeping both the decimator and the plain delay line
    resident.
  • Pitch can move after note-on. Deriving from the note-on pitch alone
    under-provisions a voice bent upward; deriving from the highest pitch the
    patch can reach and rounding up is the conservative form, matching how
    MinimumRatio already rounds up for the shaper.

What oversampling cannot fix

The C6 corner needs 32x, which is 1.53 cores for eight voices — below realtime.
That case needs a decision rather than a ratio:

  1. Run it at 32x and let the voice count collapse (the degradation ladder,
    applied to ratio rather than partials).
  2. Clamp the reachable depth * ratio * pitch product so the corner cannot be
    selected.
  3. Accept aliasing there.

Today's behaviour is (3), without having chosen it.

At C7 with ratio 16, moving depth from zero to maximum changes nothing at all:
total energy and carrier level both hold at -6.0 dB. fm is 33.5 kHz, above
the output Nyquist, so the additive oscillator never generates the modulator's
only partial and the FM silently disappears. That is the band-limiting doing
what it says, and it is an abrupt version of option 2 above — but as a played
result it is a cliff near the top of the keyboard at high ratios.

Reproducing

The probes were throwaway and live outside the tree. They overlay a modified
plugin/instrument.h on the include path to vary kOversample without
touching the worktree, and measure alias against an analytic reference
(sin(2*pi*fc*t + beta*sin(2*pi*fm*t)) = sum_k J_k(beta) sin(2*pi*(fc+k*fm)t))
rather than against another renderer. Happy to reconstruct them if this gets
picked up.

`dsp/fm_pair_layer.h` runs at a fixed `kOversample = 8` with a 513-tap decimator (`plugin/instrument.h:166` and `:188`). That single number is simultaneously too small for part of the parameter space and far too large for most of it. Found while spiking ADAA as a replacement for the oversampling. ADAA lost decisively and is not proposed here; this is what the spike found on the way. ## The defect At `depth = 8.0 cycles` and `ratio = 16` — both lanes exactly at the maxima `FmPairLayer::Ranges` defines — the shipped engine emits alias **louder than the signal**. Measured from `playground::Instrument` itself, not from a model of it: one voice, wave position 0, no formant band, no second modulator. The figure is the strongest in-band component that is not on the `fc + k*fm` lattice, relative to the strongest component that is, judged over 100 Hz - 18 kHz. | note | fc | shipped 8x | same source rebuilt at 16x | |---|---|---|---| | C3 | 131 Hz | -93.1 dB | -93.1 dB | | C4 | 262 Hz | -93.5 dB | -93.5 dB | | F#4 | 370 Hz | -48.1 dB | -93.0 dB | | A4 | 440 Hz | -1.1 dB | -93.6 dB | | C5 | 523 Hz | **+6.8 dB** | -93.1 dB | | C6 | 1047 Hz | **+7.2 dB** | **+7.2 dB** | -93 dB is the measurement floor, not the engine's. The only difference between those two columns is `kOversample` 8 -> 16 and `kDecimatorTaps` 513 -> 1025, so the cause is the ratio and not lane clipping or quantisation. Note that nothing about the *patch* changes down that column. Only the note does. Depth and ratio are held at maximum throughout. ## Why Alias becomes audible when sidebands fold below the decimator's stopband edge, which is at 0.5417 of the output rate (26 kHz) at every ratio. Content above the internal Nyquist folds to `48000*R - f`, so the condition is significant sideband energy above `48000*R - 26000` Hz. With `fm = ratio * fc`, `beta = 2*pi*depth_cycles` and the usual `k_max ~= beta + 2.5*beta^(1/3)`, the ratio a patch needs is R > (k_max * fm + 26000) / 48000 `fc` is a direct multiplier, which is why this is a per-note property and not a per-patch one. That rule reproduces every cell measured, including the marginal one: ratio 16 at depth 6 and C5 predicts 16x, and the shipped 8x measures -22.3 dB there. It is a design tool, not yet a validated derivation. ## The other half: 8x is mostly waste | ratio | ns/sample | cores | realtime | |---|---|---|---| | 2x + 129 | 3910 | 0.19 | x5.3 | | 4x + 257 | 5642 | 0.27 | x3.7 | | **8x + 513 (shipped)** | **9600** | **0.46** | **x2.2** | | 16x + 1025 | 17474 | 0.84 | x1.2 | | 32x + 2049 | 31878 | 1.53 | x0.7 | Eight voices at C2, one second of audio. Applying the rule above to real settings: | patch | needs | |---|---| | ratio 2, depth 2, C4 — an ordinary FM patch | 1x, no decimator at all | | ratio 4, depth 8, C5 | 4x | | ratio 8, depth 8, C5 | 8x, what ships | | ratio 16, depth 8, C5 | 16x | | ratio 16, depth 8, C6 | 32x | ## Proposal Derive the FM pair's ratio from the patch **and the note**, the way `VaLayer::DerivedOversampleRatio` already derives the VA layer's from the patch. Same mechanism, already proven in this tree, and it fixes both halves at once: the corner rises, and ordinary patches fall below what ships. Two differences from the VA layer's version, both worth deciding before building: - `VaLayer::DerivedOversampleRatio` takes `const Patch&` and nothing else. This one needs the key too. That is fine mechanically — both engines decide at note-on and hold for the voice's life — but two simultaneously sounding voices of the same patch can then run at different ratios, so the latency split has to hold across them. `kAlignmentDelay` already solves exactly this for the VA layer by keeping both the decimator and the plain delay line resident. - Pitch can move after note-on. Deriving from the note-on pitch alone under-provisions a voice bent upward; deriving from the highest pitch the patch can reach and rounding up is the conservative form, matching how `MinimumRatio` already rounds up for the shaper. ## What oversampling cannot fix The C6 corner needs 32x, which is 1.53 cores for eight voices — below realtime. That case needs a decision rather than a ratio: 1. Run it at 32x and let the voice count collapse (the degradation ladder, applied to ratio rather than partials). 2. Clamp the reachable `depth * ratio * pitch` product so the corner cannot be selected. 3. Accept aliasing there. Today's behaviour is (3), without having chosen it. ## Related, found alongside At C7 with ratio 16, moving depth from zero to maximum changes nothing at all: total energy and carrier level both hold at -6.0 dB. `fm` is 33.5 kHz, above the output Nyquist, so the additive oscillator never generates the modulator's only partial and the FM silently disappears. That is the band-limiting doing what it says, and it is an abrupt version of option 2 above — but as a played result it is a cliff near the top of the keyboard at high ratios. ## Reproducing The probes were throwaway and live outside the tree. They overlay a modified `plugin/instrument.h` on the include path to vary `kOversample` without touching the worktree, and measure alias against an analytic reference (`sin(2*pi*fc*t + beta*sin(2*pi*fm*t)) = sum_k J_k(beta) sin(2*pi*(fc+k*fm)t)`) rather than against another renderer. Happy to reconstruct them if this gets picked up.
bcox closed this issue 2026-08-28 13:56:53 +00:00
Author
Owner

Fixed in 57f6860 on main.

The ratio is derived per note over {1, 2, 4, 8, 16} now, from a
reachable-interval walk over kFmDepth, kFmRatio, kPitch and their
second-modulator twins. Design in
docs/superpowers/specs/2026-08-27-fm-derived-oversampling-design.md.

The two open decisions, as decided

The ceiling is 16, so the C6 corner is option 3 -- chosen this time. 32x
measures 1.53 cores for eight voices, below realtime, so provisioning it
correctly would provision a case that cannot then be played polyphonically,
at 2049 taps of resident history in every layer of every voice. That corner
still aliases. The difference from the fixed 8x is that
FmAlias.TheCappedCornerStillAliases now asserts it, so the day the ceiling
moves, a test says so rather than the corner quietly going quiet.

Pitch is read as the note's upward reach, not the note-on pitch. Reading
downward reach would under-provision the pitch a note actually starts on.

The rule is validated now, not just reproduced

plugin/fm_alias_test.cc measures the strongest off-lattice component against
the strongest on-lattice one, through playground::Instrument rather than
against a model of it, and asserts in both directions -- at the derived
ratio the alias stays down, and at a ratio forced one family step lower the
corner cells reproduce the failures measured here. A one-sided test would pass
just as happily against a measurement that had gone blind.

Two things that measurement settled:

  • The -93 dB floor in the report above was the analysis window, not the
    engine. Every provisioned cell reads -92.8 to -94.1 dB, which is exactly
    where a four-term Blackman-Harris sidelobe floor sits.
  • The capped C6 corner measures +7.30 dB against the +7.2 measured here,
    which is about as close as two independent measurements of that corner get.

Two defects in the measurement design were found by building it. The lattice
has to be |fc + k*fm|: for k negative enough the sideband reflects to
positive frequency, where it is genuine signal roughly as loud as the carrier,
and a one-sided classifier scores it as the worst artifact. And the guard band
is 5 bins, not 3 -- Blackman-Harris's main lobe is eight bins wide.

The other half

case derives was change
Init()'s default patch, C4 1 8 8x cheaper -- measured
ratio 2, depth 2, C4 1 8 8x cheaper
ratio 16, depth 8, at C2 2 8 4x cheaper
ratio 4, depth 8, C5 4 8 2x cheaper
ratio 8, depth 8, C5 8 8 same
ratio 16, depth 8, C5 16 8 2x dearer -- measured 1.7x
ratio 16, depth 8, C6 32 -> 16 8 2x dearer, still aliases

Measured at -c opt against the same benches built on main: the default
patch is 3.3x faster, and the ratio-16/depth-8/C5 corner 1.7x slower -- not
the 2x the arithmetic predicted, because fixed per-sample cost outside the
oversampled loop dilutes the multiple in both directions.

kargyraa is unchanged at 816 vs 834 ns/sample, with per-side spread of 6%
and 10% against a 2.2% median gap. It names no modulator table, so it takes
the no-modulator branch, which this change does not touch -- the 27x from
5f1bb9a survives by construction rather than by luck.

Every row that got dearer is a row measured here emitting alias at or above
the signal. Nothing that was correct before got more expensive.

One defect found on the way that was not in this report

ReachableLaneMaximum clamped a lane's reach to 1.0, on the reasoning that
"the span is where the engine's own scaling saturates". That is false for
kFmDepth and kFmRatio: ModMatrix::Apply returns a Sample, Q1_30
saturates at 1.999999999069, and neither ScaleRatio nor ToPhaseOffset
clamps. So a near-full authored depth plus an additive velocity -> depth
entry -- the commonest route in FM there is -- rendered at up to twice the
provisioned depth, and the derived ratio was up to two rungs short.

Measured before the fix, through the same alias harness: -0.04, +6.05, +6.22,
-8.77 and +7.47 dB against a -60 dB bar. That is this issue's own defect,
reached by a different door, and the derivation would have shipped
under-provisioning it. The clamp now sits at the ceiling the matrix can
actually reach, on all five lanes. Cost: crystal-bell alone, at seven keys,
none of them its demo key.

#32 -- both modulators are band-limited to the output Nyquist, so FM
vanishes entirely above G6 at ratio 16. Orthogonal to this: harmonic_limit_
is the output Nyquist at every ratio, so deriving 16x does not bring the
modulator back. Split out so a moved golden is attributable to one change
rather than to both.

Fixed in `57f6860` on `main`. The ratio is derived per note over `{1, 2, 4, 8, 16}` now, from a reachable-interval walk over `kFmDepth`, `kFmRatio`, `kPitch` and their second-modulator twins. Design in `docs/superpowers/specs/2026-08-27-fm-derived-oversampling-design.md`. ## The two open decisions, as decided **The ceiling is 16, so the C6 corner is option 3 -- chosen this time.** 32x measures 1.53 cores for eight voices, below realtime, so provisioning it correctly would provision a case that cannot then be played polyphonically, at 2049 taps of resident history in every layer of every voice. That corner still aliases. The difference from the fixed 8x is that `FmAlias.TheCappedCornerStillAliases` now asserts it, so the day the ceiling moves, a test says so rather than the corner quietly going quiet. **Pitch is read as the note's upward reach, not the note-on pitch.** Reading downward reach would under-provision the pitch a note actually starts on. ## The rule is validated now, not just reproduced `plugin/fm_alias_test.cc` measures the strongest off-lattice component against the strongest on-lattice one, through `playground::Instrument` rather than against a model of it, and asserts in **both** directions -- at the derived ratio the alias stays down, and at a ratio forced one family step lower the corner cells reproduce the failures measured here. A one-sided test would pass just as happily against a measurement that had gone blind. Two things that measurement settled: - The `-93 dB` floor in the report above **was the analysis window**, not the engine. Every provisioned cell reads -92.8 to -94.1 dB, which is exactly where a four-term Blackman-Harris sidelobe floor sits. - The capped C6 corner measures **+7.30 dB** against the `+7.2` measured here, which is about as close as two independent measurements of that corner get. Two defects in the measurement design were found by building it. The lattice has to be `|fc + k*fm|`: for `k` negative enough the sideband reflects to positive frequency, where it is genuine signal roughly as loud as the carrier, and a one-sided classifier scores it as the worst artifact. And the guard band is 5 bins, not 3 -- Blackman-Harris's main lobe is eight bins wide. ## The other half | case | derives | was | change | |---|---|---|---| | `Init()`'s default patch, C4 | 1 | 8 | 8x cheaper -- **measured** | | ratio 2, depth 2, C4 | 1 | 8 | 8x cheaper | | ratio 16, depth 8, at C2 | 2 | 8 | 4x cheaper | | ratio 4, depth 8, C5 | 4 | 8 | 2x cheaper | | ratio 8, depth 8, C5 | 8 | 8 | same | | ratio 16, depth 8, C5 | 16 | 8 | 2x dearer -- **measured 1.7x** | | ratio 16, depth 8, C6 | 32 -> **16** | 8 | 2x dearer, still aliases | Measured at `-c opt` against the same benches built on `main`: the default patch is 3.3x faster, and the ratio-16/depth-8/C5 corner 1.7x slower -- not the 2x the arithmetic predicted, because fixed per-sample cost outside the oversampled loop dilutes the multiple in both directions. `kargyraa` is unchanged at 816 vs 834 ns/sample, with per-side spread of 6% and 10% against a 2.2% median gap. It names no modulator table, so it takes the no-modulator branch, which this change does not touch -- the 27x from `5f1bb9a` survives by construction rather than by luck. Every row that got dearer is a row measured here emitting alias at or above the signal. Nothing that was correct before got more expensive. ## One defect found on the way that was not in this report `ReachableLaneMaximum` clamped a lane's reach to 1.0, on the reasoning that "the span is where the engine's own scaling saturates". That is false for `kFmDepth` and `kFmRatio`: `ModMatrix::Apply` returns a `Sample`, `Q1_30` saturates at 1.999999999069, and neither `ScaleRatio` nor `ToPhaseOffset` clamps. So a near-full authored depth plus an additive `velocity -> depth` entry -- the commonest route in FM there is -- rendered at up to twice the provisioned depth, and the derived ratio was up to two rungs short. Measured before the fix, through the same alias harness: -0.04, +6.05, +6.22, -8.77 and +7.47 dB against a -60 dB bar. That is this issue's own defect, reached by a different door, and the derivation would have shipped under-provisioning it. The clamp now sits at the ceiling the matrix can actually reach, on all five lanes. Cost: `crystal-bell` alone, at seven keys, none of them its demo key. ## Related, still open #32 -- both modulators are band-limited to the *output* Nyquist, so FM vanishes entirely above G6 at ratio 16. Orthogonal to this: `harmonic_limit_` is the output Nyquist at every ratio, so deriving 16x does not bring the modulator back. Split out so a moved golden is attributable to one change rather than to both.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
bcox/tymbal#24
No description provided.