The modulators are band-limited to the output Nyquist, so FM silently vanishes above G6 at ratio 16 #32

Closed
opened 2026-08-28 00:35:40 +00:00 by bcox · 0 comments
Owner

Both modulators in dsp/fm_pair_layer.h are band-limited to the output
Nyquist, the same limit the carrier gets. A modulator is never heard directly
-- only its sidebands are -- so that limit costs the pair its modulation
entirely once the modulator frequency crosses 24 kHz, and it does so at pitches
that are inside the playable range.

Found alongside #24 and split out from it. It is not an oversampling problem:
deriving a higher ratio for the note does not bring the modulator back, for the
reason below.

The defect

harmonic_limit_ is set at dsp/fm_pair_layer.h:1168:

harmonic_limit_ = kNyquistIncrement / static_cast<PhaseIncrement>(ratio_);

Dividing the internal Nyquist increment by the ratio gives the increment that
corresponds to 24 kHz at any ratio, so this is the output Nyquist expressed
in the internal domain. That is right for the carrier, and kHarmonicLimit's
comment says so outright: "Harmonics stop at the output rate's Nyquist. The
extra bandwidth exists for the modulation sidebands, not to widen the
instrument itself."

The same member is then handed to both modulators:

  • dsp/fm_pair_layer.h:1821 -- modulator 1
  • dsp/fm_pair_layer.h:1947 -- modulator 2

AdditiveOscillator::Render keeps harmonic k only while k * increment < limit, so a modulator whose fundamental is already at or above the limit
renders zero harmonics and outputs silence. Zero modulator is zero deviation,
and the pair degenerates to its carrier.

Because the limit tracks the output rate rather than the internal one, ratio_
cancels out of it. A note that derives 16x has exactly the same modulator
bandwidth as one that derives 1x.

Where it bites

The last modulator partial goes when ratio * fc >= 24000 (at 48 kHz), which
is key 69 + 12*log2(24000 / (440 * ratio)):

ratio modulator silent above first silent key
2 12000 Hz 126.2
4 6000 Hz 114.2
8 3000 Hz 102.2
16 1500 Hz 90.2 -- G6

At ratio 16 the cliff is at G6. That is not the top of the keyboard; it is in
the middle of it, and a patch held at ratio 16 stops responding to its depth
lane partway up a scale.

Measured for #24 at C7 with ratio 16: moving depth from zero to maximum changes
nothing at all, with total energy and carrier level both holding at -6.0 dBFS.
fm is 33.5 kHz there, which is the first row of the table applied to a note
well past its threshold.

A modulator spectrum with more than one partial thins progressively before it
cliffs -- the higher partials cross the limit first -- so the audible result is
a modulator that loses its brightness as pitch rises and then vanishes, rather
than a single clean discontinuity. Only the last partial's departure is abrupt.

Why it is not #24

#24 makes the ratio follow the patch and the note so the sidebands have room to
exist. This issue is upstream of that: the sidebands are never generated,
because the thing that would generate them was band-limited away first. The two
touch the same corner of the parameter space and have opposite signs -- #24's
corner is where there is too much modulator bandwidth to represent at 8x, and
this one is where there is none at all.

Proposal

Limit the modulators to the internal Nyquist and leave the carrier on the
output Nyquist. Concretely, a second member alongside harmonic_limit_ --
kNyquistIncrement unmodified, since the internal Nyquist is what a
PhaseIncrement is natively against -- passed at the two modulator call sites
while the three carrier sites keep the existing one.

That is band-limiting doing what it should for a signal that is not audio: the
modulator has to be representable at the rate it is computed at, and nothing
more. Whatever sidebands it then produces above the output Nyquist are the
decimator's problem, which is what the decimator is for, and #24's derivation is
what will size it.

What it costs

This changes what the instrument sounds like, not how accurately it renders
what it already sounds like, and the blast radius is the reason it is filed
separately rather than folded into #24:

  • Every golden with a modulator moves, because modulator partials that have
    always been discarded start contributing. plugin/render_golden_test.cc and
    plugin/wave_position_golden_test.cc both.
  • Patches authored against today's behaviour get brighter modulators at high
    pitch. Nobody has heard the difference yet, so there is no expectation to
    break, but it is a real timbral change and not a bug fix in the narrow sense.
  • It interacts with #24's derivation in the direction that costs polyphony: a
    modulator that now has partials where it had none needs the bandwidth to
    carry their sidebands, so #24's rule will derive higher ratios for exactly
    the notes this fixes. Landing #24 first means its measurements are taken
    against the modulator bandwidth this issue changes, so the derivation's
    validation grid will need re-running afterwards rather than merely extending.

Landing after #24, deliberately, so that a golden that moves can be attributed
to one of the two rather than to both at once.

Both modulators in `dsp/fm_pair_layer.h` are band-limited to the *output* Nyquist, the same limit the carrier gets. A modulator is never heard directly -- only its sidebands are -- so that limit costs the pair its modulation entirely once the modulator frequency crosses 24 kHz, and it does so at pitches that are inside the playable range. Found alongside #24 and split out from it. It is not an oversampling problem: deriving a higher ratio for the note does not bring the modulator back, for the reason below. ## The defect `harmonic_limit_` is set at `dsp/fm_pair_layer.h:1168`: harmonic_limit_ = kNyquistIncrement / static_cast<PhaseIncrement>(ratio_); Dividing the internal Nyquist increment by the ratio gives the increment that corresponds to 24 kHz at any ratio, so this is the *output* Nyquist expressed in the internal domain. That is right for the carrier, and `kHarmonicLimit`'s comment says so outright: "Harmonics stop at the output rate's Nyquist. The extra bandwidth exists for the modulation sidebands, not to widen the instrument itself." The same member is then handed to both modulators: - `dsp/fm_pair_layer.h:1821` -- modulator 1 - `dsp/fm_pair_layer.h:1947` -- modulator 2 `AdditiveOscillator::Render` keeps harmonic `k` only while `k * increment < limit`, so a modulator whose *fundamental* is already at or above the limit renders zero harmonics and outputs silence. Zero modulator is zero deviation, and the pair degenerates to its carrier. Because the limit tracks the output rate rather than the internal one, `ratio_` cancels out of it. A note that derives 16x has exactly the same modulator bandwidth as one that derives 1x. ## Where it bites The last modulator partial goes when `ratio * fc >= 24000` (at 48 kHz), which is key `69 + 12*log2(24000 / (440 * ratio))`: | ratio | modulator silent above | first silent key | |---|---|---| | 2 | 12000 Hz | 126.2 | | 4 | 6000 Hz | 114.2 | | 8 | 3000 Hz | 102.2 | | 16 | 1500 Hz | 90.2 -- G6 | At ratio 16 the cliff is at G6. That is not the top of the keyboard; it is in the middle of it, and a patch held at ratio 16 stops responding to its depth lane partway up a scale. Measured for #24 at C7 with ratio 16: moving depth from zero to maximum changes nothing at all, with total energy and carrier level both holding at -6.0 dBFS. `fm` is 33.5 kHz there, which is the first row of the table applied to a note well past its threshold. A modulator spectrum with more than one partial thins progressively before it cliffs -- the higher partials cross the limit first -- so the audible result is a modulator that loses its brightness as pitch rises and then vanishes, rather than a single clean discontinuity. Only the last partial's departure is abrupt. ## Why it is not #24 #24 makes the ratio follow the patch and the note so the sidebands have room to exist. This issue is upstream of that: the sidebands are never generated, because the thing that would generate them was band-limited away first. The two touch the same corner of the parameter space and have opposite signs -- #24's corner is where there is too *much* modulator bandwidth to represent at 8x, and this one is where there is none at all. ## Proposal Limit the modulators to the internal Nyquist and leave the carrier on the output Nyquist. Concretely, a second member alongside `harmonic_limit_` -- `kNyquistIncrement` unmodified, since the internal Nyquist is what a `PhaseIncrement` is natively against -- passed at the two modulator call sites while the three carrier sites keep the existing one. That is band-limiting doing what it should for a signal that is not audio: the modulator has to be representable at the rate it is computed at, and nothing more. Whatever sidebands it then produces above the output Nyquist are the decimator's problem, which is what the decimator is for, and #24's derivation is what will size it. ## What it costs This changes what the instrument sounds like, not how accurately it renders what it already sounds like, and the blast radius is the reason it is filed separately rather than folded into #24: - Every golden with a modulator moves, because modulator partials that have always been discarded start contributing. `plugin/render_golden_test.cc` and `plugin/wave_position_golden_test.cc` both. - Patches authored against today's behaviour get brighter modulators at high pitch. Nobody has heard the difference yet, so there is no expectation to break, but it is a real timbral change and not a bug fix in the narrow sense. - It interacts with #24's derivation in the direction that costs polyphony: a modulator that now has partials where it had none needs the bandwidth to carry their sidebands, so #24's rule will derive higher ratios for exactly the notes this fixes. Landing #24 first means its measurements are taken against the modulator bandwidth this issue changes, so the derivation's validation grid will need re-running afterwards rather than merely extending. Landing after #24, deliberately, so that a golden that moves can be attributed to one of the two rather than to both at once.
bcox closed this issue 2026-08-28 17:21:00 +00:00
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#32
No description provided.