UI smoothness: distinct isPlaying flow + allocation-free grid draw (v0.5.1)

Two changes that cut playback-time UI jank (measured on-device):
- Expose a deduped isPlaying StateFlow (maps the transport flow through
  distinctUntilChanged) and collect THAT in the transport toolbar, so the
  PLAY/PAUSE button recomposes on play↔stop instead of ~12×/second as the
  playhead advances (the full transport emits every line).
- Precompute the pattern grid's fixed-width text (gutter hex, note names,
  velocity/channel) into class-load lookup tables. The grid redraws on
  every playhead line and scroll pixel, and the old toString/padStart
  formatting allocated a String per cell per frame — steady draw-phase
  garbage. The draw is now allocation-free (also improves GlyphCache hit
  rate via stable string instances).

gfxinfo over ~12s of playback on the busy track (moto g84):
  janky frames 47.2% → 32.0%; 99th-pct frame 129ms → 53ms;
  missed vsync 40 → 10; 50th 18ms → 14ms.

No engine/audio change. Remaining jank is the auto-follow scroll
redrawing the whole grid each line (everything shifts), not addressed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 14:49:08 +02:00
parent cf306d25e0
commit 2f671ed372
4 changed files with 38 additions and 11 deletions

View File

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

View File

@@ -19,6 +19,11 @@ import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
@@ -107,6 +112,15 @@ class AppViewModel(
/** Live transport snapshot from the audio thread (drives the playhead). */
val transport get() = engine.transport
/** Just the play/stop state, deduped so it emits ONLY on a play↔stop change — not
* on every playhead line like [transport] does. Composables that only care whether
* playback is running (e.g. the PLAY/PAUSE button) collect this instead of the full
* transport, so they don't recompose ~12×/second during playback. */
val isPlaying: StateFlow<Boolean> = engine.transport
.map { it.isPlaying }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, engine.transport.value.isPlaying)
// ----- Binding-learn state (Settings tab) -----
/** The action id currently waiting to be bound, or null. */
var learnTarget by mutableStateOf<String?>(null); private set

View File

@@ -191,9 +191,8 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
}
// Gutter line number (hex).
drawCentered(glyphs, line.toString(16).uppercase().padStart(2, '0'),
styleGutter, 2f, y, gutterPx, rowPx)
// Gutter line number (hex), from the precomputed table.
drawCentered(glyphs, lineText(line), styleGutter, 2f, y, gutterPx, rowPx)
for (t in 0 until Pattern.TRACK_COUNT) {
val cell = pattern.cell(t, line)
@@ -243,15 +242,27 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCentered(
drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
}
// Precomputed fixed-width text tables. The grid draws on every playhead line and
// every scroll pixel, so formatting these on the fly (toString/padStart) allocated a
// fresh String per cell per frame — steady draw-phase garbage that drove GC. Building
// each once at class-load makes the whole draw allocation-free.
private val HEX2 = Array(256) { it.toString(16).uppercase().padStart(2, '0') } // 00..FF
private val NOTE_NAMES = Array(128) { Pitch.name(it) } // MIDI 0..127
private val CHAN2 = Array(100) { it.toString().padStart(2, '0') } // 00..99
/** Gutter line number (hex), from the [HEX2] table (falls back for out-of-range). */
private fun lineText(line: Int): String =
if (line in HEX2.indices) HEX2[line] else line.toString(16).uppercase().padStart(2, '0')
// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
private fun noteText(cell: Cell): String = when {
cell.isEmpty -> "···"
cell.isNoteOff -> "==="
else -> Pitch.name(cell.note)
else -> NOTE_NAMES.getOrElse(cell.note) { Pitch.name(cell.note) }
}
// A note-off ("===") has no velocity/channel of its own — it's tied to its track —
// so those columns read ".." just like an empty cell.
private fun velText(cell: Cell): String =
if (cell.isEmpty || cell.isNoteOff) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
if (cell.isEmpty || cell.isNoteOff) ".." else HEX2.getOrElse(cell.velocity) { cell.velocity.toString(16).uppercase().padStart(2, '0') }
private fun chanText(cell: Cell): String =
if (cell.isEmpty || cell.isNoteOff) ".." else cell.channel.toString().padStart(2, '0')
if (cell.isEmpty || cell.isNoteOff) ".." else CHAN2.getOrElse(cell.channel) { cell.channel.toString().padStart(2, '0') }

View File

@@ -138,13 +138,15 @@ private fun TransportToolbar(vm: AppViewModel) {
@Composable
private fun TransportButtons(vm: AppViewModel) {
val c = LocalRetro.current
val transport by vm.transport.collectAsState()
// Only the play/stop state, not the full transport — so this toolbar recomposes on
// play↔stop, NOT on every playhead line (which drove needless per-line recomposition).
val isPlaying by vm.isPlaying.collectAsState()
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the tempo readout
val project = vm.project
RetroButton("⧉ STOP", onClick = vm::stop)
// Fixed width so the button doesn't resize as the label toggles.
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
active = isPlaying, width = 112.dp, onClick = vm::playPause)
// Tempo nudger: tap -/+ around a fixed-width readout.
Row(verticalAlignment = Alignment.CenterVertically) {
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })