From 78ebd88548c626908fe4ece7f8a85b1d3473f6f9 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Sat, 18 Jul 2026 01:36:53 +0200 Subject: [PATCH] Fix synth crashes, dead filter, and playback hiccups (v0.4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three issues introduced with the 3-oscillator synth: - Crashes: ToolboxSlot.values was a plain HashMap read on the audio thread while the UI wrote it (knob drags vs. the synth's ~40-param-per-note reads). Made it a ConcurrentHashMap so get/put are atomic and reads are lock-free (RT-safe). - Filter cutoff not affecting the sound: (a) the cheap ladder went unstable at high cutoff (f > 1 -> NaN -> silent voice) — replaced with a stable Cytomic TPT state-variable low-pass (with NaN recovery); (b) a voice captured its patch only at note-on, so live knob turns were inaudible — the engine now refreshes active voices' patches every block, so cutoff/level/mod changes affect held & playing notes. - Hiccups: every note allocated a SynthPatch (+arrays) causing GC dropouts — the patch is now cached per bus and reparsed only on param/tempo change, so steady playback allocates nothing on the audio thread. Patch bump to 0.4.3 (versionCode 10). Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/audio/AudioEngine.kt | 60 +++++++++++++- .../rcmd/android/sizzle/audio/SynthVoice.kt | 80 ++++++++++++------- .../rcmd/android/sizzle/model/Toolbox.kt | 6 +- 4 files changed, 113 insertions(+), 37 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae099e3..d812f20 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 = 9 - versionName = "0.4.2" + versionCode = 10 + versionName = "0.4.3" // 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 3811a24..0169e47 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 @@ -82,6 +82,19 @@ class AudioEngine( // track last started, on the bus(es) that channel routed to. -1 = nothing yet. private val trackLastChannel = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 } + // Cached subtractive-synth patch per bus, reparsed only when the slot changes + // (version) or tempo changes — so note triggers don't allocate a patch each time, + // and active voices can be refreshed per block for live parameter feedback. + private val synthPatch = arrayOfNulls(Pattern.TRACK_COUNT) + private val synthPatchVer = IntArray(Pattern.TRACK_COUNT) { -1 } + private val synthPatchTempo = FloatArray(Pattern.TRACK_COUNT) { Float.NaN } + // Same, for the toolbox editor's audition (liveVoices) — tracks the slot currently + // being auditioned so live knob turns are heard while a preview note is held. + private var auditionSlotIdx = -1 + private var auditionPatch: SynthPatch? = null + private var auditionPatchVer = -1 + private var auditionPatchTempo = Float.NaN + /** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. */ private fun allocSynthVoice(bus: Int): SynthVoice { val base = bus * POLYPHONY @@ -496,6 +509,7 @@ class AudioEngine( if (proj != null) { applyLfos(proj) updateFxParams(proj) + refreshSynthPatches(proj) // Tempo/signature only change at control rate, so derive the per-line // sample count once per block instead of re-dividing every sample. blockSamplesPerLine = samplesPerLine(proj) @@ -762,7 +776,7 @@ class AudioEngine( slot?.type == null || slot.type == ToolboxType.SAMPLER -> {} // NES synth, or a SoundFont with nothing loaded → the tone generator. else -> { - allocSynthVoice(bus).noteOn(note, velocity, patchForSlot(slot, proj.tempoBpm)) + allocSynthVoice(bus).noteOn(note, velocity, synthPatch[bus] ?: patchForSlot(slot, proj.tempoBpm)) } } } @@ -950,6 +964,9 @@ class AudioEngine( // note-off, so a sustaining synth voice would ring forever). A SoundFont // with nothing loaded still falls through to the synth preview below. if (slot?.type == ToolboxType.SAMPLER) return + // Remember which slot is being auditioned so refreshSynthPatches pushes live + // knob turns into the held preview note. + if (slot?.type == ToolboxType.NES_SYNTH) auditionSlotIdx = slotIndex val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() } v.noteOn(midi, 110, patchForSlot(slot, project?.tempoBpm ?: 120f)) } @@ -988,7 +1005,7 @@ class AudioEngine( else -> { val v = (0 until BUS_POLYPHONY).map { busVoices[base + it] } .firstOrNull { !it.isActive } ?: busVoices[base].also { it.kill() } - v.noteOn(midi, velocity, patchForSlot(slot, proj.tempoBpm)) + v.noteOn(midi, velocity, synthPatch[ch] ?: patchForSlot(slot, proj.tempoBpm)) } } } @@ -1087,10 +1104,47 @@ class AudioEngine( } /** Parse a slot's synth patch (default init patch if it isn't an NES synth). - * Allocates one [SynthPatch] per note trigger — off the per-sample hot path. */ + * Allocates one [SynthPatch] — used only for the first audition note; the + * sequencer/bus paths use the per-block cache in [refreshSynthPatches]. */ private fun patchForSlot(slot: ToolboxSlot?, tempoBpm: Float): SynthPatch = if (slot?.type == ToolboxType.NES_SYNTH) SynthPatch.from(slot, tempoBpm) else SynthPatch.DEFAULT + /** + * Once per block: refresh each bus's cached synth patch (reparsing only when the + * slot version or tempo changed) and push it into that bus's *active* synth voices + * plus the editor's audition voices. This is what makes filter/level/mod knob + * turns audible on already-sounding notes, and keeps note triggers allocation-free. + * Runs on the audio thread; slot reads are safe against UI edits because the param + * map is concurrent. + */ + private fun refreshSynthPatches(proj: Project) { + val tempo = proj.tempoBpm + for (ch in 0 until Pattern.TRACK_COUNT) { + val slot = proj.toolbox.getOrNull(proj.mixer.channels[ch].instrumentSlot) + if (slot?.type != ToolboxType.NES_SYNTH) { synthPatch[ch] = null; synthPatchVer[ch] = -1; continue } + if (synthPatch[ch] == null || slot.version != synthPatchVer[ch] || tempo != synthPatchTempo[ch]) { + synthPatch[ch] = SynthPatch.from(slot, tempo) + synthPatchVer[ch] = slot.version + synthPatchTempo[ch] = tempo + } + val p = synthPatch[ch] ?: continue + val sbase = ch * POLYPHONY + for (i in 0 until POLYPHONY) seqVoices[sbase + i].let { if (it.isActive) it.setPatch(p) } + val bbase = ch * BUS_POLYPHONY + for (i in 0 until BUS_POLYPHONY) busVoices[bbase + i].let { if (it.isActive) it.setPatch(p) } + } + // Editor audition voices follow the slot currently being previewed. + val aslot = proj.toolbox.getOrNull(auditionSlotIdx) + if (aslot?.type == ToolboxType.NES_SYNTH) { + if (auditionPatch == null || aslot.version != auditionPatchVer || tempo != auditionPatchTempo) { + auditionPatch = SynthPatch.from(aslot, tempo) + auditionPatchVer = aslot.version + auditionPatchTempo = tempo + } + auditionPatch?.let { p -> for (v in liveVoices) if (v.isActive) v.setPatch(p) } + } + } + private fun samplesPerLine(proj: Project): Double = sampleRate * (60.0 / proj.tempoBpm) / proj.timeSignature.linesPerBeat 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 4d7012d..d0614f8 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 @@ -5,6 +5,7 @@ package space.rcmd.android.sizzle.audio import kotlin.math.abs import kotlin.math.sin +import kotlin.math.tan /** * One playing note of the subtractive (Minimoog-style) synth. Per voice: @@ -35,10 +36,12 @@ class SynthVoice(private val sampleRate: Int) { private val staticInc = DoubleArray(SynthPatch.OSC_COUNT) // phase increment when un-modulated private var pitchModActive = false // any mod slot routes to pitch? - // Cheap linear 4-pole ("Moog") ladder state. - private var o1 = 0f; private var o2 = 0f; private var o3 = 0f; private var o4 = 0f - private var s1 = 0f; private var s2 = 0f; private var s3 = 0f; private var s4 = 0f - private var fcNorm = 0.5f // cached cutoff (fraction of Nyquist), refreshed at control rate + // Topology-preserving (Cytomic) 2-pole state-variable low-pass. Unconditionally + // stable across cutoff modulation and self-oscillation, unlike the old cheap + // ladder which went unstable (NaN → dead voice) at high cutoff. Coefficients are + // refreshed at control rate; the two integrators run per sample. + private var icB = 0f; private var icL = 0f + private var ca1 = 0f; private var ca2 = 0f; private var ca3 = 0f private var ctrl = 0 // LFO + sample-and-hold. @@ -64,15 +67,9 @@ class SynthVoice(private val sampleRate: Int) { lfoInc = patch.lfoHz.toDouble() / sampleRate lfoPhase = 0.0 // retrigger the LFO per note for predictable modulation ctrl = 0 // force a cutoff recompute on the first sample - - // Precompute each oscillator's tuning so the per-sample loop skips Math.pow - // unless pitch is actually being modulated (the common case). - for (i in 0 until SynthPatch.OSC_COUNT) { - val semis = patch.oscOct[i] * 12f + patch.oscSemi[i] + patch.oscFine[i] / 100f - staticSemis[i] = semis - staticInc[i] = (baseFreq * fastPow2(semis / 12.0) / sampleRate).coerceIn(0.0, 0.49) - } - pitchModActive = (0 until SynthPatch.MOD_SLOTS).any { patch.modSrc[it] != 0 && patch.modDst[it] in 0..2 } + // Precompute oscillator tuning so the per-sample loop skips Math.pow unless + // pitch is actually being modulated (the common case). + computeTuning() } fun noteOff() { ampEnv.gateOff(); filtEnv.gateOff() } @@ -81,7 +78,27 @@ class SynthVoice(private val sampleRate: Int) { fun kill() { ampEnv.kill(); filtEnv.kill() pitch = -1 - o1 = 0f; o2 = 0f; o3 = 0f; o4 = 0f; s1 = 0f; s2 = 0f; s3 = 0f; s4 = 0f + icB = 0f; icL = 0f + } + + /** Swap in updated params for a still-sounding voice (live editing / playback + * block refresh). Envelopes keep their in-flight state; tuning + LFO rate are + * recomputed only when the patch actually changes, so held notes reflect knob + * turns (cutoff, levels, mod…) without clicking or retriggering. */ + fun setPatch(p: SynthPatch) { + if (p === patch) return + patch = p + lfoInc = p.lfoHz.toDouble() / sampleRate + computeTuning() + } + + private fun computeTuning() { + for (i in 0 until SynthPatch.OSC_COUNT) { + val semis = patch.oscOct[i] * 12f + patch.oscSemi[i] + patch.oscFine[i] / 100f + staticSemis[i] = semis + staticInc[i] = (baseFreq * fastPow2(semis / 12.0) / sampleRate).coerceIn(0.0, 0.49) + } + pitchModActive = (0 until SynthPatch.MOD_SLOTS).any { patch.modSrc[it] != 0 && patch.modDst[it] in 0..2 } } fun render(): Float { @@ -139,16 +156,18 @@ class SynthVoice(private val sampleRate: Int) { } if (patch.noiseLevel > 0.0001f) mix += nextRand() * patch.noiseLevel - // ---- filter (cutoff refreshed at control rate) ---- + // ---- filter (coefficients refreshed at control rate) ---- if (ctrl <= 0) { ctrl = CTRL_INTERVAL val exp = (patch.cutoff + patch.filterEnvAmt * filtE + patch.keytrack * keyNorm + mCut) - .coerceIn(0f, 1.2f) + .coerceIn(0f, 1.3f) val hz = (20.0 * fastPow(1000.0, exp.toDouble())).coerceIn(20.0, sampleRate * 0.45) - fcNorm = (hz / (sampleRate * 0.5)).toFloat().coerceIn(0.001f, 0.99f) + val g = tan(Math.PI * hz / sampleRate).toFloat() + val k = (2f - 1.96f * patch.resonance).coerceIn(0.05f, 2f) // lower k = more resonance + ca1 = 1f / (1f + g * (g + k)); ca2 = g * ca1; ca3 = g * ca2 } ctrl-- - val filtered = ladder(mix, fcNorm, patch.resonance) + val filtered = svf(mix) // ---- amplifier ---- var a = ampE * velocity * (1f + mAmp) @@ -196,19 +215,18 @@ class SynthVoice(private val sampleRate: Int) { } } - /** Cheap linear 4-pole ladder low-pass (Kellett/CSound form). [fc] is a fraction - * of Nyquist; [res] 0..1 (rings near 1). Stages are bounded to stay stable. */ - private fun ladder(input: Float, fc: Float, res: Float): Float { - val f = fc * 1.16f - val fb = res.coerceIn(0f, 0.98f) * (1f - 0.15f * f * f) - var x = input - o4 * fb - x *= 0.35013f * (f * f) * (f * f) - o1 = (x + 0.3f * s1 + (1f - f) * o1); s1 = x - o2 = (o1 + 0.3f * s2 + (1f - f) * o2); s2 = o1 - o3 = (o2 + 0.3f * s3 + (1f - f) * o3); s3 = o2 - o4 = (o3 + 0.3f * s4 + (1f - f) * o4); s4 = o3 - // Makeup: the input scaling above drops level hard at low cutoff; lift it back. - return o4 * 4f + /** Cytomic TPT state-variable low-pass (2-pole). Coefficients [ca1]/[ca2]/[ca3] + * are precomputed at control rate; this runs the two integrators per sample. It + * is unconditionally stable, so cutoff/resonance always track audibly and can't + * blow up. Recovers to silence if a non-finite input ever slips through. */ + private fun svf(input: Float): Float { + val v3 = input - icL + val v1 = ca1 * icB + ca2 * v3 + val v2 = icL + ca2 * icB + ca3 * v3 + icB = 2f * v1 - icB + icL = 2f * v2 - icL + if (!v2.isFinite()) { icB = 0f; icL = 0f; return 0f } + return v2 } private fun nextRand(): Float { diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt index a73696e..cc8da3b 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt @@ -314,7 +314,11 @@ class ToolboxSlot( val index: Int, var type: ToolboxType? = null, var name: String = "", - val values: MutableMap = mutableMapOf(), + // Concurrent: the audio thread READS params (per note / per block) while the UI + // thread WRITES them (knob drags, preset loads). A plain HashMap can corrupt or + // crash under that race; ConcurrentHashMap makes each get/put atomic and its + // reads lock-free (RT-safe). Iteration is weakly consistent (fine for PresetIo). + val values: MutableMap = java.util.concurrent.ConcurrentHashMap(), ) { val isEmpty: Boolean get() = type == null