Skip to content

env: adsr follows a wired trigger in every phase - #337

Merged
mishan merged 3 commits into
masterfrom
adsr-fix
Oct 4, 2026
Merged

mishan merged 3 commits into
masterfrom
adsr-fix

Conversation

@mishan

@mishan mishan commented Oct 4, 2026

Copy link
Copy Markdown
Owner

env::adsr had three bugs whenever its trigger was wired:

  1. It ignored the trigger during attack and decay. It started with its voice, and a note released during the attack still played the whole attack and decay before releasing.
  2. Retrigger was by level, not edge. In release and after it, any trigger above 0 restarted it, so a decay to s = 0 under a held key looped.
  3. Release started from the sustain level, not from where the envelope was, which clicks when letting go mid-attack.

The change

The first test is whether the trigger is wired or written as a constant (read from the node's own arg type).

  • Wired: the envelope waits for the trigger to rise, releases on a fall from any phase and from the current level, and retriggers only on a rising edge, also from the current level. The curve shapes are unchanged, and edges take effect on the next sample as before, so a note held past its decay is sample-for-sample identical.
  • Constant or unwired: the old code path, unchanged. The 8 shipped one-shots that write trigger = 0 (brass's bend, bd10, dxbell, …) are built on it.

What changes audibly

I rendered every piece (60 s) and every instrument graph (a 30 ms note, then a 1.3 s note) with the old and new plugin:

  • Unchanged: 65 of 110 renders, bit for bit.
  • Instruments: the held note is identical wherever attack plus decay fits inside it (amb01, bass, brass, clav, fmbass, ladder, rhodes, syncfun, anasync). The 30 ms note now stops when released instead of playing out its attack and decay.
    • The biggest changes are slow envelopes: bed (8 s attack), juno / strings / supersaw / section (a short note no longer swells to full), and epiano (2.6 s decay, so held notes shorter than that now release).
  • Pieces: every piece that plays short notes on slow-attack pads changes, by -6 dB to -62 dB of difference relative to the signal. The largest:
piece difference (dB rel.)
pearl -6.2
seq -8.3
sunrise -9.1
boombox -12.6
warehouse -15.3
riviera -15.5
colony -15.9
anthem -16.6
outrun -18.0

The shorter piece renders are the voices ending when their notes do.

These are the intended consequences: notes now last as long as they're played. Pieces voiced against the old behavior may want their pad holds or attacks revisited. Old/new previews of pearl, boombox, sunrise and anthem are available locally.

Testing

  • Native ctest: 46/46 pass.
  • New statecheck cases for the wired trigger: it waits for the rise; a fall mid-attack releases from the level reached; the release runs out; a decay to 0 under a held trigger ends; an unwired trigger still runs once; window agreement.
  • Against the old plugin, the three bug checks fail and the two compatibility checks pass.

mishan added a commit that referenced this pull request Oct 4, 2026
Sustain scales with velocity, so a soft note does not swell up to the
sustain level. Stagger is an entry: each player's attack, filter and
scoop wait on a gate a timer envelope opens Stagger times its share
after the key, rather than the attack being stretched and the scoop
lengthened. Needs env::adsr to wait for a wired trigger (#337); until
then every player enters with the first.
@mishan
mishan requested a balanced review from Copilot October 4, 2026 19:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Short attacks can consume the initial trigger edge twice, shifting the envelope by one sample.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Updates env::adsr so wired triggers control every envelope phase while preserving legacy constant-trigger behavior.

Changes:

  • Adds edge-driven attack, release, and retrigger handling.
  • Preserves the existing free-running path for constant triggers.
  • Adds gated-envelope and window-consistency tests.
File Description
plugins/​env/​adsr.cpp Adds separate gated and legacy envelope paths.
scripts/​statecheck.cpp Tests wired triggers, release behavior, compatibility, and state consistency.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plugins/env/adsr.cpp Outdated
mishan added a commit that referenced this pull request Oct 4, 2026
Sustain scales with velocity, so a soft note does not swell up to the
sustain level. Stagger is an entry: each player's attack, filter and
scoop wait on a gate a timer envelope opens Stagger times its share
after the key, rather than the attack being stretched and the scoop
lengthened. Needs env::adsr to wait for a wired trigger (#337); until
then every player enters with the first.
@mishan
mishan requested a balanced review from Copilot October 4, 2026 19:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Waiting voices can be retired before their trigger rises, and the window-state test currently compares only silence.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (1)

Comment thread plugins/env/adsr.cpp
}

out[i] = level;
play[i] = (phase == DONE || phase == WAITING) ? 0 : 1;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended, and I've added a comment saying why in ef015f2. A waiting envelope isn't sounding, and its gate may never open: a note shorter than the delay it was waiting out releases first. If WAITING reported play = 1, any graph that put such an envelope on its io node would hold the voice forever on every short note. A voice's own lifetime belongs to an envelope on the voice's trigger, which rises with the note on the first sample, so it never waits. A delayed envelope adds to that rather than replacing it, which is what dsp/violins.dsp and dsp/horns.dsp do with play = max(...) over their players, player 1 never being delayed. A graph whose only envelope waits on a gate has nothing that says the note has started, and retiring that voice is the right answer.

Comment thread scripts/statecheck.cpp Outdated
Comment on lines +4058 to +4059
windowsAgree(pluginPath, adsrGraph("sq", sr * 0.2f, sr * 0.1f, 0.6f,
sr * 0.1f),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ef015f2. The check now drives the envelope with a 50 Hz square (up 441 samples, down 441) and short segments, so the 2,000 samples it compares hold several rises, attacks, decays, releases and a release cut short by the next rise. As a mutation check, I dropped the release's starting level between windows, and the test fails at sample 883.

mishan added 3 commits October 4, 2026 12:56
With the trigger wired to anything, the envelope waits for it to rise,
releases on a fall from wherever it is (attack and decay included) and
from the level it reached, and retriggers only on a rising edge, from
that level. It used to start when its voice did, play out the attack
and decay of a note let go during them, release from the sustain level
whatever it was at, and restart every sample of a held trigger once a
decay reached a sustain of 0.

A trigger written as a number, or not at all, keeps the old code path
to the bit: the shipped one-shots (brass's bend, bd10, dxbell) are
built on it. A note held past its decay comes out sample for sample
as before.
The voice's first rise is spent on the sample it starts the attack:
with an attack of a sample or less the envelope has reached the decay
by the end of that sample, and the end-of-sample edge check took the
same rise again, emitting the peak twice and shifting the decay. Only
that edge is suppressed now, so a rise during an attack `reset'
started still retriggers.
The window-agreement check drives the envelope with a 50 Hz square and
short segments, so the two thousand samples it compares hold rises,
attacks, decays, releases and a release cut short, where a 2 Hz square
left them all in WAITING. Losing the release's starting level between
windows now fails it. And why a waiting envelope reports play = 0 is
said where it does.
@mishan
mishan merged commit a8bc7ff into master Oct 4, 2026
20 checks passed
@mishan
mishan deleted the adsr-fix branch October 4, 2026 20:21
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