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