From 00f52c3434e5eb2c50299fab98500295bcbb8a1f Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Sat, 18 Jul 2026 01:48:39 +0200 Subject: [PATCH] =?UTF-8?q?Move=20synth=20pitch=20modulation=20to=20contro?= =?UTF-8?q?l=20rate=20=E2=80=94=20fix=20playback=20hiccups=20(v0.4.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device profiling (worst case: 4 channels x 3 oscillators + cutoff & pitch modulation, dense pattern at 200 BPM) showed the render callback averaging 2.8ms against a 2.0ms budget with continuous xruns — the hiccups. The cost was the per-sample Math.pow used for pitch modulation across 3 oscillators x many voices. Recompute modulated oscillator pitch at control rate (every 16 samples, like the filter cutoff) instead of per sample; LFO/envelopes move slowly enough that this is inaudible. Render dropped to ~1.27ms avg with zero new xruns. No crashes under load (the ConcurrentHashMap + stable SVF filter fixes from 0.4.3 hold). Patch bump to 0.4.4 (versionCode 11). Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/audio/SynthVoice.kt | 49 +++++++++++-------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d812f20..f621420 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 = 10 - versionName = "0.4.3" + versionCode = 11 + versionName = "0.4.4" // 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/SynthVoice.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt index d0614f8..53080d8 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 @@ -34,6 +34,7 @@ class SynthVoice(private val sampleRate: Int) { private val phase = DoubleArray(SynthPatch.OSC_COUNT) private val staticSemis = FloatArray(SynthPatch.OSC_COUNT) // oct+semi+fine, no modulation private val staticInc = DoubleArray(SynthPatch.OSC_COUNT) // phase increment when un-modulated + private val modInc = DoubleArray(SynthPatch.OSC_COUNT) // modulated increment, refreshed at control rate private var pitchModActive = false // any mod slot routes to pitch? // Topology-preserving (Cytomic) 2-pole state-variable low-pass. Unconditionally @@ -97,6 +98,7 @@ class SynthVoice(private val sampleRate: Int) { 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) + modInc[i] = staticInc[i] // valid until the first control-rate refresh } pitchModActive = (0 until SynthPatch.MOD_SLOTS).any { patch.modSrc[it] != 0 && patch.modDst[it] in 0..2 } } @@ -136,27 +138,9 @@ class SynthVoice(private val sampleRate: Int) { } } - // ---- oscillators + noise ---- - var mix = 0f - for (i in 0 until SynthPatch.OSC_COUNT) { - val level = patch.oscLevel[i] - if (level <= 0.0001f) continue - val inc = if (pitchModActive) { - var semis = staticSemis[i] + mPitch * 12f - if (i == 1) semis += mOsc2 * 12f - if (i == 2) semis += mOsc3 * 12f - (baseFreq * fastPow2(semis / 12.0) / sampleRate).coerceIn(0.0, 0.49) - } else { - staticInc[i] - } - var ph = phase[i] + inc - if (ph >= 1.0) ph -= 1.0 - phase[i] = ph - mix += waveform(patch.oscWave[i], ph, inc, mPwm) * level - } - if (patch.noiseLevel > 0.0001f) mix += nextRand() * patch.noiseLevel - - // ---- filter (coefficients refreshed at control rate) ---- + // ---- control-rate update (every CTRL_INTERVAL samples): the expensive + // Math.pow for cutoff + pitch runs ~1/16 as often. LFO/envelopes move slowly + // enough that stepping the coefficients this often is inaudible. ---- if (ctrl <= 0) { ctrl = CTRL_INTERVAL val exp = (patch.cutoff + patch.filterEnvAmt * filtE + patch.keytrack * keyNorm + mCut) @@ -165,8 +149,31 @@ class SynthVoice(private val sampleRate: Int) { 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 + if (pitchModActive) { + for (i in 0 until SynthPatch.OSC_COUNT) { + var semis = staticSemis[i] + mPitch * 12f + if (i == 1) semis += mOsc2 * 12f + if (i == 2) semis += mOsc3 * 12f + modInc[i] = (baseFreq * fastPow2(semis / 12.0) / sampleRate).coerceIn(0.0, 0.49) + } + } } ctrl-- + + // ---- oscillators + noise ---- + var mix = 0f + for (i in 0 until SynthPatch.OSC_COUNT) { + val level = patch.oscLevel[i] + if (level <= 0.0001f) continue + val inc = if (pitchModActive) modInc[i] else staticInc[i] + var ph = phase[i] + inc + if (ph >= 1.0) ph -= 1.0 + phase[i] = ph + mix += waveform(patch.oscWave[i], ph, inc, mPwm) * level + } + if (patch.noiseLevel > 0.0001f) mix += nextRand() * patch.noiseLevel + + // ---- filter (coefficients computed at control rate, above) ---- val filtered = svf(mix) // ---- amplifier ----