Move synth pitch modulation to control rate — fix playback hiccups (v0.4.4)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
|||||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 10
|
versionCode = 11
|
||||||
versionName = "0.4.3"
|
versionName = "0.4.4"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class SynthVoice(private val sampleRate: Int) {
|
|||||||
private val phase = DoubleArray(SynthPatch.OSC_COUNT)
|
private val phase = DoubleArray(SynthPatch.OSC_COUNT)
|
||||||
private val staticSemis = FloatArray(SynthPatch.OSC_COUNT) // oct+semi+fine, no modulation
|
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 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?
|
private var pitchModActive = false // any mod slot routes to pitch?
|
||||||
|
|
||||||
// Topology-preserving (Cytomic) 2-pole state-variable low-pass. Unconditionally
|
// 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
|
val semis = patch.oscOct[i] * 12f + patch.oscSemi[i] + patch.oscFine[i] / 100f
|
||||||
staticSemis[i] = semis
|
staticSemis[i] = semis
|
||||||
staticInc[i] = (baseFreq * fastPow2(semis / 12.0) / sampleRate).coerceIn(0.0, 0.49)
|
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 }
|
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 ----
|
// ---- control-rate update (every CTRL_INTERVAL samples): the expensive
|
||||||
var mix = 0f
|
// Math.pow for cutoff + pitch runs ~1/16 as often. LFO/envelopes move slowly
|
||||||
for (i in 0 until SynthPatch.OSC_COUNT) {
|
// enough that stepping the coefficients this often is inaudible. ----
|
||||||
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) ----
|
|
||||||
if (ctrl <= 0) {
|
if (ctrl <= 0) {
|
||||||
ctrl = CTRL_INTERVAL
|
ctrl = CTRL_INTERVAL
|
||||||
val exp = (patch.cutoff + patch.filterEnvAmt * filtE + patch.keytrack * keyNorm + mCut)
|
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 g = tan(Math.PI * hz / sampleRate).toFloat()
|
||||||
val k = (2f - 1.96f * patch.resonance).coerceIn(0.05f, 2f) // lower k = more resonance
|
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
|
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--
|
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)
|
val filtered = svf(mix)
|
||||||
|
|
||||||
// ---- amplifier ----
|
// ---- amplifier ----
|
||||||
|
|||||||
Reference in New Issue
Block a user