Partial bank: Loris-style bandwidth-enhanced partials (per-partial noise) #92
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
bcox/tymbal#92
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
What Loris's own synthesizer does, per its source (
src/NoiseGenerator.C,src/Oscillator.C):Synthesizer.Creuses 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.kMaxPartials's comment (lanes × 4 bytes × frames), not measured.kTimelineSpeedmoves. Thestatic_assert(kTimelineSpeed == 9 + 2 * kMaxPartials)exists to catch exactly this, and every stored matrix destination aimed at a clock's speed shifts with it.B. A static per-partial array, plus one global "noisiness" lane scaling it. It stores one number per partial, alongside
keytrack,panandstart_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:
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.
kTaperStartfades 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: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 κ.This is host-side double arithmetic under
resynth/'s own posture, with none ofdsp/'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.
plugin/render_golden_test.ccdoes not move.kTaperStart.Open questions
Sources
src/NoiseGenerator.C,src/Oscillator.C,src/Synthesizer.CThe 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.
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), andamplitude × (a + b·ζ). Noisiness is 0.5 on every partial and the noise always runs, so this is the worst case.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.Per partial, from the slope between 8 and 32 partials:
So the cost of option A is mostly the lane read, not the noise.
FrameEnvelope::Valuerecomputes 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.
Lane-read experiment, 2026-09-13
Change:
FrameEnvelopegains a per-sampleResolve()that works out, once per clock:A lane read then becomes a
timeline_of_lookup, an empty/active test and one lerp. Before, every read recomputedNextStep, with its clamps and loop logic, andFrameIndex.PartialLayer::RendercallsResolve()at its top, andModulated()reads against the result. That is valid because nothing moves a clock between the top ofRenderandenvelope_.Advance()at its end.Bit-exact: under
--config=opt,converging-cloud,octave-lockandshepardrender byte-identical with and without the cache (cmp), and//dsp:partial_layer_testpasses 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:
Per partial (slope between 8 and 32 partials), and the one-voice, 32-partial figure against today:
What it means:
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 tripsplugin/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 aReadsarray owned byPartialLayerinstead. 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
82705b6on localworktree-lane-read-cache. The noise prototype and the cache together, as measured here, are commit0a98484on localworktree-bwe-bench, behindTYMBAL_BWE_MODEandTYMBAL_LANE_CACHE. Neither is pushed.