feat: the phaser, a layer effect with no storage and its own dry path #111

Merged
bcox merged 11 commits from phaser into main 2026-10-05 19:37:04 +00:00
Owner

Adds a phaser as the eleventh layer kind: the next effect from #105. It does not close that issue.

What it is

A stereo swept allpass chain as a layer engine, dsp/phaser_layer.h. 2 to 12 stages (even counts), a sweep in octaves from the engine's own LFO, signed feedback, a stereo phase offset between the sides, and its own matrix. It holds no storage and runs at voice or channel scope.

Two things differ from the delay layer, both on purpose:

  • It has its own dry path. mix runs from the input alone (0) through the deepest notch (1/2) to the chain alone (1). A notch is the input canceling against the chain, so that sum has to be sample-exact, and the mixer cannot do it: a channel-scope layer reads its tap one sample late, and on a return the dry signal is another channel's fader.
  • Each stage is its own allpass section, PhaserStage. It uses OnePoleFilter's multiply and state but forms 2 * lowpass - input exactly and rounds once, toward zero. See "The limit cycle" below.

Also in the branch: the resolver, format (PHSR, minor 2.8), patch-diff walker, editor page, and one factory patch, phaser-return (bank 61 to 62).

The limit cycle

The first version built each stage on OnePoleFilter::Lowpass. A widened decay test found that with feedback on and the stage frequency at the one-pole's upper limit (12 kHz at 48 kHz), the chain never reached zero: a residual of at most 599 raw units that repeated exactly, which would keep a note-scope voice from ever freeing.

The cause, confirmed by injection: the lowpass was rounded to nearest before being doubled. PhaserStage removes that rounding, and all 1,380 points of //dsp:phaser_layer_decay_test now reach exact zero at 48 and 96 kHz. dsp/svf.h is not modified.

What it does not touch

Built while F8b and F9.1a were in progress, so the branch edits none of: 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/layer.h, resolve/va*.h, patches/poly800_strings.h, or anything under fabric/, rtl/, hps/.

sizeof(VoiceType) is unchanged (the pin in plugin/instrument_test.cc is untouched and passes), and plugin/render_golden_test.cc and fabric/master_pin_test.cc are unmoved. The layer itself is 560 bytes.

Verification

After the rebase onto current main:

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

Before the rebase (not re-run after it):

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

An independent review re-ran 20 of the authors' injections (all reproduced) and added 14 of its own, of which 10 failed no test. Each of those now fails a named test; the ledger's "Review fixes" section has the table. bcox has listened to it in the workstation.

Cost

--config=opt, //plugin:instrument_bench, three runs on the laptop, ns per voice per sample: chorus 54.6–59.4, phaser at 4 stages 93.7–100.8, at 12 stages 134.8–141.7. No fabric price is stated; this machine has no Quartus.

Known, and left

  • Frequency floor. The patch range starts at 20 Hz, below the one-pole's band (about 23 Hz at 48 kHz, 47 Hz at 96 kHz), so the bottom of the slider does nothing. Pinned by a test as it is.
  • Note-scope release. A note-scope phaser goes inactive once its chain is empty after note-off, so a source whose release has gaps of exact silence loses the phaser for the rest of that release. Read from the code, not measured.
  • Stage output rounding. No test distinguishes toward-zero from nearest at the stage output; it is toward-zero by the repo's rule for a recursion.
  • Delay and reverb share two test holes the review found here (a short-leaf check and hand-bound editor controls no test holds). Not fixed in this branch.
  • //plugin:preset_race_test flakes under load on main too. Its coverage floor at preset_race_test.cc:636 failed 28 of 300 oversubscribed opt runs on main and 14 of 300 here; it passes 20 of 20 alone.
  • //patches:audition writes a silent wav for every return, this one included, since it feeds no input.

Spec, plan and ledger: docs/superpowers/{specs,plans,ledgers}/2026-10-04-phaser-layer*.md.

🤖 Generated with Claude Code

Adds a phaser as the eleventh layer kind: the next effect from #105. It does not close that issue. ## What it is A stereo swept allpass chain as a layer engine, `dsp/phaser_layer.h`. 2 to 12 stages (even counts), a sweep in octaves from the engine's own LFO, signed feedback, a stereo phase offset between the sides, and its own matrix. It holds no storage and runs at voice or channel scope. Two things differ from the delay layer, both on purpose: - **It has its own dry path.** `mix` runs from the input alone (0) through the deepest notch (1/2) to the chain alone (1). A notch is the input canceling against the chain, so that sum has to be sample-exact, and the mixer cannot do it: a channel-scope layer reads its tap one sample late, and on a return the dry signal is another channel's fader. - **Each stage is its own allpass section, `PhaserStage`.** It uses `OnePoleFilter`'s multiply and state but forms `2 * lowpass - input` exactly and rounds once, toward zero. See "The limit cycle" below. Also in the branch: the resolver, format (`PHSR`, minor 2.8), patch-diff walker, editor page, and one factory patch, `phaser-return` (bank 61 to 62). ## The limit cycle The first version built each stage on `OnePoleFilter::Lowpass`. A widened decay test found that with feedback on and the stage frequency at the one-pole's upper limit (12 kHz at 48 kHz), the chain never reached zero: a residual of at most 599 raw units that repeated exactly, which would keep a note-scope voice from ever freeing. The cause, confirmed by injection: the lowpass was rounded to nearest before being doubled. `PhaserStage` removes that rounding, and all 1,380 points of `//dsp:phaser_layer_decay_test` now reach exact zero at 48 and 96 kHz. `dsp/svf.h` is not modified. ## What it does not touch Built while F8b and F9.1a were in progress, so the branch edits none of: `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/layer.h`, `resolve/va*.h`, `patches/poly800_strings.h`, or anything under `fabric/`, `rtl/`, `hps/`. `sizeof(VoiceType)` is unchanged (the pin in `plugin/instrument_test.cc` is untouched and passes), and `plugin/render_golden_test.cc` and `fabric/master_pin_test.cc` are unmoved. The layer itself is 560 bytes. ## Verification After the rebase onto current `main`: - `bazel test //...`: 313 of 313 pass. - `bazel test --config=opt //...`: 313 of 313 pass. Before the rebase (not re-run after it): - `--config=asan //...` and `--config=ubsan //...`: 307 of 307 each. - `--config=tsan` on the five scoped targets: 5 of 5. An independent review re-ran 20 of the authors' injections (all reproduced) and added 14 of its own, of which 10 failed no test. Each of those now fails a named test; the ledger's "Review fixes" section has the table. bcox has listened to it in the workstation. ## Cost `--config=opt`, `//plugin:instrument_bench`, three runs on the laptop, ns per voice per sample: chorus 54.6–59.4, phaser at 4 stages 93.7–100.8, at 12 stages 134.8–141.7. No fabric price is stated; this machine has no Quartus. ## Known, and left - **Frequency floor.** The patch range starts at 20 Hz, below the one-pole's band (about 23 Hz at 48 kHz, 47 Hz at 96 kHz), so the bottom of the slider does nothing. Pinned by a test as it is. - **Note-scope release.** A note-scope phaser goes inactive once its chain is empty after note-off, so a source whose release has gaps of exact silence loses the phaser for the rest of that release. Read from the code, not measured. - **Stage output rounding.** No test distinguishes toward-zero from nearest at the stage output; it is toward-zero by the repo's rule for a recursion. - **Delay and reverb share two test holes** the review found here (a short-leaf check and hand-bound editor controls no test holds). Not fixed in this branch. - **`//plugin:preset_race_test` flakes under load on `main` too.** Its coverage floor at `preset_race_test.cc:636` failed 28 of 300 oversubscribed opt runs on `main` and 14 of 300 here; it passes 20 of 20 alone. - `//patches:audition` writes a silent wav for every return, this one included, since it feeds no input. Spec, plan and ledger: `docs/superpowers/{specs,plans,ledgers}/2026-10-04-phaser-layer*.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bcox added 11 commits 2026-10-05 19:31:09 +00:00
The next effect from issue #105: a stereo swept allpass chain as a layer
engine with its own dry path and no storage. Names what it stays out of
while stage F8b is built on another machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dsp::PhaserLayer: 2 to 12 first-order allpass stages a side over
dsp/svf.h's one-pole, one coefficient a side swept in octaves by LFO 1
with the right side on a shadow started stereo_phase ahead, a signed
feedback one sample late, and the engine's own dry path. No matrix and no
registration yet; those are the plan's Tasks 2 and 3.

The ledger holds the measured thresholds, each injection and the test it
failed, and where the build departed from the plan's sketch: the feedback
and mix are Coeff inside the engine because fixed.h cannot hold the exact
products at Sample width, and the zero-mix branch is redundant with the
mix's own arithmetic and kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine gains its ModMatrix: frequency (octaves, added to the sweep's),
depth, feedback and mix, and each LFO's rate and width, with the two engine
LFOs as sources. The matrix is evaluated before the LFOs advance, the
shadow takes LFO 1's modulation, and a lane nothing touches leaves the
render bit-identical to the one before it.

The zero-mix branch is gone. dry + mix * (wet - dry), formed exactly and
requantized once, is already the dry raw for raw at zero, so the branch
changed no output bit.

A stage is now PhaserStage, a section of the phaser's own. Widening the
decay sweep found that the stage built on OnePoleFilter::Lowpass holds a
limit cycle at the one-pole's frequency limit: there the stage's gain is
one half, the lowpass is (x + s) / 2 rounded to nearest, and 2 * lowpass - x
doubles each half-unit rounding into a whole unit in one direction, which
feedback then recirculates. A released voice at 12 kHz and 48 kHz never
went inactive at any feedback tried from 0.5 up. PhaserStage keeps the
one-pole's multiply and state and forms the allpass exactly from the
unrounded lowpass, with one requantize that saturates and truncates toward
zero. dsp/svf.h is unchanged.

//dsp:phaser_layer_decay_test, a medium target, holds the loop reaching
exact zero at 1200 still points and 180 swept ones. The ledger carries the
residuals measured on the old stage, the injections that confirm the cause,
and the one that fails nothing: nearest rounding at the exact allpass's
single requantize is not distinguished by any test here.

IsActive's readings of the stages and of the feedback state are held apart
by a test that sees each be the only one keeping the layer active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kPhaser goes last before kLayerKindCount and PhaserLayerType 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 phaser's layer is pinned at 560 bytes and its patch at 872.

resolve/phaser.h resolves the patch at one rate and holds every field
inside its range. format/phaser.h reads and writes PHSR at minor 2.8:
the reader refuses an odd or out-of-range stage count and clamps the
rest. patchdiff/walk_phaser.h names every field the writer serializes,
and the editor gains the page, its matrix labels and kPhaserShape.

The patchdiff byte sweep cannot reach the stage count, because both of
its masks turn a valid count into one the reader refuses, so
PhaserCoverage.AChangedStageCountIsNamed holds that field directly.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A channel-scope phaser reading the channel's external input, beside the
other returns: six stages, mix one half. The bank goes from 61 to 62.

patches_test.cc feeds it as it feeds the other input-only returns, and
PhaserReturn.NotchesTheReturnedSignal holds what a send comes back as:
one notch per pair of stages on each side, moving with the sweep.

patchdiff's PHSR coverage case reads phaser-return now that a factory
patch holds a phaser, in place of adding one to moog-lead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
instrument_bench gains the phaser's rows in the delay's shape, at four
and at twelve stages. The ledger takes Task 4's decisions, measurements
and injections, three runs of both benches with the delay's rows from
the same runs, and the list of things to hear by hand. CLAUDE.md names
the phaser in the dsp/ paragraph and format/phaser.h in format/'s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep, the stereo phase and the feedback lane were asserted on
swept_increment(), feedback_gain() and mix_gain(), so a render that
computed each rightly and then played another failed nothing. These read
the output: the notch's position per side in each half of a pulse sweep,
the feedback lane against a patch at the same gain, each side against a
run of its own signal with the loop on. NoteOn's three guards are held by
rendering a hostile resolved form against the clamped one, and IsActive's
stage bound by a legato drop from eight stages to four.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A patch in memory with an odd or out-of-range count resolved clamped and
sounded, wrote the raw count, and its own file then refused whole. The
writer now writes ClampStages of it; the reader still refuses.

The truncation test cut the wrapper, so the wrapper's walk refused every
cut and the leaf reader's own check was never reached. It is renamed for
what it exercises, and two cases cut the leaf inside a whole wrapper and
lie in its header size. The refusal test puts its hostile counts into
the bytes, since the writer no longer carries them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
patchdiff: each scalar field the walker names is changed alone on
phaser-return and the report must enter that path and no other.

editor: Stages, Frequency and Stereo phase recorded their binding in one
statement and wrote in another, so a write to the wrong field failed
nothing. Each now draws through a helper that writes through the pointer
it reports. The labels test writes each destination's name beside its
lane, so two names exchanged no longer pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The range test ran at 48 kHz only. It runs at all three rates, and a
second case pins where the range's ends fall against the one-pole's
band: the top past it, on it and inside it by rate, the floor under it
at all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs(phaser): the review's fixes in the ledger, and the spec as built
Some checks failed
push / small (push) Successful in 30m30s
ci / opt (pull_request) Failing after 43m2s
ci / asan (pull_request) Failing after 1h9m27s
ci / ubsan (pull_request) Failing after 52m22s
ci / tsan (pull_request) Successful in 18m2s
ci / coverage (pull_request) Failing after 56m3s
bcb2c106f2
The ledger gains a Review fixes section: each injection the review found
failing nothing, the case that fails now, what changed, what the delay,
long delay and reverb share and were left with, and the minors left. Its
header no longer says one target at one build measured every figure.

The spec's state section names PhaserStage and the measured sizes, its
format bullet says the writer writes the clamped stage count, and its
testing bullets say what the tests hold. The plan keeps its text and
gains dated notes where the build departed from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bcox merged commit f6aa8ebaf5 into main 2026-10-05 19:37:04 +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!111
No description provided.