Partial bank: Loris-style bandwidth-enhanced partials (per-partial noise) #92

Open
opened 2026-09-13 20:17:37 +00:00 by bcox · 2 comments
Owner

The partial bank (dsp/partial_layer.h) renders every partial as a pure sinusoid. Anything noisy in a sound — breath, bow hair, the wash on a struck plate, the residual left after a resynthesis — has nowhere to go but a separate unvoiced layer, which is not tied to any partial's frequency, amplitude or timeline.

Fitz and Haken's bandwidth-enhanced partial, the synthesis model behind Loris, gives each partial a noisiness coefficient. The partial's energy moves between a sinusoid and a narrow band of noise centered on the same frequency. This ticket proposes adding that to the partial bank. Not scheduled.

It's the partial-bank counterpart of what #91 found for the modal bank: smearing lines into narrow noise bands is what removes the "bell" quality. The partial bank has no feedback loop, though, so #91's rule that noise must go on the pickup rather than the resonator doesn't apply here.

The model

The model comes from Fitz & Haken, JAES 50(11), 2002, eq. 11. The same equation is eq. 1 in Fitz, Haken & Christensen, ICMC 2000:

y(n) = Ã(n) · [ √(1 − κ(n)) + √(2κ(n)) · ζ(n) ] · cos θ(n)
  • Ã(n): the partial's total amplitude, sinusoid plus noise.
  • κ(n) ∈ [0, 1]: the bandwidth coefficient, where 0 is a pure sinusoid and 1 is all noise.
  • θ(n): the partial's ordinary accumulated phase.
  • ζ(n): lowpass-filtered noise. Its gain is scaled so it carries the same total spectral energy as a full-amplitude sinusoid. That makes the energy match in expectation, not sample by sample.

What Loris's own synthesizer does, per its source (src/NoiseGenerator.C, src/Oscillator.C):

  • Noise: zero-mean, unit-variance Gaussian, from Box–Muller over a Park–Miller LCG.
  • Filter: 3rd-order Chebyshev, 500 Hz cutoff, 1 dB ripple, with fixed coefficients.
  • Bandwidth is fixed. It's 500 Hz whatever the partial's frequency. The ICMC 2000 paper says so explicitly.
  • Noise state belongs to the oscillator, one per partial. Unverified: whether distinct partials get distinct seeds. Synthesizer.C reuses one oscillator across its partial loop.

Where κ lives: two options

A. A third lane block, kPartialBandwidthBase. This matches Loris exactly: κ varies over time per breakpoint, and it's what an analysis would write.

  • Lane count at 32 partials goes from 73 to 105.
  • Value rectangle grows from 73 KiB to 105 KiB per layer at 256 frames. That's computed from kMaxPartials's comment (lanes × 4 bytes × frames), not measured.
  • kTimelineSpeed moves. The static_assert(kTimelineSpeed == 9 + 2 * kMaxPartials) exists to catch exactly this, and every stored matrix destination aimed at a clock's speed shifts with it.
  • The PRTL reader has to change. It derives the writer's block bases from the stored capacity, assuming two per-partial blocks. A third block needs either a format change there or a record that states its block count.
  • If #89's format reset lands first, this is designed in from the start rather than migrated.

B. A static per-partial array, plus one global "noisiness" lane scaling it. It stores one number per partial, alongside keytrack, pan and start_phase, and changes no lane numbering. It's cheap and format-light, but it can't say what a Loris analysis says: a partial whose noisiness changes over the note, such as a breathy attack settling into a clean tone.

A is the real feature and B is a stepping stone. Choose before the format work, not after.

Datapath

Per partial, per sample, on top of today's phase step, sine lookup and amplitude multiply:

  • Noise source: independent per partial. The #91 spike steps one 32-bit xorshift once per consumer in sequence, which costs no per-partial state. A per-partial LFSR is the fabric-natural choice. Loris uses Gaussian noise. Narrowly lowpassed uniform noise tends toward Gaussian, so it's probably fine, but check by ear rather than assuming.
  • Filter: Loris's 3rd-order Chebyshev is about 6 multiplies plus 3 states per partial. A one-pole is 1–2 multiplies plus 1 state. Start with the one-pole and compare by ear against a Chebyshev reference rendered offline.
  • Bandwidth: fixed or proportional to frequency? Loris fixes it at 500 Hz. On #91's modal pickup, a noise cutoff proportional to each bin's frequency is what bcox liked. A fixed width smears high partials far less, relative to their frequency, than low ones. That's worth exposing, with Loris's fixed 500 Hz as the default reference.
  • Mix coefficients: √(1 − κ) and √(2κ) change at lane rate. Use a small square-root ROM indexed by quantized κ, not a per-sample square root.
  • State size: about 8 bytes per partial (filter state plus RNG), so about 256 B at 32 partials. Per its 2026-09-05 comment, sizeof(PartialLayerType) was 2384 B at 32 partials against a 6400 B bound. That figure hasn't been re-measured for this ticket.

Aliasing: the header's claim needs a noise term

The partial bank claims to be alias-free for timeline-rate motion. kTaperStart fades a partial over the top sixteenth of the band. Amplitude modulation by noise of bandwidth B spreads a partial to roughly f ± B, plus the filter's skirt beyond that. So a noisy partial within B of Nyquist folds noise back down, even when its frequency lane never moves. Options:

  • Start the taper B earlier for partials with κ > 0.
  • Scale ζ's contribution by the same taper.

Also, κ driven by an audio-rate LFO is amplitude modulation at audio rate. That's the same class of hazard the header already documents for frequency lanes.

Analysis side (a separate stage)

resynth/ has a harmonic scan and an STFT grid scan, and neither estimates noisiness. Loris's bandwidth association (ICMC 2000, §4) takes the short-time spectral energy the extracted sinusoids don't account for, computed via Parseval over overlapping frequency regions. It hands that energy to nearby partials as κ.

  • Region width: the "bandwidth enhancement region width" parameter, default 2000 Hz, typically 1000–4000 Hz.
  • Bark-scale regions are the paper's alternative.

This is host-side double arithmetic under resynth/'s own posture, with none of dsp/'s constraints. Loris's documentation recommends enabling it only for noisy sources.

Tests this needs

Verify each one by injecting the bug it claims to catch.

  • κ = 0 everywhere is bit-exact with today's renderer, and plugin/render_golden_test.cc does not move.
  • Energy is preserved in expectation across κ: long-window RMS at κ = 0, 0.5 and 1 agrees within a stated tolerance.
  • Noise really is independent across partials: two partials at the same frequency and κ = 1 are uncorrelated. A shared-generator bug would pass every level test.
  • Noise bandwidth is what the filter claims, measured around a κ = 1 partial.
  • No fold-back near Nyquist for a noisy partial parked just under kTaperStart.
  • Format round trip, plus a reader test for a writer at a different capacity. PRTL already carries that hazard for two blocks.

Open questions

  • A or B, and whether that choice waits on #89.
  • Fixed bandwidth, frequency-proportional bandwidth, or a per-patch choice.
  • Filter order, decided by listening against a reference.
  • Whether the unvoiced layer's role shrinks once partials can carry their own noise.

Sources

The Loris details above were checked against those papers and the source by a research pass on 2026-09-13. The JAES abstract says Kyma has a real-time implementation; no hardware or EaganMatrix-specific detail was verified.

The partial bank (`dsp/partial_layer.h`) renders every partial as a pure sinusoid. Anything noisy in a sound — breath, bow hair, the wash on a struck plate, the residual left after a resynthesis — has nowhere to go but a separate unvoiced layer, which is not tied to any partial's frequency, amplitude or timeline. Fitz and Haken's **bandwidth-enhanced partial**, the synthesis model behind Loris, gives each partial a noisiness coefficient. The partial's energy moves between a sinusoid and a narrow band of noise centered on the same frequency. This ticket proposes adding that to the partial bank. **Not scheduled.** It's the partial-bank counterpart of what #91 found for the modal bank: smearing lines into narrow noise bands is what removes the "bell" quality. The partial bank has no feedback loop, though, so #91's rule that noise must go on the pickup rather than the resonator doesn't apply here. ## The model The model comes from Fitz & Haken, JAES 50(11), 2002, eq. 11. The same equation is eq. 1 in Fitz, Haken & Christensen, ICMC 2000: ``` y(n) = Ã(n) · [ √(1 − κ(n)) + √(2κ(n)) · ζ(n) ] · cos θ(n) ``` - **Ã(n):** the partial's total amplitude, sinusoid plus noise. - **κ(n) ∈ [0, 1]:** the bandwidth coefficient, where 0 is a pure sinusoid and 1 is all noise. - **θ(n):** the partial's ordinary accumulated phase. - **ζ(n):** lowpass-filtered noise. Its gain is scaled so it carries the same total spectral energy as a full-amplitude sinusoid. That makes the energy match **in expectation**, not sample by sample. What Loris's own synthesizer does, per its source (`src/NoiseGenerator.C`, `src/Oscillator.C`): - **Noise:** zero-mean, unit-variance Gaussian, from Box–Muller over a Park–Miller LCG. - **Filter:** 3rd-order Chebyshev, 500 Hz cutoff, 1 dB ripple, with fixed coefficients. - **Bandwidth is fixed.** It's 500 Hz whatever the partial's frequency. The ICMC 2000 paper says so explicitly. - **Noise state belongs to the oscillator,** one per partial. Unverified: whether distinct partials get distinct seeds. `Synthesizer.C` reuses one oscillator across its partial loop. ## Where κ lives: two options **A. A third lane block, `kPartialBandwidthBase`.** This matches Loris exactly: κ varies over time per breakpoint, and it's what an analysis would write. - **Lane count** at 32 partials goes from 73 to 105. - **Value rectangle** grows from 73 KiB to 105 KiB per layer at 256 frames. That's computed from `kMaxPartials`'s comment (lanes × 4 bytes × frames), not measured. - **`kTimelineSpeed` moves.** The `static_assert(kTimelineSpeed == 9 + 2 * kMaxPartials)` exists to catch exactly this, and every stored matrix destination aimed at a clock's speed shifts with it. - **The PRTL reader has to change.** It derives the writer's block bases from the stored capacity, assuming two per-partial blocks. A third block needs either a format change there or a record that states its block count. - **If #89's format reset lands first,** this is designed in from the start rather than migrated. **B. A static per-partial array, plus one global "noisiness" lane scaling it.** It stores one number per partial, alongside `keytrack`, `pan` and `start_phase`, and changes no lane numbering. It's cheap and format-light, but it can't say what a Loris analysis says: a partial whose noisiness changes over the note, such as a breathy attack settling into a clean tone. A is the real feature and B is a stepping stone. Choose before the format work, not after. ## Datapath Per partial, per sample, on top of today's phase step, sine lookup and amplitude multiply: - **Noise source:** independent per partial. The #91 spike steps one 32-bit xorshift once per consumer in sequence, which costs no per-partial state. A per-partial LFSR is the fabric-natural choice. Loris uses Gaussian noise. Narrowly lowpassed uniform noise tends toward Gaussian, so it's probably fine, but check by ear rather than assuming. - **Filter:** Loris's 3rd-order Chebyshev is about 6 multiplies plus 3 states per partial. A one-pole is 1–2 multiplies plus 1 state. Start with the one-pole and compare by ear against a Chebyshev reference rendered offline. - **Bandwidth: fixed or proportional to frequency?** Loris fixes it at 500 Hz. On #91's modal pickup, a noise cutoff proportional to each bin's frequency is what bcox liked. A fixed width smears high partials far less, relative to their frequency, than low ones. That's worth exposing, with Loris's fixed 500 Hz as the default reference. - **Mix coefficients:** √(1 − κ) and √(2κ) change at lane rate. Use a small square-root ROM indexed by quantized κ, not a per-sample square root. - **State size:** about 8 bytes per partial (filter state plus RNG), so about 256 B at 32 partials. Per its 2026-09-05 comment, `sizeof(PartialLayerType)` was 2384 B at 32 partials against a 6400 B bound. That figure hasn't been re-measured for this ticket. ## Aliasing: the header's claim needs a noise term The partial bank claims to be alias-free for timeline-rate motion. `kTaperStart` fades a partial over the top sixteenth of the band. Amplitude modulation by noise of bandwidth B spreads a partial to roughly f ± B, plus the filter's skirt beyond that. So a noisy partial within B of Nyquist folds noise back down, even when its frequency lane never moves. Options: - Start the taper B earlier for partials with κ > 0. - Scale ζ's contribution by the same taper. Also, κ driven by an audio-rate LFO is amplitude modulation at audio rate. That's the same class of hazard the header already documents for frequency lanes. ## Analysis side (a separate stage) `resynth/` has a harmonic scan and an STFT grid scan, and neither estimates noisiness. Loris's **bandwidth association** (ICMC 2000, §4) takes the short-time spectral energy the extracted sinusoids don't account for, computed via Parseval over overlapping frequency regions. It hands that energy to nearby partials as κ. - **Region width:** the "bandwidth enhancement region width" parameter, default 2000 Hz, typically 1000–4000 Hz. - **Bark-scale regions** are the paper's alternative. This is host-side double arithmetic under `resynth/`'s own posture, with none of `dsp/`'s constraints. Loris's documentation recommends enabling it only for noisy sources. ## Tests this needs Verify each one by injecting the bug it claims to catch. - **κ = 0 everywhere is bit-exact with today's renderer,** and `plugin/render_golden_test.cc` does not move. - **Energy is preserved in expectation across κ:** long-window RMS at κ = 0, 0.5 and 1 agrees within a stated tolerance. - **Noise really is independent across partials:** two partials at the same frequency and κ = 1 are uncorrelated. A shared-generator bug would pass every level test. - **Noise bandwidth is what the filter claims,** measured around a κ = 1 partial. - **No fold-back near Nyquist** for a noisy partial parked just under `kTaperStart`. - **Format round trip,** plus a reader test for a writer at a different capacity. PRTL already carries that hazard for two blocks. ## Open questions - A or B, and whether that choice waits on #89. - Fixed bandwidth, frequency-proportional bandwidth, or a per-patch choice. - Filter order, decided by listening against a reference. - Whether the unvoiced layer's role shrinks once partials can carry their own noise. ## Sources - K. Fitz, L. Haken, "On the Use of Time-Frequency Reassignment in Additive Sound Modeling," JAES 50(11), 2002. Preprint: https://www.cerlsoundgroup.org/Loris/papers/aes2001.pre.pdf - K. Fitz, L. Haken, P. Christensen, "A New Algorithm for Bandwidth Association in Bandwidth-Enhanced Additive Sound Modeling," ICMC 2000: https://www.cerlsoundgroup.org/Loris/papers/association.pdf - Loris source (mirror): https://github.com/ngwese/loris — `src/NoiseGenerator.C`, `src/Oscillator.C`, `src/Synthesizer.C` - Loris C modeling guide (region-width default): https://www.cerlsoundgroup.org/Loris/LorisModelingInC.html - X. Serra, J. O. Smith, "Spectral Modeling Synthesis," CMJ 14(4), 1990: the deterministic-plus-stochastic antecedent. - Not read, so nothing above is attributed to them: Haken, Fitz & Christensen, ch. 3 in Beauchamp (ed.), *Analysis, Synthesis, and Perception of Musical Sounds*, Springer 2007; and the CMJ 2003 "Cell-Utes" paper (doi:10.1162/014892603322482529). The Loris details above were checked against those papers and the source by a research pass on 2026-09-13. The JAES abstract says Kyma has a real-time implementation; no hardware or EaganMatrix-specific detail was verified.
Author
Owner

Measured cost, 2026-09-13

Setup: a prototype in PartialLayer's partial loop, behind a compile flag. It adds a per-partial xorshift, a one-pole lowpass (500 Hz), and amplitude × (a + b·ζ). Noisiness is 0.5 on every partial and the noise always runs, so this is the worst case.

  • B: a and b are fixed per note.
  • A: B plus one extra timeline lane read per partial, standing in for a noisiness lane, with a and b from a 256-entry square-root table.

Method: //plugin:instrument_bench, --config=opt, AMD Ryzen 7 7840HS, three runs of each mode interleaved. Figures are medians in ns/sample, min–max in brackets.

row today B A B / today A / today
C2, 1 voice, 8 partials 279 (276–286) 304 (304–313) 371 (364–372) 1.09× 1.33×
C2, 1 voice, 16 partials 403 (398–409) 453 (452–455) 582 (581–587) 1.12× 1.44×
C2, 1 voice, 32 partials 646 (641–662) 761 (751–812) 1007 (1005–1019) 1.18× 1.56×
C2, 4 voices, 32 partials 2547 (2495–2581) 2960 (2951–3014) 3927 (3904–4052) 1.16× 1.54×
C2, 8 voices, 32 partials 5061 (4975–5227) 5944 (5916–6088) 7896 (7789–7912) 1.17× 1.56×
C6, 1 voice, 32 partials 606 (604–613) 719 (717–735) 953 (943–965) 1.19× 1.57×
C8, 1 voice, 32 partials 534 (534–547) 661 (645–736) 868 (861–881) 1.24× 1.63×

Per partial, from the slope between 8 and 32 partials:

ns per partial added
today 15.3 —
B 19.0 about 3.7 ns for the noise datapath
A 26.5 about another 7.5 ns for the lane read

So the cost of option A is mostly the lane read, not the noise. FrameEnvelope::Value recomputes the clock's next step and its clamps on every call, then does the lerp. Every partial already pays for two such reads today. Computing each clock's frame indices and weight once per sample would make all lane reads cheaper, today's included. That experiment is running now, and its result goes on this ticket.

Also: a noisiness-zero bypass, one compare per partial, would make B nearly free for patches that don't use it. That bypass wasn't measured. These are CPU figures; in fabric, B is an LFSR plus about four multiplies per partial, and A's extra lane is a memory read plus a lerp multiply.

## Measured cost, 2026-09-13 **Setup:** a prototype in `PartialLayer`'s partial loop, behind a compile flag. It adds a per-partial xorshift, a one-pole lowpass (500 Hz), and `amplitude × (a + b·ζ)`. Noisiness is 0.5 on every partial and the noise always runs, so this is the worst case. - **B:** a and b are fixed per note. - **A:** B plus one extra timeline lane read per partial, standing in for a noisiness lane, with a and b from a 256-entry square-root table. **Method:** `//plugin:instrument_bench`, `--config=opt`, AMD Ryzen 7 7840HS, three runs of each mode interleaved. Figures are medians in ns/sample, min–max in brackets. | row | today | B | A | B / today | A / today | |---|---|---|---|---|---| | C2, 1 voice, 8 partials | 279 (276–286) | 304 (304–313) | 371 (364–372) | 1.09× | 1.33× | | C2, 1 voice, 16 partials | 403 (398–409) | 453 (452–455) | 582 (581–587) | 1.12× | 1.44× | | C2, 1 voice, 32 partials | 646 (641–662) | 761 (751–812) | 1007 (1005–1019) | 1.18× | 1.56× | | C2, 4 voices, 32 partials | 2547 (2495–2581) | 2960 (2951–3014) | 3927 (3904–4052) | 1.16× | 1.54× | | C2, 8 voices, 32 partials | 5061 (4975–5227) | 5944 (5916–6088) | 7896 (7789–7912) | 1.17× | 1.56× | | C6, 1 voice, 32 partials | 606 (604–613) | 719 (717–735) | 953 (943–965) | 1.19× | 1.57× | | C8, 1 voice, 32 partials | 534 (534–547) | 661 (645–736) | 868 (861–881) | 1.24× | 1.63× | **Per partial,** from the slope between 8 and 32 partials: | | ns per partial | added | |---|---|---| | today | 15.3 | — | | B | 19.0 | about 3.7 ns for the noise datapath | | A | 26.5 | about another 7.5 ns for the lane read | **So the cost of option A is mostly the lane read, not the noise.** `FrameEnvelope::Value` recomputes the clock's next step and its clamps on every call, then does the lerp. Every partial already pays for two such reads today. Computing each clock's frame indices and weight once per sample would make all lane reads cheaper, today's included. That experiment is running now, and its result goes on this ticket. **Also:** a noisiness-zero bypass, one compare per partial, would make B nearly free for patches that don't use it. That bypass wasn't measured. These are CPU figures; in fabric, B is an LFSR plus about four multiplies per partial, and A's extra lane is a memory read plus a lerp multiply.
Author
Owner

Lane-read experiment, 2026-09-13

Change: FrameEnvelope gains a per-sample Resolve() that works out, once per clock:

  • whether the clock is empty;
  • the origin row (the current frame's values, or the release origin);
  • the target row (the next step's frame);
  • whether it is active, and its weight.

A lane read then becomes a timeline_of_ lookup, an empty/active test and one lerp. Before, every read recomputed NextStep, with its clamps and loop logic, and FrameIndex. PartialLayer::Render calls Resolve() at its top, and Modulated() reads against the result. That is valid because nothing moves a clock between the top of Render and envelope_.Advance() at its end.

Bit-exact: under --config=opt, converging-cloud, octave-lock and shepard render byte-identical with and without the cache (cmp), and //dsp:partial_layer_test passes with it on.

Same bench and method as the comment above. Six binaries run interleaved three times. Medians in ns/sample, min–max in brackets:

row today B A cache cache + B cache + A
C2, 1 voice, 8 partials 279 (278–286) 310 (305–328) 365 (364–366) 199 (199–199) 239 (228–243) 261 (260–263)
C2, 1 voice, 16 partials 403 (402–413) 462 (453–472) 583 (576–586) 268 (267–274) 343 (326–352) 392 (392–399)
C2, 1 voice, 32 partials 649 (647–669) 758 (757–765) 999 (994–1010) 408 (403–408) 543 (523–554) 660 (658–667)
C2, 4 voices, 32 partials 2550 (2524–2586) 2965 (2952–3019) 4013 (3904–4061) 1572 (1563–1599) 2087 (2019–2091) 2591 (2566–2629)
C2, 8 voices, 32 partials 5081 (5044–5166) 5906 (5888–6009) 8026 (7896–8243) 3132 (3124–3189) 4116 (4023–4184) 5182 (5098–5326)
C6, 1 voice, 32 partials 607 (600–619) 716 (715–724) 956 (953–969) 368 (367–373) 494 (486–503) 630 (612–639)
C8, 1 voice, 32 partials 531 (528–542) 654 (645–655) 871 (868–873) 307 (305–308) 436 (424–442) 543 (537–549)

Per partial (slope between 8 and 32 partials), and the one-voice, 32-partial figure against today:

variant ns per partial 1 voice, 32 partials
today 15.4 1.00×
B 18.7 1.17×
A 26.4 1.54×
cache 8.7 0.63×
cache + B 12.7 0.84×
cache + A 16.6 1.02×

What it means:

  • The cache alone takes 37% off today's partial bank, and about 6.7 ns off every partial. It is worth doing whether or not this ticket goes ahead.
  • With the cache, option A, a noisiness lane, costs what today's layer costs. Option B costs less than today's layer.

The catch is where the cache lives. Storing it in each FrameEnvelope::Clock, two pointers plus weight and flags, adds 96 bytes per envelope at four clocks. That trips plugin/program.h's FM pair voice-slot bound (sizeof(FmPairLayer) <= 9024), because the FM pair holds an envelope too. The experiment keeps the cache as a Reads array owned by PartialLayer instead. Whether the other engines gain from it hasn't been measured: they read far fewer lanes per sample than a 32-partial bank.

The cache now has its own ticket, #93, and its own branch: commit 82705b6 on local worktree-lane-read-cache. The noise prototype and the cache together, as measured here, are commit 0a98484 on local worktree-bwe-bench, behind TYMBAL_BWE_MODE and TYMBAL_LANE_CACHE. Neither is pushed.

## Lane-read experiment, 2026-09-13 **Change:** `FrameEnvelope` gains a per-sample `Resolve()` that works out, once per clock: - whether the clock is empty; - the origin row (the current frame's values, or the release origin); - the target row (the next step's frame); - whether it is active, and its weight. A lane read then becomes a `timeline_of_` lookup, an empty/active test and one lerp. Before, every read recomputed `NextStep`, with its clamps and loop logic, and `FrameIndex`. `PartialLayer::Render` calls `Resolve()` at its top, and `Modulated()` reads against the result. That is valid because nothing moves a clock between the top of `Render` and `envelope_.Advance()` at its end. **Bit-exact:** under `--config=opt`, `converging-cloud`, `octave-lock` and `shepard` render byte-identical with and without the cache (`cmp`), and `//dsp:partial_layer_test` passes with it on. **Same bench and method as the comment above.** Six binaries run interleaved three times. Medians in ns/sample, min–max in brackets: | row | today | B | A | cache | cache + B | cache + A | |---|---|---|---|---|---|---| | C2, 1 voice, 8 partials | 279 (278–286) | 310 (305–328) | 365 (364–366) | 199 (199–199) | 239 (228–243) | 261 (260–263) | | C2, 1 voice, 16 partials | 403 (402–413) | 462 (453–472) | 583 (576–586) | 268 (267–274) | 343 (326–352) | 392 (392–399) | | C2, 1 voice, 32 partials | 649 (647–669) | 758 (757–765) | 999 (994–1010) | 408 (403–408) | 543 (523–554) | 660 (658–667) | | C2, 4 voices, 32 partials | 2550 (2524–2586) | 2965 (2952–3019) | 4013 (3904–4061) | 1572 (1563–1599) | 2087 (2019–2091) | 2591 (2566–2629) | | C2, 8 voices, 32 partials | 5081 (5044–5166) | 5906 (5888–6009) | 8026 (7896–8243) | 3132 (3124–3189) | 4116 (4023–4184) | 5182 (5098–5326) | | C6, 1 voice, 32 partials | 607 (600–619) | 716 (715–724) | 956 (953–969) | 368 (367–373) | 494 (486–503) | 630 (612–639) | | C8, 1 voice, 32 partials | 531 (528–542) | 654 (645–655) | 871 (868–873) | 307 (305–308) | 436 (424–442) | 543 (537–549) | **Per partial** (slope between 8 and 32 partials), and the one-voice, 32-partial figure against today: | variant | ns per partial | 1 voice, 32 partials | |---|---|---| | today | 15.4 | 1.00× | | B | 18.7 | 1.17× | | A | 26.4 | 1.54× | | cache | 8.7 | 0.63× | | cache + B | 12.7 | 0.84× | | cache + A | 16.6 | 1.02× | **What it means:** - **The cache alone takes 37% off today's partial bank,** and about 6.7 ns off every partial. It is worth doing whether or not this ticket goes ahead. - **With the cache, option A, a noisiness lane, costs what today's layer costs.** Option B costs less than today's layer. **The catch is where the cache lives.** Storing it in each `FrameEnvelope::Clock`, two pointers plus weight and flags, adds 96 bytes per envelope at four clocks. That trips `plugin/program.h`'s FM pair voice-slot bound (`sizeof(FmPairLayer) <= 9024`), because the FM pair holds an envelope too. The experiment keeps the cache as a `Reads` array owned by `PartialLayer` instead. Whether the other engines gain from it hasn't been measured: they read far fewer lanes per sample than a 32-partial bank. The cache now has its own ticket, #93, and its own branch: commit `82705b6` on local `worktree-lane-read-cache`. The noise prototype and the cache together, as measured here, are commit `0a98484` on local `worktree-bwe-bench`, behind `TYMBAL_BWE_MODE` and `TYMBAL_LANE_CACHE`. Neither is pushed.
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#92
No description provided.