FM kFrequency: max_deviation_hz needs a keytrack, because no constant is right across a range #65

Open
opened 2026-09-05 23:17:48 +00:00 by bcox · 2 comments
Owner

Patch::Ranges::max_deviation_hz is the full scale of the FM depth lane under
Patch::IndexLaw::kFrequency, and it defaults to 4000.0. That default was chosen
on paper. It has now been checked against a sound -- patches/chowning_horn.h is
the bank's first frequency-law patch -- and the conclusion is that the default
is fine and the parameterization is the problem.

Under this law the index is derived, I = d / fm. With d a constant in hertz
and fm rising with the note, the index falls as 1/fm: across chowning-horn's
declared C2-C5 that is a 16x span by construction. So:

  • At C3 with ratio 1, a musically ordinary index of 5 wants d about 654 Hz --
    16% of a 4000 Hz lane.
  • At C6 the same index wants about 5233 Hz -- 131% of it. The lane runs out.

Lowering 4000 makes the top worse; raising it makes the bottom worse. No constant
is right at both ends.

Why the obvious fixes are wrong. "The bottom of the lane is too coarse" does
not hold: the lane is Q1.30, one LSB is 3.7 uHz, and a timeline pixel is 0.18 of
index at C3. The useful settings are compressed into the bottom of the travel,
not hard to dial. And making the lane logarithmic would turn every authored
deviation ramp into an exponential index ramp -- exactly what a brass attack needs
it not to do, and most of what this law was adopted for.

The actual lever is a keytrack on the deviation: scale d by some power of
fc so the index holds where the author put it, with the exponent as the authored
quantity. Exponent 0 is today's behavior (constant hertz, index falls 16x across
four octaves); exponent 1 is the phase law (constant index); the interesting
instruments live between. That makes the two laws the ends of one continuum rather
than a switch, which is worth deciding before building it.

Not urgent: chowning-horn sounds right across its declared range as it is, and
max_deviation_hz is serialized at patch minor 31, so changing the default is not
free. Filed so the reasoning is not re-derived.

Found while validating the 4000 Hz default on branch fm-followups.

Addendum, 2026-10-05

Three things, from reading the tree at f6aa8eba. Nothing built or tried.

1. Status against #34

The curves landed, so this no longer waits on them. ModOp::kExpScale is a * 2^(4 * param * b) (dsp/mod_ops.h:150-162), which is the exponential-in-key shape the keytrack needs. The travel half still stands: a scale-mode entry's factor is 1 + depth * (product - 1), clamped, so it only attenuates (dsp/mod_matrix.h:173-181), and the depth lane's ceiling is unchanged (kLaneReachCeiling, dsp/fm_pair_layer.h:1307). The one matrix route today is to author the lane for the top of the range and attenuate downward with kExpScale on the note source. Untried; the note source's pivot at key 60 and the op's four-octave span need checking against a real range before it counts as a workaround.

2. The same need under the phase law: keyboard level scaling with a break point

The row this ticket is linked from in moregaps.md's feature table is "level scaled along the keyboard, with a break point or curve", and that is wider than a keytrack on the frequency law's deviation:

  • The DX7's per-operator keyboard level scaling -- a break point, a depth and a curve on each side -- is dropped by the importer ("keyboard level scaling dropped: the FM pair has no per-key level curve", dx7/translate.cc:680-683), as is rate scaling (:684-687).
  • For a modulator operator it is this ticket's keytrack with a break point and two curves; for a carrier it is the same shape on the amplitude lane; and it applies under kPhase as much as under kFrequency, so the fix is not specific to the frequency law.
  • The CS-80's keyboard control (brilliance and level shaped separately below and above mid-keyboard) and the K4's and ESQ-1's key-scaling curves are the same feature on other instruments.

Whether #34's ops can express a break point with a different curve on each side (a kMax or kMin against a constant, then a curve) has not been tried, and the exponent field proposed above would not cover a break point either. A design here should take the break point in.

3. Two corrections to the text above

  • C2 to C5 is three octaves, so the index span is 8x, not 16x. patches/chowning_horn.h's own header agrees: 16.5 at C2 to 2.1 at C5.
  • "Exponent 1 is the phase law" holds for the scalar index only. The two laws also differ in the per-partial 1/m integrator (kIntegratorResponse, dsp/fm_pair_layer.h:2707-2717), so exponent 1 equals kPhase only for a sine modulator. The enum selects the spectral axis and the exponent the pitch axis; they are orthogonal, not two ends of one line. That is also why the IndexLaw switch does not close this ticket.
`Patch::Ranges::max_deviation_hz` is the full scale of the FM depth lane under `Patch::IndexLaw::kFrequency`, and it defaults to 4000.0. That default was chosen on paper. It has now been checked against a sound -- `patches/chowning_horn.h` is the bank's first frequency-law patch -- and the conclusion is that **the default is fine and the parameterization is the problem.** Under this law the index is derived, `I = d / fm`. With `d` a constant in hertz and `fm` rising with the note, the index falls as `1/fm`: across chowning-horn's declared C2-C5 that is a 16x span by construction. So: - At C3 with ratio 1, a musically ordinary index of 5 wants `d` about 654 Hz -- 16% of a 4000 Hz lane. - At C6 the same index wants about 5233 Hz -- 131% of it. The lane runs out. Lowering 4000 makes the top worse; raising it makes the bottom worse. No constant is right at both ends. **Why the obvious fixes are wrong.** "The bottom of the lane is too coarse" does not hold: the lane is Q1.30, one LSB is 3.7 uHz, and a timeline pixel is 0.18 of index at C3. The useful settings are compressed into the bottom of the travel, not hard to dial. And making the lane logarithmic would turn every authored deviation ramp into an exponential index ramp -- exactly what a brass attack needs it *not* to do, and most of what this law was adopted for. **The actual lever is a keytrack on the deviation**: scale `d` by some power of `fc` so the index holds where the author put it, with the exponent as the authored quantity. Exponent 0 is today's behavior (constant hertz, index falls 16x across four octaves); exponent 1 is the phase law (constant index); the interesting instruments live between. That makes the two laws the ends of one continuum rather than a switch, which is worth deciding before building it. Not urgent: chowning-horn sounds right across its declared range as it is, and `max_deviation_hz` is serialized at patch minor 31, so changing the default is not free. Filed so the reasoning is not re-derived. Found while validating the 4000 Hz default on branch `fm-followups`. ## Addendum, 2026-10-05 Three things, from reading the tree at `f6aa8eba`. Nothing built or tried. ### 1. Status against #34 The curves landed, so this no longer waits on them. `ModOp::kExpScale` is `a * 2^(4 * param * b)` (`dsp/mod_ops.h:150-162`), which is the exponential-in-key shape the keytrack needs. The travel half still stands: a scale-mode entry's factor is `1 + depth * (product - 1)`, clamped, so it only attenuates (`dsp/mod_matrix.h:173-181`), and the depth lane's ceiling is unchanged (`kLaneReachCeiling`, `dsp/fm_pair_layer.h:1307`). The one matrix route today is to author the lane for the top of the range and attenuate downward with `kExpScale` on the note source. Untried; the note source's pivot at key 60 and the op's four-octave span need checking against a real range before it counts as a workaround. ### 2. The same need under the phase law: keyboard level scaling with a break point The row this ticket is linked from in `moregaps.md`'s feature table is "level scaled along the keyboard, with a break point or curve", and that is wider than a keytrack on the frequency law's deviation: - The DX7's per-operator keyboard level scaling -- a break point, a depth and a curve on each side -- is dropped by the importer ("keyboard level scaling dropped: the FM pair has no per-key level curve", `dx7/translate.cc:680-683`), as is rate scaling (`:684-687`). - For a modulator operator it is this ticket's keytrack with a break point and two curves; for a carrier it is the same shape on the amplitude lane; and it applies under `kPhase` as much as under `kFrequency`, so the fix is not specific to the frequency law. - The CS-80's keyboard control (brilliance and level shaped separately below and above mid-keyboard) and the K4's and ESQ-1's key-scaling curves are the same feature on other instruments. Whether #34's ops can express a break point with a different curve on each side (a `kMax` or `kMin` against a constant, then a curve) has not been tried, and the exponent field proposed above would not cover a break point either. A design here should take the break point in. ### 3. Two corrections to the text above - C2 to C5 is three octaves, so the index span is 8x, not 16x. `patches/chowning_horn.h`'s own header agrees: 16.5 at C2 to 2.1 at C5. - "Exponent 1 is the phase law" holds for the scalar index only. The two laws also differ in the per-partial 1/m integrator (`kIntegratorResponse`, `dsp/fm_pair_layer.h:2707-2717`), so exponent 1 equals `kPhase` only for a sine modulator. The enum selects the spectral axis and the exponent the pitch axis; they are orthogonal, not two ends of one line. That is also why the `IndexLaw` switch does not close this ticket.
Author
Owner

Deferred: this waits on log/lin/exp curves for the mod matrix, so that the right home for the lever gets decided rather than assumed.

Why wait. A curve on kSharedNote would make the note source exponential in key, which is the shape a keytrack needs. If that lands, a matrix entry may be the right place for this rather than a seventh scalar in Ranges on one engine.

Curves are necessary but not sufficient, which is the part worth not re-deriving. Two independent blockers, measured:

  1. Shape. kSharedNote is clamp((key - 60)/64, ±1) -- linear in key (dsp/layer.h:353). Constant index needs d proportional to fc, exponential in key. An endpoint-matched straight line across chowning-horn's three octaves overshoots by 1.67x, +4.4 dB of index. Curves fix this.

  2. Travel. The depth lane's hard ceiling is kLaneReachCeiling = Sample::kMaxRaw ~= 2.0 (dsp/fm_pair_layer.h:1337). Constant index over three octaves needs 8x. Two times of travel buys an exponent of exactly 1/3, and less in practice because the envelope contour needs the lane too. Curves do not fix this.

ModMatrix::Apply is (base + offset) * scale. kAdd reaches the 2x ceiling but only by adding a constant, which shifts the authored contour rather than scaling it. kMul has the right shape, but its factor is 1 + depth*(product - 1) with product - 1 in [-2, 0], so it is always <= 1 -- it can only attenuate (dsp/mod_matrix.h:148). Neither mode multiplies a lane above what the author wrote.

So the curve design is where to decide whether it also raises the lane ceiling or adds an above-unity multiply mode. If curves ship as shape-only, this issue comes back needing the Ranges field after all.

Two corrections to the text above.

  • "across chowning-horn's declared C2-C5 that is a 16x span by construction" -- C2-C5 is three octaves, so 8x. patches/chowning_horn.h's own header agrees: an index of 16.5 at C2 falling to 2.1 at C5, a factor of 7.9. The rest of the arithmetic holds (654 Hz at C3, 5233 Hz at C6, 131% of the lane).
  • "exponent 1 is the phase law (constant index)" -- true of the scalar index, not of the timbre. The two laws also differ in a per-partial 1/m integrator (kIntegratorResponse, applied at dsp/fm_pair_layer.h:3776), which is pitch-independent and survives any exponent. Exponent 1 equals kPhase only for a sine modulator. So the enum selects the spectral axis and the exponent the pitch axis: orthogonal, rather than two ends of one continuum. A keytrack applied under both laws would give that continuum properly -- kPhase at exponent -1 reproduces kFrequency at 0 -- at the cost of making every existing kPhase patch's bandwidth demand key-dependent, which re-opens the alias grid.

One note for whoever picks this up, in whatever form it takes: RequiredBandwidthRatio has to see the same factor the render path does. dsp/fm_pair_layer.h:1863-1878 documents that kFrequency's bandwidth demand is pitch-invariant precisely because fm divides out of beta * edge. Scaling d by the key breaks that invariance by construction, so a keytrack applied at note-on but not in the derivation under-provisions oversampling up the keyboard. plugin/fm_alias_test.cc is what would catch it.

Deferred: this waits on log/lin/exp curves for the mod matrix, so that the right home for the lever gets decided rather than assumed. **Why wait.** A curve on `kSharedNote` would make the note source exponential in key, which is the shape a keytrack needs. If that lands, a matrix entry may be the right place for this rather than a seventh scalar in `Ranges` on one engine. **Curves are necessary but not sufficient**, which is the part worth not re-deriving. Two independent blockers, measured: 1. **Shape.** `kSharedNote` is `clamp((key - 60)/64, ±1)` -- linear in key (`dsp/layer.h:353`). Constant index needs `d` proportional to `fc`, exponential in key. An endpoint-matched straight line across chowning-horn's three octaves overshoots by 1.67x, +4.4 dB of index. Curves fix this. 2. **Travel.** The depth lane's hard ceiling is `kLaneReachCeiling` = `Sample::kMaxRaw` ~= 2.0 (`dsp/fm_pair_layer.h:1337`). Constant index over three octaves needs 8x. Two times of travel buys an exponent of exactly 1/3, and less in practice because the envelope contour needs the lane too. Curves do not fix this. `ModMatrix::Apply` is `(base + offset) * scale`. `kAdd` reaches the 2x ceiling but only by adding a constant, which shifts the authored contour rather than scaling it. `kMul` has the right shape, but its factor is `1 + depth*(product - 1)` with `product - 1` in `[-2, 0]`, so it is always <= 1 -- it can only attenuate (`dsp/mod_matrix.h:148`). Neither mode multiplies a lane above what the author wrote. So the curve design is where to decide whether it also raises the lane ceiling or adds an above-unity multiply mode. If curves ship as shape-only, this issue comes back needing the `Ranges` field after all. **Two corrections to the text above.** - "across chowning-horn's declared C2-C5 that is a 16x span by construction" -- C2-C5 is three octaves, so 8x. `patches/chowning_horn.h`'s own header agrees: an index of 16.5 at C2 falling to 2.1 at C5, a factor of 7.9. The rest of the arithmetic holds (654 Hz at C3, 5233 Hz at C6, 131% of the lane). - "exponent 1 is the phase law (constant index)" -- true of the scalar index, not of the timbre. The two laws also differ in a per-partial 1/m integrator (`kIntegratorResponse`, applied at `dsp/fm_pair_layer.h:3776`), which is pitch-independent and survives any exponent. Exponent 1 equals kPhase only for a sine modulator. So the enum selects the spectral axis and the exponent the pitch axis: orthogonal, rather than two ends of one continuum. A keytrack applied under *both* laws would give that continuum properly -- kPhase at exponent -1 reproduces kFrequency at 0 -- at the cost of making every existing kPhase patch's bandwidth demand key-dependent, which re-opens the alias grid. One note for whoever picks this up, in whatever form it takes: `RequiredBandwidthRatio` has to see the same factor the render path does. `dsp/fm_pair_layer.h:1863-1878` documents that kFrequency's bandwidth demand is pitch-invariant precisely because `fm` divides out of `beta * edge`. Scaling `d` by the key breaks that invariance by construction, so a keytrack applied at note-on but not in the derivation under-provisions oversampling up the keyboard. `plugin/fm_alias_test.cc` is what would catch it.
Author
Owner

When mod-matrix curves land, update the DX7 importer (dx7/translate.cc, branch dx7-import) to use them. It currently drops or linearizes the DX7's scaling, each reported in its loss report:

  • Keyboard level scaling (breakpoint, L/R depth, -LIN/-EXP/+EXP/+LIN curves): dropped.
  • Keyboard rate scaling: dropped. It also needs envelope time as a modulation destination; check whether the engine has one.
  • Key velocity sensitivity: a linear sens/7 route rather than Dexed's ScaleVelocity table.
  • Amp mod sensitivity: a linear LFO route rather than the DX7's curve.

These should become curve-shaped routes from note number and velocity, the same capability this issue waits on.

When mod-matrix curves land, update the DX7 importer (`dx7/translate.cc`, branch `dx7-import`) to use them. It currently drops or linearizes the DX7's scaling, each reported in its loss report: - **Keyboard level scaling** (breakpoint, L/R depth, -LIN/-EXP/+EXP/+LIN curves): dropped. - **Keyboard rate scaling**: dropped. It also needs envelope time as a modulation destination; check whether the engine has one. - **Key velocity sensitivity**: a linear `sens/7` route rather than Dexed's `ScaleVelocity` table. - **Amp mod sensitivity**: a linear LFO route rather than the DX7's curve. These should become curve-shaped routes from note number and velocity, the same capability this issue waits on.
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#65
No description provided.