Fix synth crashes, dead filter, and playback hiccups (v0.4.3)

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 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 01:36:53 +02:00
parent 74d60993f0
commit 78ebd88548
4 changed files with 113 additions and 37 deletions

View File

@@ -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"

View File

@@ -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<SynthPatch>(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

View File

@@ -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 {

View File

@@ -314,7 +314,11 @@ class ToolboxSlot(
val index: Int,
var type: ToolboxType? = null,
var name: String = "",
val values: MutableMap<String, String> = 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<String, String> = java.util.concurrent.ConcurrentHashMap(),
) {
val isEmpty: Boolean get() = type == null