Skip to content

Add plucked_string: a Karplus-Strong string with harmonic partials - #807

Merged
charliehuge merged 2 commits into
devfrom
feat/plucked-string
Oct 26, 2026
Merged

charliehuge merged 2 commits into
devfrom
feat/plucked-string

Conversation

@ii-impy

@ii-impy ii-impy commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Summary

A new operator, plucked_string: an extended Karplus–Strong string. Its controls are resonator's less structure, mapped the same way, so an instrument swaps one for the other by type.

pluck (gate edge) ─► one period of noise ─► 2-pole LP ─► circular position comb ─┐
                                                                                 ▼
in ─► × (1 − loop gain at f₀) ─────────────────────────────────────────────► + ─► line (N samples) ─► allpass (frac) ─► loss LP ─► × g ─┐
                                                                                 ▲                                                    │
                                                                                 └────────────────────────────────────────────────────┘ ─► out
  • Tuning: the allpass is solved exactly at f₀ for what the line and the loss filter's phase delay leave.
  • brightness: partial k decays 1 + h·(k − 1) times faster than the fundamental. The loss pole is fitted to that at the 8th partial, and the same control sets the pluck's colour.
  • damping: the fundamental's ring time, on resonator's curve.
  • in: normalized to unity at f₀.
  • with_lowest_freq(hz): sizes the line, the way Delay::with_max_time does.
  • Cost: a few operations a sample, whatever the pitch or brightness. resonator's cost grows with its mode count.

Wanted by ii-handhelds' Emotional Support Synth for a nylon-string sound. There it renders the four-string graph in 27% fewer instructions than four resonators.

Evidence

16 OpDriver tests in plucked_string.rs, among them:

  • Pitch: within 3 cents of freq from 82 Hz to 2 kHz, dark and bright; partials 2–6 within 5 cents of harmonic.
  • Ring time: the measured ring is within 15% of the mapped one.
  • Position: plucking the middle cuts the 2nd partial over 10× against plucking near the end.
  • Bowing: a sine at f₀ passes within ±1.5 dB of unity.
  • Stability: never grows at damping 1 and brightness 1, from 20 Hz to 8 kHz; finite under NaN controls.

The two bugs the first draft had, each with the test that caught it:

  • Before: a_pluck_rings_at_its_freq failed at 1046.5 Hz, dark. A fixed loss pole cut the fundamental 4% a period, so the string died in ~50 ms.
    After: the loss pole is fitted per partial, and the test passes.
  • Before: a_dark_pluck_has_no_buzz measured an RMS frequency of 870 Hz on a dark 220 Hz pluck. A one-pole burst falls as 1/k, a sawtooth.
    After: a two-pole burst, settled over a period first, measures under 550 Hz.

Gates: cargo test and cargo test -p reuben-core --features bench green; cargo fmt --all --check and cargo clippy --all-targets -- -D warnings clean. reuben describe plucked_string matches the contract. breath-harp.json, with its resonators swapped for plucked_string, passes reuben validate.

Not ear-checked: the tests prove the oracle, not the sound.

Merge Danger

Door: two-way

Additive: a new operator and its bench workload, with no existing operator changed.

Blast Radius: none

sync-docs has not been run for it: authoring.md, the library index and the operator lists don't mention plucked_string yet.

🤖 Generated with Claude Code

ii-impy and others added 2 commits October 22, 2026 14:42
A delay line a period long, closed through a first-order allpass for the fractional period and a
one-pole loss filter. A pluck adds one period of shaped noise to the line. Its controls are the
Resonator's less `structure`, mapped the same way, so one stands in for the other: an instrument
that wants a nylon or steel string rather than a modal bank swaps the type and drops one control.

- `freq` holds to within 3 cents from 82 Hz to 2 kHz at any brightness: the allpass is solved
  exactly at the fundamental for what the line and the loss filter's phase delay leave.
- `brightness` sets how much faster partial k decays than the fundamental, `1 + h·(k - 1)` times,
  and the loss filter's pole is fitted to that at the 8th partial. A fixed pole cut a high, dark
  string's fundamental by 4% a period and killed it in 50 ms.
- `damping` maps to the Resonator's ring times, and `in` is normalized to unity at the
  fundamental, as the Resonator's sustained path is.
- `position` is a circular comb over the period, so plucking the middle cancels the even
  partials whole.
- `with_lowest_freq` sizes the line for a host whose heap is short, as `Delay::with_max_time`
  does: 100 Hz at 48 kHz is 1.9 KB.

Its cost does not grow with pitch or brightness; the Resonator's grows with its mode count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A one-pole lowpass leaves the burst's partials falling as 1/k, a sawtooth's, so a pluck buzzed at
any brightness: a dark 220 Hz pluck measured an RMS frequency of 870 Hz, four times its pitch. A
plucked string's shape falls as 1/k², so the burst now runs through two poles, and its noise gain
is the exact one for two stages rather than one stage squared. The filters also run through the
period once before the kept pass, so the burst ends where it starts and the loop has no seam to
repeat. The same pluck now measures under 550 Hz, which `a_dark_pluck_has_no_buzz` holds.

Pluck gain goes from 0.38 to 0.4: a unit pluck peaks between 0.31 (dark) and 0.87 (bright).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@charliehuge
charliehuge merged commit 933825b into dev Oct 26, 2026
19 checks passed
@charliehuge
charliehuge deleted the feat/plucked-string branch October 26, 2026 20:31
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