Skip to content

Let a host size the Delay's line and cap the Resonator's modes - #806

Merged
charliehuge merged 3 commits into
devfrom
feat/delay-max-time
Oct 15, 2026
Merged

charliehuge merged 3 commits into
devfrom
feat/delay-max-time

Conversation

@charliehuge

@charliehuge charliehuge commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Two construction-time ceilings a bare-metal host needs. They are for ii-handhelds' Emotional Support Synth, a Daisy Seed patch with four resonators and an echo. Neither changes what a document produces, or what any existing caller gets.

Delay::with_max_time

  • The problem. Delay reserved a fixed 2 s line, allocated lazily on its first process. At 48 kHz that is 384 KB, more than the Seed's whole internal heap (about 287 KB). It was also an allocation on the audio thread for every new Delay.
  • The constructor. Delay::with_max_time(secs) takes the ceiling at construction:
    • Delay::new keeps 2 s.
    • time clamps to the chosen ceiling.
    • spawn carries the ceiling to copies.
  • Degenerate inputs.
    • A NaN ceiling takes the 1 ms floor. f32::clamp would have passed it through to a panicking clamp in process.
    • The line is at least two samples, so the read-head clamp always has a valid range.
  • When the line is allocated. It is now allocated in on_instantiate, where the sample rate is fixed, not on the first process. The lazy branch stays as a guard for a box processed without a Plan.
  • Cost for a Swap. The new Plan holds a line for every Delay, including the fresh twins of surviving boxes, which are thrown away when the survivors move in. So a Swap's peak heap is the old Plan's lines plus the new Plan's. This is documented in the module.
  • Documents can't set it. Constants are read by the loader, not by the operator. Exposing the ceiling in a document would need that plumbing first.

Resonator::with_modes

  • The problem. The bank advanced all 32 modes every sample, including the ones recompute had muted for sitting past Nyquist.

  • The cap. with_modes(n) caps the bank at construction. new keeps 32, and spawn carries the cap to copies.

  • Skipping muted modes. Separately from the cap, the bank now advances only up to the last mode left sounding. It clears the ring of any mode that leaves that range.

  • Output: bit-identical, with one exception. Two checks agree:

    • an FNV hash of 3000 blocks with a strike and a retune past Nyquist and back;
    • an independent review's old-vs-new run over slices of 1–300 frames sweeping 20 Hz–8 kHz.

    The exception is a mode that leaves and comes back exactly one frame later. Before, it kept one sample of its old ring; now it comes back silent, which is the intended behaviour.

  • Speed. The loop walks slices cut to the active count instead of indexing below a runtime bound, which removes the bounds checks. Measured with callgrind on one 32-mode resonator over 3000 blocks, instructions go from 53.4M to 47.1M (−12%).

Tests

New tests cover:

  • a lowered ceiling caps the echo there;
  • instantiate sizes the line to max_time;
  • degenerate ceilings render instead of panicking. I checked this test fails on the previous logic.
  • spawned copies keep their ceiling or cap;
  • fewer modes drop only the upper partials;
  • a mode pushed past Nyquist is cleared.

Locally, cargo test, fmt --check and clippy -D warnings all pass. CI, including the perf gate, was green on the first push.

Follow-up, not in this PR

reverb.rs still sizes its buffers on the first process, which is the same audio-thread allocation this PR removes from Delay.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MAsy4haLapatc2ssNNUZKo

…antiate

`Delay` reserved a fixed 2 s line, allocated lazily on its first `process`. At 48 kHz that is
384 KB, which does not fit a bare-metal heap of a few hundred KB, so a firmware host could not
use the operator at all. `Delay::with_max_time` takes the ceiling at construction; `Delay::new`
keeps 2 s, so documents and every existing caller are unchanged. `time` clamps to the chosen
ceiling, and `spawn` carries it to copies.

The line is now allocated in `on_instantiate`, where the sample rate is fixed, rather than on
the first `process`. The lazy branch stays as a guard for a box processed without a Plan.

The ceiling is not reachable from a document: constants are read by the loader, not by the
operator, so exposing it there would need that plumbing first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MAsy4haLapatc2ssNNUZKo
The bank advanced all 32 modes every sample, including the ones `recompute` had muted for
sitting past Nyquist. On a Cortex-M7 a four-string patch of these is most of a 32-frame block,
and a host with a small speaker has no use for partials it cannot reproduce.

`Resonator::with_modes(n)` caps the bank at construction; `Resonator::new` keeps 32, and
`spawn` carries the cap to copies. Independently of the cap, the bank now advances only up to the
last mode `recompute` left sounding, and clears the ring of any mode that leaves that range so a
later retune down does not bring it back mid-ring. Muted modes already decayed to exact zero
within two samples, so the output is bit-identical (an FNV hash of 3000 blocks, with a strike
and a retune past Nyquist and back, matches the previous code).

The loop walks slices cut to the active count instead of indexing below it, which removes the
bounds checks a runtime bound would otherwise keep: the default 32-mode path is 12% fewer
instructions than before (callgrind, one resonator, 3000 blocks: 53.4M -> 47.1M).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MAsy4haLapatc2ssNNUZKo
- `with_max_time(NaN)` kept the NaN (`f32::clamp` passes it through), and `process` then clamped
  `time` against it, which panics on the render thread. A NaN now takes the 1 ms floor.
- A 1 ms ceiling at a sample rate of 1 kHz or less made a one-sample line, and the read head's
  `clamp(1.0, len - 1)` became an empty range and panicked. The line is at least two samples.
- The Delay module doc says what instantiate-time allocation costs a Swap: the new Plan holds a
  line for every Delay, survivors' discarded twins included.
- The Resonator docs and operator-dev.md describe the mode cap, the skipped muted modes, and
  `on_instantiate` as the hook for config-dependent state generally, not only the Voicer's.
- `instantiate_sizes_the_line_so_process_never_allocates_it` asserted only the size; it is now
  named for what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MAsy4haLapatc2ssNNUZKo
@charliehuge
charliehuge merged commit 9879046 into dev Oct 15, 2026
19 checks passed
@charliehuge
charliehuge deleted the feat/delay-max-time branch October 15, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants