diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ad7f09..76d159a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { // (android.media.midi) and AAudio low-latency audio we rely on. minSdk = 26 targetSdk = 34 - versionCode = 23 - versionName = "0.8.0" + versionCode = 24 + versionName = "0.8.1" // We provide our own instrumentation runner if/when tests are added. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index 5bad9db..a353694 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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) diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt index f91cefe..d81ee50 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt @@ -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. diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt index d2b79e5..660acd4 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt @@ -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 diff --git a/app/src/test/java/space/rcmd/android/sizzle/audio/SynthVoiceGateTest.kt b/app/src/test/java/space/rcmd/android/sizzle/audio/SynthVoiceGateTest.kt new file mode 100644 index 0000000..3d5f5be --- /dev/null +++ b/app/src/test/java/space/rcmd/android/sizzle/audio/SynthVoiceGateTest.kt @@ -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) + } +}