Skip idle voice pools per block — fix playback dropouts (v0.4.6)
fillBlock rendered EVERY pooled voice every sample: 32+32 sequencer + 32+32 bus-audition + 16 live/audition voices, render() called ~144× per frame × 96 frames per callback — even at idle. The bus-audition pool (64 voices) is only used by the Toolbox MIDI piano yet ran every sample during normal playback. This fixed cost, not active-voice count, dominated the render budget. Now the pools are scanned once per block into per-bus active flags, and the per-sample mix loop skips whole idle buses and the entire bus- audition pool when nothing is being auditioned. The sequencer sets the flag the instant it triggers a note mid-block, so newly-started notes still sound sample-accurately within the same block. On-device profiling (moto g84, 98% battery, real busy track, ~11 sample voices, 96-frame/2.0ms callback): before: avg 2.00 ms, xruns +250..+515 per ~500 callbacks (audible drops) after: avg 1.44 ms, xruns +0 (steady) Fast-tapping the audition keyboard during playback: avg 1.1-1.4 ms, xruns +0, voices peaked at 16 then drained cleanly — no stuck notes. With this, under-load render time equals idle, so voice rendering is no longer the bottleneck; the remaining ~1.4 ms is fixed JNI + per-channel FX overhead. A block-based per-voice refactor was evaluated and dropped as unnecessary (it would not reduce that fixed cost). 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 = 12
|
||||
versionName = "0.4.5"
|
||||
versionCode = 13
|
||||
versionName = "0.4.6"
|
||||
|
||||
// We provide our own instrumentation runner if/when tests are added.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -144,6 +144,16 @@ class AudioEngine(
|
||||
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
||||
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
||||
|
||||
// Per-block active-bus flags (audio-thread only). Rebuilt once per block so the
|
||||
// per-sample mix loop can skip whole idle buses — and the entire bus-audition pool
|
||||
// when nothing is being auditioned — instead of calling render() on all
|
||||
// POLYPHONY*TRACK_COUNT pooled voices every sample (the fixed cost that dominated
|
||||
// the render budget even at idle). The sequencer sets [blockBusActive] the instant
|
||||
// it triggers a note mid-block, so a newly-started note still sounds sample-
|
||||
// accurately within the same block.
|
||||
private val blockBusActive = BooleanArray(Pattern.TRACK_COUNT)
|
||||
private val blockBusAudition = BooleanArray(Pattern.TRACK_COUNT)
|
||||
|
||||
/** The song being played. Swapped atomically; read on the audio thread. */
|
||||
@Volatile private var project: Project? = null
|
||||
|
||||
@@ -519,6 +529,25 @@ class AudioEngine(
|
||||
// mid-block; indexing + array iteration below then allocate nothing.
|
||||
val chains = channelChains
|
||||
|
||||
// Once per block: flag which buses have any active voices, so the per-sample
|
||||
// loop only renders those (see [blockBusActive]). Scanning the pools once here
|
||||
// costs O(pool); the alternative — calling render() on every pooled voice every
|
||||
// sample — costs O(pool * frames) and was the bulk of the idle render budget.
|
||||
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||
val base = bus * POLYPHONY
|
||||
var seqAct = false
|
||||
for (i in 0 until POLYPHONY) {
|
||||
if (seqVoices[base + i].isActive || sampleVoices[base + i].isActive) { seqAct = true; break }
|
||||
}
|
||||
blockBusActive[bus] = seqAct
|
||||
val bbase = bus * BUS_POLYPHONY
|
||||
var busAct = false
|
||||
for (i in 0 until BUS_POLYPHONY) {
|
||||
if (busVoices[bbase + i].isActive || busSampleVoices[bbase + i].isActive) { busAct = true; break }
|
||||
}
|
||||
blockBusAudition[bus] = busAct
|
||||
}
|
||||
|
||||
for (i in 0 until frames) {
|
||||
if (playing && proj != null) advanceSequencer(proj)
|
||||
|
||||
@@ -526,14 +555,23 @@ class AudioEngine(
|
||||
var mixR = 0f
|
||||
if (proj != null) {
|
||||
channelSum.fill(0f)
|
||||
// Pools are laid out bus*POLYPHONY + i, so the bus is vi / POLYPHONY.
|
||||
for (vi in seqVoices.indices) {
|
||||
channelSum[vi / POLYPHONY] += seqVoices[vi].render() + sampleVoices[vi].render()
|
||||
}
|
||||
// Live bus-audition voices feed the same per-channel sum, so they
|
||||
// run through the channel's FX/volume/mute just like the sequencer.
|
||||
for (bi in busVoices.indices) {
|
||||
channelSum[bi / BUS_POLYPHONY] += busVoices[bi].render() + busSampleVoices[bi].render()
|
||||
// Sum each bus's pooled voices, skipping buses whose pools are idle.
|
||||
// Pools are laid out bus*POLYPHONY + i / bus*BUS_POLYPHONY + i.
|
||||
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||
if (blockBusActive[bus]) {
|
||||
val base = bus * POLYPHONY
|
||||
var sum = channelSum[bus]
|
||||
for (v in 0 until POLYPHONY) sum += seqVoices[base + v].render() + sampleVoices[base + v].render()
|
||||
channelSum[bus] = sum
|
||||
}
|
||||
// Live bus-audition voices feed the same per-channel sum, so they
|
||||
// run through the channel's FX/volume/mute just like the sequencer.
|
||||
if (blockBusAudition[bus]) {
|
||||
val bbase = bus * BUS_POLYPHONY
|
||||
var sum = channelSum[bus]
|
||||
for (v in 0 until BUS_POLYPHONY) sum += busVoices[bbase + v].render() + busSampleVoices[bbase + v].render()
|
||||
channelSum[bus] = sum
|
||||
}
|
||||
}
|
||||
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||
val mc = proj.mixer.channels[ch]
|
||||
@@ -765,6 +803,10 @@ class AudioEngine(
|
||||
* by the sequencer and the arpeggiator.
|
||||
*/
|
||||
private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int) {
|
||||
// A note is starting on this bus mid-block; mark the bus active so the rest of
|
||||
// this block's per-sample mix renders it (the once-per-block scan ran before
|
||||
// this trigger). Without this the note would be silent until the next block.
|
||||
blockBusActive[bus] = true
|
||||
val slot = proj.toolbox.getOrNull(proj.mixer.channels[bus].instrumentSlot)
|
||||
val vs = voiceSample(slot, note)
|
||||
when {
|
||||
|
||||
Reference in New Issue
Block a user