Skip to content

oscillators: KS fixes, a ring per KS osc, tuning, and pluck position from duty - #1204

Open
rt-rtos wants to merge 8 commits into
shorepine:mainfrom
rt-rtos:feat/ks-string
Open

rt-rtos wants to merge 8 commits into
shorepine:mainfrom
rt-rtos:feat/ks-string

Conversation

@rt-rtos

@rt-rtos rt-rtos commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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:

# change output applies to main
1 start a note at ring index 0 changes on a retrigger alone
2 size the ring from the sample rate unchanged at 44.1 kHz alone
3 release a KS osc at note-off changes at a note-off alone
4 a ring per KS osc unchanged for a single KS osc on 1-2 (context only)
5 tuning allpass every note needs 4
6 gain ramp across the block unchanged at constant gain needs 5
7 pluck position from duty unchanged at the default duty on 1-4

Commits 1 and 2 fix out-of-bounds ring access; 4 fixes polyphonic KS, where
today every KS osc shares one ring and ks_oscs > 1 renders silence; 5 fixes
the 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). One
gain for all three:

  1. main
  2. this PR
  3. this PR, duty 0.37
2-ks-tuning.mp4

Where the harmonics land. One note each at A3, A4, A5 and
A6, main then this PR: each panel is one harmonic, in cents from the exact
multiple of the note's pitch (dashed line). On main all four sit sharp by
the 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 main beats against it. Spectra from one KS osc alone, 44.1 kHz,
feedback 0.999.

1. Start a note at ring index 0

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.
Host sim under AddressSanitizer: a sounding SAW osc switched to KS segfaults
in render_ks on main and 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.
TestKarplusStrong retriggers osc 0 three times, so its reference changes
here.

2. Size the ring from the sample rate

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 size now comes from
AMY_SAMPLE_RATE, and render_ks clamps buflen as ks_note_on already
did. 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
buflen 0: ks_note_on divides the burst's sum by it and render_ks takes
the ring index modulo it (host sim on main, freq=50000:
UndefinedBehaviorSanitizer reports the division by zero in ks_note_on).
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.

3. Release a KS osc at note-off

The note-off path sent KS to 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 held its sustain level after the note-off;
  • a KS osc 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, 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_off goes.

Host build, KS at 220 Hz, feedback 0.998, output RMS over the 20 blocks before
and after a note-off:

main before / after this commit before / after
stock EG0 193.4 / 179.3 194.7 / 20.7
A0,1,0,1,200,0 (200 ms release) 193.4 / 179.3 194.7 / 91.6

TestKarplusStrong sends 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, fixed
point, 44.1 kHz; render_ks and ks_note_on are unchanged between 1.2.178,
where these were taken, and main):

  • Voices damp each other. Every sounding KS osc applies the loop's
    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).
  • Every KS note-on re-excites every sounding KS voice. ks_note_on
    refills 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%.
  • A held note is overwritten by the notes played over it. An A2 held
    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 > 1 does not help today. ks_note_on fills ring N and leaves
    the cursor at N + 1, the ring render_ks then reads. At ks_oscs = 4 a
    single note renders silence, and later notes play a ring filled three
    note-ons earlier. Under UndefinedBehaviorSanitizer the first note at
    ks_oscs = 8 reads the never-written ring and overflows.

Each KS osc now has its own ring, synth[osc]->ks_ring, allocated at the
osc's first KS note-on and freed by free_osc. With this commit, at the
default config:

check main, ks_oscs = 1 main, ks_oscs = 4 this commit
3 notes vs 1 note, fb 0.9 / 0.99 / 1.0 1.12x / 0.97x / 1.03x - / 1.07x / 0.95x 1.65x / 1.48x / 1.63x
5th note-on at velocity 0.01 over 4 ringing, fb 0.9 +134% +299% -44.5% (own decay)
strum: A2 fundamental, 1 s after another string is re-plucked -19.0 dB -19.6 dB -4.9 dB
held A2 fundamental under the melody, over 2.5 s -31.5 dB -18.4 dB -12.4 dB
single note at ks_oscs = 4 - silent plays
  • Lifetime. The ring lives as long as the osc. It is allocated in
    ks_note_on, during delta application, where ensure_osc_allocd already
    allocates the osc itself, and free_osc releases both. A reset or a switch
    to 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.
  • Voices. Nothing in KS picks or steals a ring. Which osc plays a note
    is instrument.c's decision, as for any other wave.
  • ks_oscs. No longer a ring count: 0 turns KS off, as before, and any
    other value turns it on. A single KS osc renders as on main; two or more
    no longer share a ring, so their output changes at the default config.
  • Memory. Nothing at amy_start, where main allocates ks_oscs
    rings. One ring of MAX_KS_BUFFER_LEN SAMPLEs per osc that has played a
    KS 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 from ram_caps_oscs, which is PSRAM
    on Tulip and AMYboard, where ks_init used plain malloc; render_ks
    with the ring in PSRAM is not measured.
  • Allocation failure. ks_note_on reports through amy_oom and the osc
    stays silent.
  • Threads. render_ks reads 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: main at ks_oscs 1 and 4, then
this commit. One gain for all three.

5. Tuning allpass

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(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 once
per 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 SAMPLE of state per osc.

Fundamental error per octave at 44.1 kHz, from the loop's phase delay:

notes before after
A1-G#2 +1.9 to +5.1 c 0.00 c
A2-G#3 +3.2 to +7.3 c 0.00 c
A3-G#4 +6.5 to +16.7 c 0.00 c
A4-G#5 +12.6 to +36.4 c -0.01 to +0.02 c
A5-G#6 +21.3 to +77.1 c -0.10 to +0.19 c
A6-G#7 +38.9 to +170.7 c -0.79 to +2.24 c

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).

3-ks-tuning-cents
  • Loop. The block is split where the ring wraps, so the inner loop has no
    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 a
    26-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.
  • Cost on target. ESP32-S3, 240 MHz, fixed point, 48 kHz: a KS voice
    costs about 17,400 cycles per 256-sample block on main, and about 2,700
    fewer 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.
  • Peak. render_ks now returns the block's absolute peak, tracked the
    way the other render_* functions do. The first sample used to seed it
    with its sign, so the return could be negative or under the true peak.
  • Pitch modulation. a follows the pitch block by block and the state
    carries across; |a| stays within (-0.2, 0.33]. A +-1 octave pitch LFO at
    20 and 200 Hz runs clean under the sanitizers.
  • Wrap after a pitch step. When an upward step leaves phase past the
    new buflen, that sample takes ring[0] as its neighbour and the read
    restarts at 0: one sample, once.
  • Reference test. TestKarplusStrong's reference changes here.

6. Gain ramp across the block

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
(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 ramp
spills registers in this loop and costs about 2,600; the pointer walk is
what keeps it cheap.

7. Pluck position from duty

KS ignores duty, and every note starts from a noise burst whose harmonic
balance 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 string
is plucked, as a fraction of its length; the burst becomes a mix of the
noise, combed at b, and a zero-mean pulse b of the period wide (an ideal
pluck's force on the bridge: notches at multiples of 1/b on 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, so
    the 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. duty and 1 - duty give the same magnitude spectrum (plucking
    from 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.h sets duty on a KS osc.

  • Constant coefficient only, at note-on. The pluck position is fixed per
    note. msynth[osc]->duty is recomputed only while the osc sounds, so at
    note-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 at KS_PICK_RAMP (0.1). A full-depth
    comb at small b is close to a differentiator (gain 2 sin(n pi b) at
    harmonic 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 and
    the pulse. ks_pluck instead folds everything into the pass that already
    removed the mean, so the only extra work over main is that one pass, at
    note-on, fixed point per sample:

    • The mean is folded into the comb:
      (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.
    • The comb runs in place without a scratch buffer. The index map
      i -> i - M (mod buflen) splits the ring into gcd(buflen, M) cycles.
      Walking each cycle in that direction, every step reads x[i - M], which
      is 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.
    • It relies on 0 < M < buflen, which ks_note_on checks (anything else
      takes 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
      buflen from 2 to 873 and every M from 1 to buflen - 1 (380,628
      pairs), under AddressSanitizer and UndefinedBehaviorSanitizer.
    • If you would rather have the obvious shape, a scratch copy costs at most
      MAX_KS_BUFFER_LEN SAMPLEs (3.2 KB at 44.1 kHz) per note-on, on the
      stack or preallocated; the in-place walk saves that memory, not time.

    About 23,600 cycles per plucked note-on on the ESP32-S3; render_ks is
    untouched.

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):

A2 main A2 duty 0.37 A4 main A4 duty 0.37
spread of h1..h4 over the plucks 10.5-21.2 dB 0.6-3.8 dB 12.1-16.0 dB 2.1-6.6 dB
h2 - h1, min to max -14.1 to +7.1 -0.9 to +0.9 -8.4 to +8.3 -6.7 to +0.8
burst RMS (first period) -33.7 dBFS -33.6 -33.6 -33.4
RMS over the 1 s pluck -45.0 dBFS -37.5 -50.3 -43.4
5-ks-pluck-harmonics
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

  • Host sim, fixed point, 44.1 kHz. Every commit was built from its own tree
    under AddressSanitizer and UndefinedBehaviorSanitizer, with the shift check
    off because log2_exp2.c already has a negative shift that trips it. 39
    scenarios at ks_oscs 1 and 8: pitches 55 Hz-40 kHz, pitch jumps past a
    shrunk 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 > 1 read, and main also fails the SAW-to-KS overrun. Commit 4
    onward has no findings. Each commit changes exactly the scenarios the
    summary table lists.
  • Commit 4 was reworked in review from a ring pool to a ring per osc. After
    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 wave
    switch, 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.
  • ESP32-S3 on-target cycle counts: all variants compiled side by side in one
    firmware and selected per scene; boot-to-boot noise +-0.01%. Not measured
    on RP2040, where the per-block a is one soft-float divide per KS osc.
  • src/*.c build warning-free with the Makefile's flags.
  • make test not run in full. TestKarplusStrong passes against the regenerated
    reference.
  • Loop shape: the render_ks sample loop was reached by compiling each
    candidate, 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 i counter is 41 instructions, spills registers and is not a
    hardware 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:
Variant Sample loop Loop insns Cycles per voice per block vs v3 (220 / 1760 / 3520 Hz) Output
3 tuning loop, no ramp (baseline) 31 0 reference
4 + ramp, i counter 41 +2,590 / +2,585 / +2,584 reference for the ramp
7 + ramp, buf pointer walk 34 +793 / +784 / +784 = v4
8 pointer walk, no ramp 31 -3 / -2 / -3 = v3
9 branch per block: v3 or v4 loop 31 / 41 +40 constant gain, +2,042 under an envelope (220 Hz) = v4
10 wrap-free runs + ramp 28, hardware loop -1,391 / -937 / -392 = v4
11 wrap-free runs, no ramp 26, hardware loop -1,952 / -1,598 / -1,148 = v3
12 stepped ramp, 8 x 32 31 inner +186 / +182 / +181 differs under an envelope
13 two passes via scratch 22 + 15 +1,839 / +1,837 / +1,831 = v4

"= 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:

amy.send(osc=0, wave=amy.KS, feedback=0.995, duty=0.37)
amy.send(osc=0, note=45, vel=1)

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.
@bwhitman

Copy link
Copy Markdown
Collaborator

Hi, thank you for this. can you add a working TestKarplusStrong reference wav so the CI passes? Then we can test it on hardware.

@dpwe

dpwe commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

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.
@rt-rtos

rt-rtos commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Hi, thank you for this. can you add a working TestKarplusStrong reference wav so the CI passes? Then we can test it on hardware.

Added: the last commit regenerates tests/ref/TestKarplusStrong.wav, the only
reference this PR changes.

@rt-rtos

rt-rtos commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

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?

Both are gone in the push I just made. Each KS osc now owns its ring:
synth[osc]->ks_ring, allocated at the osc's first KS note-on and freed in
free_osc. ks_alloc_row, the owner table and ks_index are deleted, and
voice management is instrument.c's alone.

Why it had the other shape: the PR came out of a longer run of experiments on
my synth (loop allpass stages, stiffness, resonators, pluck shaping), each
measured for cost against what it does to the sound. Some of them needed knobs
for the ring count, the voice count and where the rings are allocated. The
fixed pool is a remnant of that, and the stealing existed only because a fixed
pool can hold fewer rings than there are KS oscs. AMY has no such constraint,
and I should have caught it before the PR went out.

What changes:

  • ks_oscs is no longer a ring count: 0 turns KS off, anything else turns it
    on. I left the field and AMY_KS_OSCS in place; say if you would rather
    rename or drop them.
  • Polyphonic KS works at the default config. A single KS osc renders as
    before.
  • Memory: nothing at amy_start, then 3.2 KB (at 44.1 kHz) per osc that has
    played a KS note, held until the osc is freed. 32 KS voices hold 103 KB.
    The only limit is amy_oom, which leaves the osc silent. ks_oscs could
    cap the ring count if you want a ceiling; a note past the cap would be
    silent.
  • Rings come from ram_caps_oscs: PSRAM on Tulip and AMYboard, where
    ks_init used plain malloc. I have not measured render_ks with the
    ring in PSRAM; ram_caps_block would keep it where it was.
  • The tuning allpass state is now synth[osc]->ks_tune_state.

Two fixes on the way:

  • The ring is zeroed at allocation. On main, a pitch bend below the note-on
    pitch reads ring memory that was never written.
  • Commit 2: on main a KS note above the sample rate (freq=50000) divides
    by zero in ks_note_on. It is now silent.

Checked on the host (fixed point, AddressSanitizer and
UndefinedBehaviorSanitizer):

  • Commits 4-7 render bit-identical to their previous versions given enough
    rings (ks_oscs = 16).
  • Osc reset, wave switch, breakpoint grow, voice release through FREE_OSC,
    stop and restart, pitch drop after the note-on: no findings, no leaked
    ring.
  • The fifth-note row of the table in section 4 goes from +9.5% to -44.5%,
    the four voices' own decay.
  • The esp32s3 sample loop is still a hardware loop. Not repeated: on-device
    cycle counts.

Edge cases, to listen to

all.mp4

Ten short passages rendered from the new branch at the
default config, played back to back, peak-normalized:

at passage what plays
0:00 01-voice-steal-3-voices 16 notes on a three-voice KS synth, then five at once
0:05 02-steal-with-envelope the same run with a 300 ms release and note-offs
0:08 03-pitch-bend-chord a held chord under pitch_bend: +2 semitones, -1 octave, back
0:12 04-vibrato-then-dive 5 Hz vibrato, then a one-octave dive and climb
0:18 05-portamento-legato portamento=150, legato notes on one osc
0:21 06-retrigger-storm one osc retriggered every 30 ms over a second osc holding A1
0:24 07-wave-switch a sounding osc switched SAW, KS, PULSE, KS
0:27 08-duty-sweep one note at duty 0.5, 0.45, 0.37, 0.25, 0.1
0:31 09-range-extremes A1, C8, 13.3 kHz, a note under the 55 Hz floor, A2
0:34 10-twelve-voices-run 32 notes on a twelve-voice KS synth
  • Nine render bit-identical to the previous push given enough rings
    (ks_oscs = 32). The bent chord at 0:08 differs, because the previous push
    read unwritten ring memory there.
  • Pitch bends and dives add a little broadband noise, as on main: the ring
    length is a whole number of samples, so it changes in steps as the pitch
    moves. It is quieter with this PR than on main.

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

🎛️ AMY HW CI (AMYboard bench)

Flashed this PR's AMY (LoadTestChord: 6-voice Juno patch=1, one held note every 2 s) onto the physical AMYboard and measured the smoothed render load as the chord grows — back-to-back with the same sketch built at the PR's merge base, so Δ is this PR's own cost.

✅ PASS — the bench ran the test to completion.

notes held main @ 00141f2 this PR Δ
1 991 1000 +9
2 1148 1152 +4
3 1717 1717 +0
4 1892 1884 -8
5 2483 2499 +16
6 2601 2604 +3

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 tools/arduino_loadsweep/.

@bwhitman

bwhitman commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

This passes the tulip and amyboard HWCI , so ok w/ me to merge when dan says so

@dpwe

dpwe commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

What do you think about removing eg0_is_default_gate? I'd rather not introduce such a distinct, parameter-dependent change in behavior. Also, I think it assumes but doesn't check that eg0 is driving the amp envelope; of course, it usually does, but it doesn't have to.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants