Conversation
render_ks reads synth[osc]->phase as an index into the ring, and play_delta keeps the phase across a note-on. An osc switched to KS while its phasor is nonzero (a sounding or releasing osc, or one with trigger_phase set) reads and writes up to 64K samples past a 3.2 KB ring. ks_note_on now zeroes the phase. A retrigger of a sounding KS osc now reads the fresh burst from index 0 rather than from where the old note had got to: the same noise, rotated. TestKarplusStrong retriggers osc 0 three times, so its reference changes.
|
Hi, thank you for this. can you add a working TestKarplusStrong reference wav so the CI passes? Then we can test it on hardware. |
|
This is very exciting. The sounds are fantastic and I will be glad when KS stops being such an orphan in AMY. I'm unclear about the additional indirection between osc and ring. I know it was weird before (with only a single KS buffer), but can't we dynamically spawn KS buffers as oscs are allocated to them? And I'm a bit alarmed that there appears to be a new voice stealing algorithm here (although it sounds good). Why can't we use the regular synth/instrument.c voice management? |
MAX_KS_BUFFER_LEN was the literal 802, one period of 55 Hz at 44.1 kHz. At 48 kHz (AMY_DAISY, the web build) a 55 Hz note needs 872 samples: freq >= 55 admits it and render_ks does not clamp, so MIDI 33-34 read and write up to 70 samples past the ring every block. The ring is now sized from AMY_SAMPLE_RATE and render_ks clamps buflen as ks_note_on already does. At 44.1 kHz the size is still 802 and the output is unchanged. The other end had no bound either: a note above the sample rate gives buflen 0, and ks_note_on then divides the burst's sum by it while render_ks takes the ring index modulo it. ks_note_on now keeps buflen at 1 or more and render_ks skips a frequency at or above the sample rate, so such a note is silent.
62bdaaa to
4e58f18
Compare
Added: the last commit regenerates |
Both are gone in the push I just made. Each KS osc now owns its ring: Why it had the other shape: the PR came out of a longer run of experiments on What changes:
Two fixes on the way:
Checked on the host (fixed point, AddressSanitizer and
Edge cases, to listen to all.mp4Ten short passages rendered from the new branch at the
|
🎛️ AMY HW CI (AMYboard bench)Flashed this PR's AMY (LoadTestChord: 6-voice Juno ✅ PASS — the bench ran the test to completion.
Full chord settled render μs: 2607 (was 2603, Δ +0.2%) (peak 2617, 39 samples) ⬇️ Artifacts: serial log · load trace · report Self-hosted bench (amyboardci). FAIL means only that the test could not run — the load values are informational, with no threshold and no audio compare. See |
|
This passes the tulip and amyboard HWCI , so ok w/ me to merge when dan says so |
|
What do you think about removing Other than that, it all looks very clean! Thanks again. |
A KS note-off called ks_note_off, which zeroed msynth[osc]->amp. hold_and_modify recomputes amp at the start of the next block, so the zero never reached a render, and note_off_clock was never set. A KS osc given an amp envelope therefore held its sustain level after the note-off, and one that decayed to silence was never in release, so the silence check never retired it and render_ks kept running every block. KS now takes the default note-off path: note_off_clock starts the release, so a KS envelope releases and a decayed KS osc is retired. With the stock EG0 the string stops at note-off, as every other wave does; a note meant to ring out gets a release time or no note-off. ks_note_off goes.
ks_buffer is a pool of ks_oscs rings, but render_ks reads the ring at the module-global ks_polyphony_index rather than one tied to the osc. Every sounding KS osc therefore reads and damps the same ring, and each note-on refills it under all of them: three simultaneous notes are as loud as one, and a note-on too quiet to hear raises the voices already ringing by 134% at feedback 0.9. With ks_oscs > 1 it is worse: ks_note_on fills ring N and leaves the cursor at N + 1, the ring render_ks then reads, so a single note renders silence. The ring now belongs to the osc: synth[osc]->ks_ring is allocated at the osc's first KS note-on (MAX_KS_BUFFER_LEN SAMPLEs, ram_caps_oscs) and freed by free_osc, so it lives as long as the osc does and KS polyphony follows voice allocation like any other wave. The pool, ks_init and ks_deinit go away. If the allocation fails, the osc reports through amy_oom and stays silent. ks_oscs is now only a switch: 0 turns KS off, as before, and any other value no longer sets a ring count. Nothing is allocated until a KS note plays, where the pool held ks_oscs rings from amy_start.
The two-tap average in render_ks is centred half a sample ahead of the read, so the loop delay is buflen - 0.5 with buflen = floor(AMY_SAMPLE_RATE / freq): 0.5 to 1.5 samples short of the period. Every KS note renders sharp, +12.6 cents at A4 and up to +77 cents across A5-G#6 at 44.1 kHz. A first-order allpass after the average adds the missing D = period + 0.5 - buflen, in [0.5, 1.5), as its delay at low frequencies, with a = (1 - D)/(1 + D) set once per block (the tuning allpass in Jaffe and Smith, "Extensions of the Karplus-Strong Plucked-String Algorithm", 1983). It is the one-multiply form, with one SAMPLE of state per osc, cleared at note-on. The fundamental lands within 0.2 cents up to G#6. The allpass delay falls slightly toward high frequencies, so upper partials of high notes sit a few cents off exact harmonics (mode 8 at A5: -2.8 cents). The sample loop splits the block where the ring wraps, so its inner loop has no wrap test and walks the ring and the output with pointers. With the allpass it is a 26-instruction hardware loop on esp32s3 at -O2, against 33 instructions before this commit, plus a few dozen per wrap, once or twice a block at mid pitches. Two side effects: render_ks returns the block's absolute peak, as the other render_* functions do (the first sample used to seed it with its sign), and after an upward pitch step that leaves phase past the new buflen, that sample takes ring[0] as its neighbour and the read restarts at 0.
render_ks multiplied the whole block by the end-of-block msynth[osc]->amp and never touched last_amp; every other renderer ramps from last_amp to amp across the block. An amp envelope on a KS osc therefore stepped once per block, which zippers a fast decay and cuts a ringing string at a release. The gain now ramps like the others. At constant gain the output is bit-identical to the previous commit. The inner loop stays a hardware loop on esp32s3 (28 instructions with the ramp, 26 without).
KS ignores duty, and every note starts from a noise burst whose harmonic balance is random per pluck: over eight plucks of A2, h2 sits anywhere from 14 dB below h1 to 7 dB above it. ks_note_on now reads duty_coefs[COEF_CONST] as a pluck position b = |duty - 0.5|. The burst becomes a mix of the noise, combed to notch the harmonics a pluck at b leaves out, and a zero-mean pulse b of the period wide, the force an ideal pluck puts on the bridge: the same notches on a 1/n slope, the same on every note. Pulse share and comb depth ease in below b = 0.1 so the first steps off 0.5 stay close to the plain burst; a full-depth short comb is nearly a differentiator and strips the low harmonics. The reset default of 0.5 gives b = 0 and runs the old mean-removal loop, so existing KS output is bit-exact, including its use of the random stream. The work is at note-on only, in the pass that already removed the mean, fixed point per sample with scales from the fill's expected RMS; render_ks is untouched.
The test retriggers one KS osc four times and sends a note-off at 900 ms. Its render changes with the ring index reset at note-on, with the tuning allpass, and at the note-off, where the stock EG0 now stops the string.
4e58f18 to
c701cc8
Compare
Summary
Seven commits to Karplus-Strong, one change each, in an order that lets you
take any prefix and most of them alone, and an eighth that regenerates the
test reference:
maindutyCommits 1 and 2 fix out-of-bounds ring access; 4 fixes polyphonic KS, where
today every KS osc shares one ring and
ks_oscs > 1renders silence; 5 fixesthe tuning of every note. If you would rather take them as separate PRs, I can
split along the table.
TestKarplusStrong's reference changes with commits 1,3 and 5; the last commit regenerates it.
Listen first
1-ks-riff.mp4
A short riff on six KS oscs, one per string (a new note on
a string retriggers it, notes on different strings ring over each other),
through a light soft clip on the output bus (
dist_clip, drive 1.5). Onegain for all three:
main2-ks-tuning.mp4
Where the harmonics land. One note each at A3, A4, A5 and
A6,
mainthen this PR: each panel is one harmonic, in cents from the exactmultiple of the note's pitch (dashed line). On
mainall four sit sharp bythe same amount, 8 cents at A3 to 39 at A6; with this PR they sit on the
line. The audio is the string plus a quiet sine at the exact pitch, so the
string on
mainbeats against it. Spectra from one KS osc alone, 44.1 kHz,feedback 0.999.
1. Start a note at ring index 0
render_ksreadssynth[osc]->phaseas an index into the ring, andplay_deltakeeps the phase across a note-on. An osc switched to KS whileits phasor is nonzero (a sounding or releasing osc, or one with
trigger_phaseset) reads and writes up to 64K samples past a 3.2 KB ring.Host sim under AddressSanitizer: a sounding SAW osc switched to KS segfaults
in
render_ksonmainand plays with this commit.A retrigger of a sounding KS osc now reads the fresh burst from index 0
instead of from where the old note had got to: the same noise, rotated.
TestKarplusStrongretriggers osc 0 three times, so its reference changeshere.
2. Size the ring from the sample rate
MAX_KS_BUFFER_LENwas the literal 802, one period of 55 Hz at 44.1 kHz. At48 kHz (
AMY_DAISY, the web build) a 55 Hz note needs 872 samples:freq >= 55admits it andrender_ksdoes not clamp, so MIDI 33-34 read andwrite up to 70 samples past the ring every block. The size now comes from
AMY_SAMPLE_RATE, andrender_ksclampsbuflenasks_note_onalreadydid. The 55 Hz floor is unchanged, and at 44.1 kHz the size is still 802
and the output is unchanged.
The other end had no bound either. A note above the sample rate gives
buflen0:ks_note_ondivides the burst's sum by it andrender_kstakesthe ring index modulo it (host sim on
main,freq=50000:UndefinedBehaviorSanitizer reports the division by zero in
ks_note_on).ks_note_onnow keepsbuflenat 1 or more andrender_ksskips afrequency at or above the sample rate, so such a note is silent.
3. Release a KS osc at note-off
The note-off path sent KS to
ks_note_off, which zeroedmsynth[osc]->amp.hold_and_modifyrecomputes amp at the start of the next block, so the zeronever reached a render, and
note_off_clockwas never set:never retired it and
render_kskept running every block.KS now takes the default note-off path, so EG0 releases it like any other
wave. With the stock EG0 the string stops at note-off; a note meant to ring
out gets a release time, or no note-off.
ks_note_offgoes.Host build, KS at 220 Hz, feedback 0.998, output RMS over the 20 blocks before
and after a note-off:
mainbefore / afterA0,1,0,1,200,0(200 ms release)TestKarplusStrongsends a note-off at 900 ms, so its reference changes here.4. A ring per KS osc
What one ring does to any polyphonic KS, measured on
main(host sim, fixedpoint, 44.1 kHz;
render_ksandks_note_onare unchanged between 1.2.178,where these were taken, and
main):averaging write to the same ring every sample. Three simultaneous notes
measure 1.12x / 0.97x / 1.03x the RMS of one note at feedback 0.9 / 0.99 /
1.0, where independent voices give about 1.73x. In a strummed four-note
chord at feedback 0.995, each string loses 17-20 dB over the second after
one of them is re-plucked; the loop's own decay over that second is 5 dB
(A2) to 10 dB (A3).
ks_note_onrefills the whole ring under all of them. With four KS voices ringing at
feedback 0.9, a fifth note-on at velocity 0.01, inaudible on its own,
raises their output by 134%.
under an eight-note melody on three other KS oscs loses 31.5 dB of its
fundamental over 2.5 s; the loop's own decay over that time is 12.3 dB.
ks_oscs > 1does not help today.ks_note_onfills ring N and leavesthe cursor at N + 1, the ring
render_ksthen reads. Atks_oscs = 4asingle note renders silence, and later notes play a ring filled three
note-ons earlier. Under UndefinedBehaviorSanitizer the first note at
ks_oscs = 8reads the never-written ring and overflows.Each KS osc now has its own ring,
synth[osc]->ks_ring, allocated at theosc's first KS note-on and freed by
free_osc. With this commit, at thedefault config:
main,ks_oscs = 1main,ks_oscs = 4ks_oscs = 4ks_note_on, during delta application, whereensure_osc_allocdalreadyallocates the osc itself, and
free_oscreleases both. A reset or a switchto another wave keeps the ring until the osc is freed. It is zeroed once,
at allocation, because a pitch drop after the note-on lengthens the loop
past what the burst filled.
is
instrument.c's decision, as for any other wave.ks_oscs. No longer a ring count: 0 turns KS off, as before, and anyother value turns it on. A single KS osc renders as on
main; two or moreno longer share a ring, so their output changes at the default config.
amy_start, wheremainallocatesks_oscsrings. One ring of
MAX_KS_BUFFER_LENSAMPLEs per osc that has played aKS note: 3.2 KB at 44.1 kHz, so 32 KS voices hold 103 KB. The osc block
itself is about 0.7 KB on a 32-bit build, so a one-osc KS voice costs about
what a five-osc voice of any other wave does. Nothing caps the number of
rings other than
amy_oom. Rings come fromram_caps_oscs, which is PSRAMon Tulip and AMYboard, where
ks_initused plainmalloc;render_kswith the ring in PSRAM is not measured.
ks_note_onreports throughamy_oomand the oscstays silent.
render_ksreads only its own osc's ring.4-ks-ring.mp4
A strummed A2-E3-A3-C#4 with the C#4 re-plucked at 1.6 s,
then an A2 held under an eight-note melody:
mainatks_oscs1 and 4, thenthis commit. One gain for all three.
5. Tuning allpass
The two-tap average in
render_ksis centred half a sample ahead of theread, so the loop delay is
buflen - 0.5. Withbuflen = floor(fs / freq)the loop is 0.5 to 1.5 samples short and every note is sharp. A first-order
allpass after the average adds the missing
d = period + 0.5 - buflen, in[0.5, 1.5), as its delay at low frequencies:
a = (1 - d)/(1 + d), set onceper block. This is the tuning allpass from Jaffe and Smith ("Extensions of
the Karplus-Strong Plucked-String Algorithm", Computer Music Journal 7(2),
1983), in the one-multiply form, with one
SAMPLEof state per osc.Fundamental error per octave at 44.1 kHz, from the loop's phase delay:
Host sim (fixed point, feedback 0.998, pitch by autocorrelation minus a SINE
at the same note): before matches the table within 0.03 c at A1-A6; after
reads within 1.2 c from A1 to E7. The allpass delay falls slightly toward
high frequencies, so upper partials of high notes land a little flat of the
exact harmonics: at A5 harmonics 2-4 are within 0.4 c and mode 8 reads
-2.8 c; at A6 harmonic 2 reads -9.6 c and harmonic 4 -4.9 c (fundamental
-0.1 c).
wrap test and walks the ring and the output with pointers; the sample at the
wrap takes
ring[0]as its neighbour. On esp32s3 at -O2 the inner loop is a26-instruction hardware loop with the allpass, against a 33-instruction
plain loop on
main, plus a few dozen instructions per wrap. Per block:one float divide for
a.costs about 17,400 cycles per 256-sample block on
main, and about 2,700fewer with commits 4-5 (four voices at 110-330 Hz). The saving shrinks at
high pitches, where the ring wraps more often per block: at 3.5 kHz it is
about 800 cycles smaller than at 220 Hz.
render_ksnow returns the block's absolute peak, tracked theway the other
render_*functions do. The first sample used to seed itwith its sign, so the return could be negative or under the true peak.
afollows the pitch block by block and the statecarries across;
|a|stays within (-0.2, 0.33]. A +-1 octave pitch LFO at20 and 200 Hz runs clean under the sanitizers.
phasepast thenew
buflen, that sample takesring[0]as its neighbour and the readrestarts at 0: one sample, once.
TestKarplusStrong's reference changes here.6. Gain ramp across the block
render_ksmultiplied the whole block by the end-of-blockmsynth[osc]->ampand never touched
last_amp; every other renderer ramps fromlast_amptoampacross the block. An amp envelope on a KS osc therefore stepped onceper block, which zippers a fast decay and cuts a ringing string at a release
(commit 3 makes that release reachable).
At constant gain the output is bit-identical to commit 5. The inner loop
stays a hardware loop on esp32s3 (28 instructions). On the ESP32-S3 the ramp
costs about 560 cycles per voice per block, so commits 4-6 together cost
about 2,200 fewer than
main. Written with an index counter, the same rampspills registers in this loop and costs about 2,600; the pointer walk is
what keeps it cheap.
7. Pluck position from
dutyKS ignores
duty, and every note starts from a noise burst whose harmonicbalance is random per pluck: over eight plucks of A2, h2 lands anywhere from
14 dB below h1 to 7 dB above it.
b = |duty - 0.5|is now where the stringis plucked, as a fraction of its length; the burst becomes a mix of the
noise, combed at
b, and a zero-mean pulsebof the period wide (an idealpluck's force on the bridge: notches at multiples of
1/bon a 1/n slope,the same on every note). The default 0.5 is today's burst, bit-exact,
including its use of the random stream.
Why
duty, and why folded at 0.5. Every osc resets to duty 0.5, sothe fold keeps existing KS patches unchanged. Read literally (duty = pulse
width), the default would become a hollow mid-string pluck and change every
KS sound.
dutyand1 - dutygive the same magnitude spectrum (pluckingfrom either end), so the fold gives up no timbre. One behavior change: an
osc switched to KS from another wave keeps its duty, so a KS note that
inherits a duty other than 0.5 now sounds plucked off-centre. No preset in
patches.hsets duty on a KS osc.Constant coefficient only, at note-on. The pluck position is fixed per
note.
msynth[osc]->dutyis recomputed only while the osc sounds, so atnote-on it still holds the previous note's value.
Ease-in below b = 0.1. Pulse share and comb depth ramp from 0 at b = 0
to
KS_PICK_MIX(0.6) and full depth atKS_PICK_RAMP(0.1). A full-depthcomb at small
bis close to a differentiator (gain2 sin(n pi b)atharmonic n): without the ramp, duty 0.49 would put h1 at -24 dB.
Level. The burst is scaled to the fill's expected RMS; burst RMS stays
within 0.2 dB of today's at duty 0.45, 0.37 and 0.25. Over the note it is
louder: energy moves into the low harmonics, which the two-point average
loses slowest, so the first second of a pluck at duty 0.37 is 7.5 dB (A2)
and 6.9 dB (A4) above today's.
Fixed mix. 0.6 pulse to 0.4 noise was chosen by ear on guitar-like
material. There is no separate control for it.
One pass, in place. The obvious shape is to copy the ring, comb from
the copy (
x[i] - g x[i - M]), then run a second pass for the rescale andthe pulse.
ks_pluckinstead folds everything into the pass that alreadyremoved the mean, so the only extra work over
mainis that one pass, atnote-on, fixed point per sample:
(x[i] - mean) - g (x[i - M] - mean) = x[i] - g x[i - M] - (1 - g) mean,so the separate mean-removal loop goes away when
M > 0.i -> i - M (mod buflen)splits the ring intogcd(buflen, M)cycles.Walking each cycle in that direction, every step reads
x[i - M], whichis the element the next step overwrites, so it is still unmodified. The
only element overwritten before its last read is the cycle's first one,
which is saved before the walk and used at the cycle's last step.
0 < M < buflen, whichks_note_onchecks (anything elsetakes the plain mean-removal loop), and on the gcd loop giving the cycle
count. Checked on the host: bit-identical to copy-then-comb for every
buflenfrom 2 to 873 and everyMfrom 1 tobuflen - 1(380,628pairs), under AddressSanitizer and UndefinedBehaviorSanitizer.
MAX_KS_BUFFER_LENSAMPLEs (3.2 KB at 44.1 kHz) per note-on, on thestack or preallocated; the in-place walk saves that memory, not time.
About 23,600 cycles per plucked note-on on the ESP32-S3;
render_ksisuntouched.
Eight plucks per note at velocity 1, harmonic levels 50-300 ms after note-on
(host sim, fixed point, 44.1 kHz, feedback 0.995):
6-ks-pluck.mp4
A2, A3, A4, A5, each soft then hard:
main, then duty 0.45,0.37 and 0.25. One gain for all four.
Verification
under AddressSanitizer and UndefinedBehaviorSanitizer, with the shift check
off because
log2_exp2.calready has a negative shift that trips it. 39scenarios at
ks_oscs1 and 8: pitches 55 Hz-40 kHz, pitch jumps past ashrunk ring, +-1 octave pitch LFOs, note-off envelopes, a retrigger storm,
11 KS oscs on 8 rings,
TestKarplusStrong's sequence, feedback 0 and 1,and a SAW osc switched to KS. Commits 1-3 fail only the known
ks_oscs > 1read, andmainalso fails the SAW-to-KS overrun. Commit 4onward has no findings. Each commit changes exactly the scenarios the
summary table lists.
that, commits 4-7 each render bit-identical to their earlier version run
with enough rings (
ks_oscs = 16) over five sequences, and a reset, a waveswitch, a breakpoint grow, a voice release, a stop and restart and a pitch
drop after the note-on run clean under both sanitizers. The 39 scenarios
above and the on-target counts below were taken with the pool and not
repeated; the sample loop compiles to the same esp32s3 hardware loop,
instruction for instruction.
firmware and selected per scene; boot-to-boot noise +-0.01%. Not measured
on RP2040, where the per-block
ais one soft-float divide per KS osc.src/*.cbuild warning-free with the Makefile's flags.make testnot run in full.TestKarplusStrongpasses against the regeneratedreference.
render_kssample loop was reached by compiling eachcandidate, reading the esp32s3 assembly for instruction count and whether
it is a hardware loop, and only then timing it on target. The gain ramp
with an
icounter is 41 instructions, spills registers and is not ahardware loop (about +2,600 cycles per voice per block over the same loop
without the ramp). Walking the ring and output with pointers gives 34
instructions, still no hardware loop (about +800). Splitting the block at
the ring wrap, so the loop has no wrap test, gives 28 instructions as a
hardware loop (about +560). Every step renders the same output as the
first in all on-target scenes, envelope scenes included. Only esp32s3 was
inspected; other compilers may generate different code from the pointer
walk and the split. All variants tried:
icounterbufpointer walk"= v4" and "= v3" mean bit-identical to that variant in all six scenes,
envelope scenes included. Variant 10 is the shipped shape.
To try it: