From 12c209284323a80bc10df0d422db91d205a4baeb Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Sat, 18 Jul 2026 10:39:57 +0200 Subject: [PATCH] Render only active voices via per-block active-voice lists (v0.4.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/audio/AudioEngine.kt | 115 +++++++++--------- 2 files changed, 61 insertions(+), 58 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a88dde5..b084637 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index 2e69234..4809b7d 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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 {