Fix stuck audition notes under rapid / chord tapping (v0.8.1)

The synth/SF2 audition note-off matched a voice purely by pitch, and isActive
stays true through a voice's release tail. So tapping a key, then immediately
holding it again, landed the note-off on the still-releasing first voice (a
no-op) and left the held one stuck, needing a MIDI panic. Two fingers on one
key hit the same trap.

Give voices an isGated flag (held = ADSR not in release/idle) and make
auditionSlotOff / liveNoteOff release a gated voice of that pitch in preference
to one already ringing out. Add SynthVoiceGateTest covering the gate/release
distinction and the exact stuck-note scenario.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 21:51:41 +02:00
parent 5943040a2b
commit a5b9837bbc
5 changed files with 98 additions and 6 deletions

View File

@@ -527,7 +527,10 @@ class AudioEngine(
}
fun liveNoteOff(pitch: Int) {
liveVoices.firstOrNull { it.isActive && it.pitch == pitch }?.noteOff()
// Prefer a held (gated) voice over one already releasing, so a fast retrigger
// of the same pitch never leaves the held voice stuck (see [auditionSlotOff]).
(liveVoices.firstOrNull { it.pitch == pitch && it.isGated }
?: liveVoices.firstOrNull { it.isActive && it.pitch == pitch })?.noteOff()
}
// ------------------------------------------------------------- render loop
@@ -1075,10 +1078,15 @@ class AudioEngine(
v.noteOn(midi, 110, patchForSlot(slot, project?.tempoBpm ?: 120f))
}
/** Release an auditioned note (matches both synth and sample audition pools). */
/** Release an auditioned note (matches both synth and sample audition pools).
* Releases a *held* (gated) voice of this pitch in preference to one that's already
* ringing out its release tail — otherwise rapid re-tapping / chord-tapping the same
* key lands the note-off on the releasing voice and leaves the held one stuck. */
fun auditionSlotOff(midi: Int) {
liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
(liveVoices.firstOrNull { it.pitch == midi && it.isGated }
?: liveVoices.firstOrNull { it.isActive && it.pitch == midi })?.noteOff()
(auditionSampleVoices.firstOrNull { it.pitch == midi && it.isGated }
?: auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi })?.noteOff()
}
// ---------------------------------------------- live mix-bus audition (MIDI piano)

View File

@@ -64,6 +64,10 @@ class SampleVoice(private val engineSampleRate: Int) {
private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay
val isActive: Boolean get() = sample != null
/** Note is still *held* (not in its release stage). A voice ringing out its
* release tail is still [isActive]; distinguishing the two lets a note-off
* target the held voice instead of one already releasing — see [SynthVoice.isGated]. */
val isGated: Boolean get() = sample != null && phase != Phase.RELEASE
/**
* @param root MIDI note at which the sample plays at its natural pitch.

View File

@@ -55,6 +55,11 @@ class SynthVoice(private val sampleRate: Int) {
private val filtEnv = Adsr()
val isActive: Boolean get() = ampEnv.active
/** Gate is still open (attack/decay/sustain) — the note is *held*. A voice in its
* release tail is still [isActive] but no longer gated; distinguishing the two
* lets a note-off target the held voice instead of one that's already ringing
* out (which otherwise leaves the held note stuck under rapid re-tapping). */
val isGated: Boolean get() = ampEnv.gateOpen
/** Start (or steal-and-restart) this voice with the given [patch]. */
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
@@ -254,6 +259,8 @@ class SynthVoice(private val sampleRate: Int) {
private var level = 0f
private var a = 0.01f; private var d = 0.1f; private var s = 0.7f; private var r = 0.2f
val active: Boolean get() = stage != 0
/** Attack/decay/sustain — i.e. gate on, not yet released (stage 4) or idle (0). */
val gateOpen: Boolean get() = stage in 1..3
fun gateOn(aa: Float, dd: Float, ss: Float, rr: Float) {
a = aa.coerceAtLeast(0.0005f); d = dd; s = ss.coerceIn(0f, 1f); r = rr