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:
Reactorcoremeltdown
2026-07-18 10:28:35 +02:00
parent 3155caf3cc
commit d54fd27c9e
2 changed files with 52 additions and 10 deletions

View File

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

View File

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