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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user