Render only active voices via per-block active-voice lists (v0.4.7)

Optimization B, superseding v0.4.6's per-bus skip. Instead of iterating
each active bus's whole POLYPHONY pool per sample (calling render() on
idle slots, which merely early-out), fillBlock now builds compact lists
of the voices that are actually sounding — once per block — and the
per-sample mix loop touches only those.

Correctness for mid-block starts: the sequencer can trigger a note part
way through a block, so the allocators queue the chosen voice into this
block's list (queueSeq/queueSmp), keeping note timing sample-accurate.
A per-voice generation stamp dedups the append so a voice that finishes
and is re-allocated within one block is never rendered twice (which would
double its amplitude). Live/bus-audition voices are snapshotted at block
start (a manual press mid-block sounds at the next block, ≤2 ms later —
imperceptible, and the pre-existing ~30 ms output buffer dwarfs it).

On-device (moto g84, 98% battery, real busy track, ~13 voices, 2.0 ms
budget), avg render callback:
  baseline 2.00 ms (xruns +250..+515) → A 1.44 ms (+0) → B ~1.00 ms (+0)
B removes ~51 inactive-slot render() calls per sample; ART's virtual
dispatch makes each ~90 ns, so this is a real ~30% gain over A (not the
~1% first estimated from the sample track — measurement corrected it).
Fast-tapping audition during playback stays ~1.0-1.3 ms, xruns +0,
voices drain cleanly (no stuck notes, no double-render).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 10:39:57 +02:00
parent d54fd27c9e
commit 12c2092843
2 changed files with 61 additions and 58 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 13
versionName = "0.4.6"
versionCode = 14
versionName = "0.4.7"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -95,13 +95,15 @@ class AudioEngine(
private var auditionPatchVer = -1
private var auditionPatchTempo = Float.NaN
/** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. */
/** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. The
* chosen voice is queued into this block's active list so a note started mid-block
* is rendered from the same sample (see [queueSeq]). */
private fun allocSynthVoice(bus: Int): SynthVoice {
val base = bus * POLYPHONY
for (i in 0 until POLYPHONY) seqVoices[base + i].let { if (!it.isActive) return it }
for (i in 0 until POLYPHONY) seqVoices[base + i].let { if (!it.isActive) { queueSeq(base + i); return it } }
val idx = synthAlloc[bus]
synthAlloc[bus] = (idx + 1) % POLYPHONY
return seqVoices[base + idx].also { it.kill() }
return seqVoices[base + idx].also { it.kill(); queueSeq(base + idx) }
}
/** Grab a free sample voice from [bus]'s pool, or steal the round-robin one.
@@ -109,12 +111,19 @@ class AudioEngine(
* itself (an abrupt kill of a loud voice would click). */
private fun allocSampleVoice(bus: Int): SampleVoice {
val base = bus * POLYPHONY
for (i in 0 until POLYPHONY) sampleVoices[base + i].let { if (!it.isActive) return it }
for (i in 0 until POLYPHONY) sampleVoices[base + i].let { if (!it.isActive) { queueSmp(base + i); return it } }
val idx = sampleAlloc[bus]
sampleAlloc[bus] = (idx + 1) % POLYPHONY
return sampleVoices[base + idx]
return sampleVoices[base + idx].also { queueSmp(base + idx) }
}
/** Append a sequencer synth/sample voice index to this block's active list, unless
* it is already queued this block (generation stamp) — dedup prevents a finished-
* then-reallocated voice from being rendered twice in one block. Audio-thread only
* (called from [playNote] via the allocators, all inside [fillBlock]). */
private fun queueSeq(i: Int) { if (seqGen[i] != blockGen) { seqGen[i] = blockGen; actSeq[actSeqN++] = i } }
private fun queueSmp(i: Int) { if (smpGen[i] != blockGen) { smpGen[i] = blockGen; actSmp[actSmpN++] = i } }
/** Release (fade out) every active voice on [bus] — a note-off cell on its channel. */
private fun releaseBus(bus: Int) {
val base = bus * POLYPHONY
@@ -144,15 +153,25 @@ 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)
// Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
// the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
// never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
// scanned every sample before). Each list holds global voice indices; the bus for a
// seq/sample/bus voice is index / POLYPHONY (or / BUS_POLYPHONY).
private val actSeq = IntArray(seqVoices.size); private var actSeqN = 0
private val actSmp = IntArray(sampleVoices.size); private var actSmpN = 0
private val actBus = IntArray(busVoices.size); private var actBusN = 0
private val actBSmp = IntArray(busSampleVoices.size); private var actBSmpN = 0
private val actLive = IntArray(liveVoices.size); private var actLiveN = 0
private val actASmp = IntArray(auditionSampleVoices.size); private var actASmpN = 0
// The sequencer can start a note mid-block; its voice must join this block's list so
// it sounds sample-accurately (not one block late). [queueSeq]/[queueSmp] append it,
// deduped by a per-voice generation stamp: a voice already queued this block (stamp
// == [blockGen]) is not added twice — which would otherwise double-render (and
// double the amplitude of) a voice that finished and was re-allocated within a block.
private val seqGen = IntArray(seqVoices.size)
private val smpGen = IntArray(sampleVoices.size)
private var blockGen = 0
/** The song being played. Swapped atomically; read on the audio thread. */
@Volatile private var project: Project? = null
@@ -529,24 +548,19 @@ 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
}
// Once per block: collect the indices of the voices that are actually sounding
// into compact lists, so the per-sample loop iterates only those instead of the
// whole pool. Building the lists costs O(pool) once; rendering then costs
// O(active * frames) rather than O(pool * frames). The sequencer appends any
// note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
blockGen++
actSeqN = 0; actSmpN = 0; actBusN = 0; actBSmpN = 0; actLiveN = 0; actASmpN = 0
for (i in seqVoices.indices) if (seqVoices[i].isActive) { seqGen[i] = blockGen; actSeq[actSeqN++] = i }
for (i in sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
for (i in busSampleVoices.indices) if (busSampleVoices[i].isActive) actBSmp[actBSmpN++] = i
for (i in liveVoices.indices) if (liveVoices[i].isActive) actLive[actLiveN++] = i
for (i in auditionSampleVoices.indices) if (auditionSampleVoices[i].isActive) actASmp[actASmpN++] = i
for (i in 0 until frames) {
if (playing && proj != null) advanceSequencer(proj)
@@ -555,24 +569,14 @@ class AudioEngine(
var mixR = 0f
if (proj != null) {
channelSum.fill(0f)
// 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
}
}
// Sum only the voices that are actually sounding this block. Each voice's
// bus is its index / pool stride. Live bus-audition voices feed the same
// per-channel sum, so they run through the channel's FX/volume/mute like
// the sequencer.
for (k in 0 until actSeqN) { val vi = actSeq[k]; channelSum[vi / POLYPHONY] += seqVoices[vi].render() }
for (k in 0 until actSmpN) { val vi = actSmp[k]; channelSum[vi / POLYPHONY] += sampleVoices[vi].render() }
for (k in 0 until actBusN) { val vi = actBus[k]; channelSum[vi / BUS_POLYPHONY] += busVoices[vi].render() }
for (k in 0 until actBSmpN) { val vi = actBSmp[k]; channelSum[vi / BUS_POLYPHONY] += busSampleVoices[vi].render() }
for (ch in 0 until Pattern.TRACK_COUNT) {
val mc = proj.mixer.channels[ch]
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
@@ -588,8 +592,8 @@ class AudioEngine(
mixL += s; mixR += s
}
}
for (lv in liveVoices) { val v = lv.render() * LIVE_GAIN; mixL += v; mixR += v }
for (av in auditionSampleVoices) { val v = av.render() * LIVE_GAIN; mixL += v; mixR += v }
for (k in 0 until actLiveN) { val v = liveVoices[actLive[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
for (k in 0 until actASmpN) { val v = auditionSampleVoices[actASmp[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
val oL = softClip(mixL * MASTER_GAIN)
val oR = softClip(mixR * MASTER_GAIN)
out[2 * i] = oL
@@ -803,10 +807,9 @@ 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
// The allocators ([allocSynthVoice]/[allocSampleVoice]) queue the chosen voice
// into this block's active list, so a note started mid-block sounds sample-
// accurately within the same block.
val slot = proj.toolbox.getOrNull(proj.mixer.channels[bus].instrumentSlot)
val vs = voiceSample(slot, note)
when {