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:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user