diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f191f7..60388fa 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 = 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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index 13fa704..2ac506b 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -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 = 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(null); private set diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt index bd0fcc3..61b97c5 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt @@ -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') } diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt index efa2ebd..9fbf013 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/TrackerScreen.kt @@ -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) })