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:
@@ -22,8 +22,8 @@ android {
|
|||||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 23
|
versionCode = 24
|
||||||
versionName = "0.8.0"
|
versionName = "0.8.1"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -527,7 +527,10 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun liveNoteOff(pitch: Int) {
|
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
|
// ------------------------------------------------------------- render loop
|
||||||
@@ -1075,10 +1078,15 @@ class AudioEngine(
|
|||||||
v.noteOn(midi, 110, patchForSlot(slot, project?.tempoBpm ?: 120f))
|
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) {
|
fun auditionSlotOff(midi: Int) {
|
||||||
liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
(liveVoices.firstOrNull { it.pitch == midi && it.isGated }
|
||||||
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
?: 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)
|
// ---------------------------------------------- live mix-bus audition (MIDI piano)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ class SampleVoice(private val engineSampleRate: Int) {
|
|||||||
private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay
|
private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay
|
||||||
|
|
||||||
val isActive: Boolean get() = sample != null
|
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.
|
* @param root MIDI note at which the sample plays at its natural pitch.
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ class SynthVoice(private val sampleRate: Int) {
|
|||||||
private val filtEnv = Adsr()
|
private val filtEnv = Adsr()
|
||||||
|
|
||||||
val isActive: Boolean get() = ampEnv.active
|
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]. */
|
/** Start (or steal-and-restart) this voice with the given [patch]. */
|
||||||
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
|
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
|
||||||
@@ -254,6 +259,8 @@ class SynthVoice(private val sampleRate: Int) {
|
|||||||
private var level = 0f
|
private var level = 0f
|
||||||
private var a = 0.01f; private var d = 0.1f; private var s = 0.7f; private var r = 0.2f
|
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
|
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) {
|
fun gateOn(aa: Float, dd: Float, ss: Float, rr: Float) {
|
||||||
a = aa.coerceAtLeast(0.0005f); d = dd; s = ss.coerceIn(0f, 1f); r = rr
|
a = aa.coerceAtLeast(0.0005f); d = dd; s = ss.coerceIn(0f, 1f); r = rr
|
||||||
|
|||||||
@@ -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