feat: the frequency shifter, a ring modulator at sideband zero, over a Hilbert pair #148

Merged
bcox merged 10 commits from shifter into main 2026-10-06 02:42:43 +00:00
Owner

Adds a frequency shifter, with the ring modulator as one setting of it, as the twelfth layer kind: the next effect from #105. It does not close that issue.

What it is

  • dsp/hilbert.h: a Hilbert pair, two chains of eight one-multiply allpass sections whose outputs sit a quarter turn apart across the band. Its coefficient table, dsp/hilbert_coeffs.h, is generated: materials/hilbert.h holds the elliptic design in double, bazel run //tools:hilbert_gen > dsp/hilbert_coeffs.h rewrites it, and //materials:hilbert_test fails when the two disagree. It is a primitive of its own because the mono-to-stereo widener and barber-pole phasing both wait on one.
  • dsp/shifter_layer.h: a stereo single-sideband shifter over that pair. sideband runs from -1 (shift down) through 0 (both sidebands, which is ring modulation) to +1 (shift up). The carrier is linear in hertz and signed, so it sweeps through zero; it can follow the key, bend and glide included. It has its own dry path, as the phaser does, and the dry is the raw input, so a small shift at half mix gives notches that travel. mirror sends the two sides to opposite sidebands and stereo_phase offsets the right side's carrier.

Also in the branch: the resolver, format (SHFT, minor 2.9), patch-diff walker, editor page, and one factory patch, shifter-return (bank 62 to 63).

Where it departs from the design

The spec is brought to what was built; the ledger has the measurements.

  • The feedback loop as designed did not decay. Built as specified, 28 of 64 standing-carrier points never freed, most at the rail. At DC and Nyquist the two branches are not in quadrature and the product can exceed unity gain (the explanation is an argument; the threshold measured at 0.70 to 0.72 fits 1/sqrt 2). The feedback path, and only it, now passes through a filter with notches at DC and Nyquist and is read as a 24-bit word. The filter costs 1.9 dB at 20 Hz in the feedback path at 48 kHz; the heard product is untouched.
  • The feedback's ceiling falls to zero after the release, one Coeff step a sample, 64,881 samples. With the gain at zero the pair has no loop around it, so a released voice freeing is a property of the arithmetic. Held notes render bit-identically to before the change. The cost is a shorter release tail at high feedback: at 0.99 the last audible sample comes at 0.22 to 0.42 of the held tail's length over the three carriers tried; at 0.5 nothing changes. Nobody has heard this.
  • The reader clamps every field and refuses none, unlike the phaser's stage count.
  • The plan's series-termination rule was wrong for this design (a term that is exactly zero ended a series early and put one table word off); the series now ends on the power of q.

Measured

  • Unwanted sideband from the engine's render, 184 points across inputs, shifts and both directions: -82.18 dB at 44.1 kHz, -82.05 at 48, -82.19 at 96. Over the whole band from the design at the table's words: -79.79 dB.
  • Widths from a measured L1 norm of 7.93: state Q4.30, coefficient Q0.26.
  • sizeof(VoiceType) unchanged at 794,528; the layer is 952 bytes.
  • --config=opt, //plugin:instrument_bench, three runs on the laptop, ns per voice per sample: chorus 58.0–60.7, phaser at 12 stages 131.4–137.3, shifter 135.8–140.6, shifter with a tracked carrier 137.3–139.1. No fabric price is stated.

What it does not touch

dsp/va_*.h, dsp/layer_strip.h, dsp/mod_ops.h, dsp/mod_matrix.h, dsp/voice_allocator.h, dsp/svf.h, dsp/phase.h, dsp/lfo.h, dsp/sine.h, dsp/phaser_layer.h, dsp/layer.h, resolve/va*.h, patches/poly800_strings.h, or anything under fabric/, rtl/, hps/. plugin/render_golden_test.cc and fabric/master_pin_test.cc are unmoved.

Verification

On the branch's final tree:

  • bazel test //...: 322 of 322 pass.
  • bazel test --config=opt //...: 322 of 322 pass.

Before the review's last two commits, which change tests, comments and docs only (not re-run after them):

  • --config=asan //... and --config=ubsan //...: 322 of 322 each.
  • --config=tsan on the five scoped targets: 5 of 5.

An independent review found no defect in the code. It re-ran fourteen injections, two of which failed nothing or failed only the medium target; both now fail a named test in the small one, along with the four rounding sites and a legato note-on taking an edited field. It traced two anomalies the builders had left: the returned lines sitting 3.01 dB under what mix alone gives is the center pan law, and the lower sideband rising at feedback 0.9 is the loop clipping.

Known, and left

  • A held voice can keep a small cycle. With the carrier at exactly 1/5 or 2/5 of the rate and |feedback| at 0.9 or more, a held voice idles at up to 1,368 raw units, about -118 dBFS, which is above a 24-bit last bit. How near the fraction a carrier must be is unmeasured, and whether it is audible is unjudged. A released voice frees.
  • Feedback 0.9 clips for sends above roughly -14 dBFS (the reviewer's figure, not re-measured).
  • A voice-scope shifter reading the external input or the sidechain holds its voice while the send carries signal, as a phaser would.
  • Nothing has been listened to, and the editor page has not been seen on a screen. The default patch and shifter-return (0.5 Hz up, mix one half, feedback +0.25, a quarter turn of stereo phase) are starting values.
  • The bank is at 63. format/project_test.cc's fixture puts every factory patch on a channel and asserts the bank fits in 64; the 65th patch trips that test's build, not the product.
  • //patches:audition writes a silent wav for every return, this one included.

Spec, plan and ledger: docs/superpowers/{specs,plans,ledgers}/2026-10-05-frequency-shifter*.md.

🤖 Generated with Claude Code

Adds a frequency shifter, with the ring modulator as one setting of it, as the twelfth layer kind: the next effect from #105. It does not close that issue. ## What it is - **`dsp/hilbert.h`**: a Hilbert pair, two chains of eight one-multiply allpass sections whose outputs sit a quarter turn apart across the band. Its coefficient table, `dsp/hilbert_coeffs.h`, is generated: `materials/hilbert.h` holds the elliptic design in `double`, `bazel run //tools:hilbert_gen > dsp/hilbert_coeffs.h` rewrites it, and `//materials:hilbert_test` fails when the two disagree. It is a primitive of its own because the mono-to-stereo widener and barber-pole phasing both wait on one. - **`dsp/shifter_layer.h`**: a stereo single-sideband shifter over that pair. `sideband` runs from -1 (shift down) through 0 (both sidebands, which is ring modulation) to +1 (shift up). The carrier is linear in hertz and signed, so it sweeps through zero; it can follow the key, bend and glide included. It has its own dry path, as the phaser does, and the dry is the raw input, so a small shift at half mix gives notches that travel. `mirror` sends the two sides to opposite sidebands and `stereo_phase` offsets the right side's carrier. Also in the branch: the resolver, format (`SHFT`, minor 2.9), patch-diff walker, editor page, and one factory patch, `shifter-return` (bank 62 to 63). ## Where it departs from the design The spec is brought to what was built; the ledger has the measurements. - **The feedback loop as designed did not decay.** Built as specified, 28 of 64 standing-carrier points never freed, most at the rail. At DC and Nyquist the two branches are not in quadrature and the product can exceed unity gain (the explanation is an argument; the threshold measured at 0.70 to 0.72 fits 1/sqrt 2). The feedback path, and only it, now passes through a filter with notches at DC and Nyquist and is read as a 24-bit word. The filter costs 1.9 dB at 20 Hz in the feedback path at 48 kHz; the heard product is untouched. - **The feedback's ceiling falls to zero after the release**, one `Coeff` step a sample, 64,881 samples. With the gain at zero the pair has no loop around it, so a released voice freeing is a property of the arithmetic. Held notes render bit-identically to before the change. The cost is a shorter release tail at high feedback: at 0.99 the last audible sample comes at 0.22 to 0.42 of the held tail's length over the three carriers tried; at 0.5 nothing changes. **Nobody has heard this.** - **The reader clamps every field and refuses none**, unlike the phaser's stage count. - **The plan's series-termination rule was wrong** for this design (a term that is exactly zero ended a series early and put one table word off); the series now ends on the power of q. ## Measured - Unwanted sideband from the engine's render, 184 points across inputs, shifts and both directions: -82.18 dB at 44.1 kHz, -82.05 at 48, -82.19 at 96. Over the whole band from the design at the table's words: -79.79 dB. - Widths from a measured L1 norm of 7.93: state Q4.30, coefficient Q0.26. - `sizeof(VoiceType)` unchanged at 794,528; the layer is 952 bytes. - `--config=opt`, `//plugin:instrument_bench`, three runs on the laptop, ns per voice per sample: chorus 58.0–60.7, phaser at 12 stages 131.4–137.3, shifter 135.8–140.6, shifter with a tracked carrier 137.3–139.1. No fabric price is stated. ## What it does not touch `dsp/va_*.h`, `dsp/layer_strip.h`, `dsp/mod_ops.h`, `dsp/mod_matrix.h`, `dsp/voice_allocator.h`, `dsp/svf.h`, `dsp/phase.h`, `dsp/lfo.h`, `dsp/sine.h`, `dsp/phaser_layer.h`, `dsp/layer.h`, `resolve/va*.h`, `patches/poly800_strings.h`, or anything under `fabric/`, `rtl/`, `hps/`. `plugin/render_golden_test.cc` and `fabric/master_pin_test.cc` are unmoved. ## Verification On the branch's final tree: - `bazel test //...`: 322 of 322 pass. - `bazel test --config=opt //...`: 322 of 322 pass. Before the review's last two commits, which change tests, comments and docs only (not re-run after them): - `--config=asan //...` and `--config=ubsan //...`: 322 of 322 each. - `--config=tsan` on the five scoped targets: 5 of 5. An independent review found no defect in the code. It re-ran fourteen injections, two of which failed nothing or failed only the medium target; both now fail a named test in the small one, along with the four rounding sites and a legato note-on taking an edited field. It traced two anomalies the builders had left: the returned lines sitting 3.01 dB under what mix alone gives is the center pan law, and the lower sideband rising at feedback 0.9 is the loop clipping. ## Known, and left - **A held voice can keep a small cycle.** With the carrier at exactly 1/5 or 2/5 of the rate and |feedback| at 0.9 or more, a held voice idles at up to 1,368 raw units, about -118 dBFS, which is above a 24-bit last bit. How near the fraction a carrier must be is unmeasured, and whether it is audible is unjudged. A released voice frees. - **Feedback 0.9 clips** for sends above roughly -14 dBFS (the reviewer's figure, not re-measured). - **A voice-scope shifter reading the external input or the sidechain** holds its voice while the send carries signal, as a phaser would. - **Nothing has been listened to**, and the editor page has not been seen on a screen. The default patch and `shifter-return` (0.5 Hz up, mix one half, feedback +0.25, a quarter turn of stereo phase) are starting values. - **The bank is at 63.** `format/project_test.cc`'s fixture puts every factory patch on a channel and asserts the bank fits in 64; the 65th patch trips that test's build, not the product. - `//patches:audition` writes a silent wav for every return, this one included. Spec, plan and ledger: `docs/superpowers/{specs,plans,ledgers}/2026-10-05-frequency-shifter*.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dsp/hilbert.h is sixteen one-multiply allpass sections, eight a branch,
whose outputs are a quarter turn apart from 20/48000 of the rate to the
same distance below Nyquist. materials/hilbert.h holds the elliptic
half-band design in double, tools/hilbert_gen writes dsp/hilbert_coeffs.h
from it, and //materials:hilbert_test diffs the two byte for byte.

The state is Q4.30 toward zero and the coefficient Q0.26: the chain's L1
norm was measured at 7.93 before the state's type was fixed, and four
integer bits hold a Sample's whole range times that with the section's
exact product still inside 64 bits. The ledger has the measurements and
the injections each test was failed by.

The design's series end on the power of q and not on the term, since a
sine reduced as an integer is exactly zero where libm's is not, and the
plan's rule stopped the eleventh coefficient's series two terms in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShifterLayer over the Hilbert pair: a signed carrier linear in hertz that
can follow the key, I * cos - sideband * Q * sin, its own dry path, one
feedback gain, mirror and stereo phase, two LFOs and a matrix.

The loop as the spec has it does not decay: outside the pair's band the
product passes zero frequency and Nyquist at up to the square root of two,
and a carrier standing at the wrong phase, or at a small fraction of the
rate, climbed to the rail at any gain above about 0.71. Two things the
spec does not have are in the feedback path because of it: a filter with a
notch at each end, and a 24-bit toward-zero feedback word against the
small cycles the sections' own roundings held. One carrier, a fifth of the
rate, past the patch's range, still holds a cycle at some gains; the sweep
target pins it and the ledger has the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The closed loop can hold a small cycle at a carrier whose period is a few
whole samples, a fifth of the rate among them, so a released voice freeing
was a fact about the carriers a sweep tried. The feedback gain now has a
ceiling: the 0.99 bound while the note is held, which changes nothing
there, falling one Coeff unit a sample from the release to exactly zero in
64,881 samples, and put back by any note-on. At zero nothing is around the
pair and it empties as a chain does.

The decay sweeps now hold the note, since that is where the closed loop is
still the question, and released sweeps stand beside them with 44.1 kHz
added. The held cycle stays pinned from its own side. The ledger has what
the fall takes off a release tail at 0.99, 0.9 and 0.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kShifter goes last before kLayerKindCount and ShifterLayerType last in
VoiceType's list, so no existing kind's index moves. sizeof(VoiceType),
its Patch and ResolvedPatch, and the allocator are measured unchanged;
the shifter's layer is pinned at 952 bytes and its patch at 904.

resolve/shifter.h resolves the patch at one rate: the carrier as a signed
increment held inside +-8 kHz and inside Nyquist, the key's anchor formed
by the engine's own exp2 and zero when the patch does not track, the
depth and the shift lane's span. format/shifter.h reads and writes SHFT
at minor 2.9. No field refuses: the reader holds each inside the range
the resolver does, with the same helpers, so a patch that sounds writes a
file that resolves to what was playing. patchdiff/walk_shifter.h names
every field the writer serializes, and the editor gains the page, its
matrix labels and kShifterShape.

There is no shifter-return until the next task, so the patch-diff name
test and the coverage sweep's case add a shifter to moog-lead.

A voice-level test holds that a tracked carrier follows the note, and a
second freeing test runs at a fifth of the rate, where a held loop can
keep a cycle: the first passes with the feedback's ceiling made never to
fall, and this one does not.

Sites outside the -Werror=switch net: plugin/state_census.cc's engine
names, program_resolve_test's door lists, patch_test's bare-layer
fixture, the coverage test's engine list and the size pins. The ledger
has the sizes, the decisions and the injections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A channel-scope frequency shifter reading the channel's external input,
beside the other returns: half a hertz upward, the upper sideband, a
quarter turn between the sides, feedback 0.25, mix one half. Starting
values nobody has heard. The bank goes from 62 to 63, one under the
sixty-four format/project_test.cc's static_assert allows.

patches_test.cc feeds it as it feeds the other input-only returns, and
ShifterReturn.ShiftsTheReturnedSignalAndSumsItWithTheSend holds what a
send comes back as. The shift is too small for an ordinary spectrum, so
the window is four cycles of the carrier and the sine sits on a bin of
it: the send and the shifted signal come back as two lines four bins
apart at one level, nothing a shift below, and the level over time falls
once per carrier cycle.

patchdiff's SHFT coverage case and its name test read shifter-return now
that a factory patch holds a shifter; the name test moves from diff_test
to cli_test, beside the phaser's.

The ledger has the figures, the injections, and what no test holds: the
feedback's value and the stereo phase's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
instrument_bench gains the shifter's rows in the phaser's shape, a fixed
carrier and a key-tracked one, and held_voice_bench's header says where
the shifter's cost is priced. The ledger takes a summary of what was
built, the decisions the spec did not anticipate, the open and unheld
items and the list of things to hear by hand; three runs of both benches
with the phaser's and the chorus's rows from the same runs; and the five
lanes of the full suite. Its duplicated Task 1 block and second Task 2
heading are merged. CLAUDE.md names the Hilbert pair and the shifter in
the dsp/ paragraph and format/shifter.h in format/'s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found no defect in the engine and five places where a wrong
engine passed. dsp/shifter_layer.h changes in comments only.

A legato note-on taking an edited mirror, stereo phase, sideband or mix
was held by nothing. ALegatoNoteOnTakesEachEditedField edits each alone
and requires the render from that sample to be a fresh note's of the
edited patch, and not the unedited one's; ARefreshTakesEachEditedField
is the same through the allocator, where a channel-scope held voice takes
an editor's edit as that note-on.

StaysActiveUntilBothSidesAreZero never had a sample where the loop
filters alone held the layer, and its comment said it did. The new case
stands the carrier at phase zero, where the pairs empty 21,911 samples
after the release and the filters 28 later, and asserts that it happened.

shifter-return's level is held to the send, the mix's half, the patch's
trim and the center of the pan law, within a hundredth of a decibel,
where it was within six.

The loop's four roundings that no decay depends on are formed again in
plain integers and the engine's word required: three over a noise run
with the count of words a rounding to nearest would have changed, and
the half products in one built sample where that bit decides the word.
The header says which roundings a decay rests on and which it does not.

The matrix's reset at a fresh note-on changes no sample, since Evaluate
resets before it folds; the header says so instead of a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs(shifter): the review's fixes in the ledger, and the spec as built
Some checks failed
push / small (push) Waiting to run
ci / opt (pull_request) Has been cancelled
ci / asan (pull_request) Has been cancelled
ci / ubsan (pull_request) Has been cancelled
ci / tsan (pull_request) Has been cancelled
ci / coverage (pull_request) Has been cancelled
c2cc9db2f9
The ledger gains a Review section: each finding and what was done, each
new injection and the case it failed, what the loop's four other
roundings do and do not decide, the two anomalies Task 4 left untraced
with their causes (the pan law at center; the loop clipping at the test's
send level, not the loop gain), and what is left open. The sentences the
review found untrue are corrected where they stood: 16b and "a pair
outlasts its filter every time", and "a third to a fifth" for the
released tail.

The spec describes the code as built: the product through the loop
filter, read as a 24-bit word under a ceiling that falls after the
release; the sideband on the sine and the half products; the hot state;
exact zero as a released voice's property and the held cycle; where each
clamp is applied; a reader that clamps and refuses nothing; the measured
widths; the resolved form; the series' termination. Its open questions
are the decisions taken. Its model figures stay, marked as the design's.

The sweep test's comment no longer puts the held cycle under a 24-bit
word's last bit, which it is above. CLAUDE.md's phaser is a layer effect
with no storage and a dry path of its own, no longer the one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into shifter
Some checks failed
push / small (push) Successful in 32m22s
ci / opt (pull_request) Failing after 40m36s
ci / asan (pull_request) Failing after 1h12m39s
ci / ubsan (pull_request) Failing after 52m51s
ci / tsan (pull_request) Successful in 19m7s
ci / coverage (pull_request) Failing after 55m39s
022f47fb2f
bcox merged commit 6a3fede9d3 into main 2026-10-06 02:42:43 +00:00
bcox deleted branch shifter 2026-10-06 02:42:43 +00:00
Sign in to join this conversation.
No reviewers
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!148
No description provided.