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:
@@ -0,0 +1,73 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Locks the gate-vs-release distinction the audition note-off relies on. A voice in
|
||||
* its release tail must report [SynthVoice.isActive] == true (still sounding) but
|
||||
* [SynthVoice.isGated] == false, so a note-off preferring a gated voice never lands
|
||||
* on a releasing one — the fix for stuck notes under rapid / chord tapping.
|
||||
*/
|
||||
class SynthVoiceGateTest {
|
||||
|
||||
private val sr = 48_000
|
||||
|
||||
@Test fun heldNoteIsGated() {
|
||||
val v = SynthVoice(sr)
|
||||
v.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||
assertTrue("a freshly triggered note is gated", v.isGated)
|
||||
assertTrue(v.isActive)
|
||||
// Advance well past the attack/decay into sustain — still held.
|
||||
repeat(sr / 2) { v.render() }
|
||||
assertTrue("a sustaining note is still gated", v.isGated)
|
||||
assertTrue(v.isActive)
|
||||
}
|
||||
|
||||
@Test fun releasingNoteIsActiveButNotGated() {
|
||||
val v = SynthVoice(sr)
|
||||
v.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||
repeat(sr / 4) { v.render() } // reach sustain
|
||||
v.noteOff()
|
||||
// One sample into the release: still audible, but no longer gated.
|
||||
v.render()
|
||||
assertTrue("a releasing voice is still active (audible tail)", v.isActive)
|
||||
assertFalse("a releasing voice is no longer gated", v.isGated)
|
||||
}
|
||||
|
||||
@Test fun releaseReachesSilence() {
|
||||
val v = SynthVoice(sr)
|
||||
v.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||
repeat(sr / 4) { v.render() }
|
||||
v.noteOff()
|
||||
// DEFAULT release is 0.2 s; render a full second to be safe.
|
||||
repeat(sr) { v.render() }
|
||||
assertFalse("the voice frees itself after the release completes", v.isActive)
|
||||
assertFalse(v.isGated)
|
||||
}
|
||||
|
||||
/** The stuck-note scenario at the voice level: a releasing voice and a held voice
|
||||
* of the same pitch coexist; selecting the gated one for note-off leaves no held
|
||||
* voice behind (whereas picking the first *active* match would strand it). */
|
||||
@Test fun gatedPreferenceReleasesTheHeldVoice() {
|
||||
val releasing = SynthVoice(sr)
|
||||
val held = SynthVoice(sr)
|
||||
releasing.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||
repeat(sr / 4) { releasing.render() }
|
||||
releasing.noteOff()
|
||||
releasing.render() // now active-but-not-gated
|
||||
held.noteOn(60, 100, SynthPatch.DEFAULT) // same pitch, gated
|
||||
|
||||
val pool = listOf(releasing, held)
|
||||
// Mirror auditionSlotOff's selection: prefer a gated voice of this pitch.
|
||||
val target = pool.firstOrNull { it.pitch == 60 && it.isGated }
|
||||
?: pool.firstOrNull { it.isActive && it.pitch == 60 }
|
||||
target?.noteOff()
|
||||
|
||||
assertFalse("the held voice received the note-off and is releasing", held.isGated)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user