Make mixer VU meters optional via a Settings toggle (v0.9.1)

Add a persisted "Mixer VU meters" switch (Settings -> Interface, on by default),
backed by a DataStore boolean. When off, the mixer skips the per-frame poll loop
entirely (the LaunchedEffect is keyed on the flag) and doesn't draw the meter, so
disabling it removes the metering cost, not just the visuals; the volume fill and
thumb render exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 21:54:07 +02:00
parent b07d758609
commit 7a0f2e4426
5 changed files with 66 additions and 9 deletions

View File

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

View File

@@ -6,6 +6,7 @@ package space.rcmd.android.sizzle.io
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
@@ -26,6 +27,8 @@ data class AppSettings(
val kbdChannel: Int = 0, val kbdChannel: Int = 0,
/** Base octave for the on-screen keyboards. */ /** Base octave for the on-screen keyboards. */
val kbdOctave: Int = 3, val kbdOctave: Int = 3,
/** Show the live VU meters inside the mixer volume faders. */
val vuMeters: Boolean = true,
) )
/** /**
@@ -47,6 +50,7 @@ class SettingsStore(private val context: Context) {
recordTailBars = prefs[RECORD_TAIL] ?: 2, recordTailBars = prefs[RECORD_TAIL] ?: 2,
kbdChannel = prefs[KBD_CHANNEL] ?: 0, kbdChannel = prefs[KBD_CHANNEL] ?: 0,
kbdOctave = prefs[KBD_OCTAVE] ?: 3, kbdOctave = prefs[KBD_OCTAVE] ?: 3,
vuMeters = prefs[VU_METERS] ?: true,
) )
} }
@@ -73,11 +77,17 @@ class SettingsStore(private val context: Context) {
} }
} }
/** Persist whether the mixer's fader VU meters are shown. */
suspend fun saveVuMeters(enabled: Boolean) {
context.settingsDataStore.edit { prefs -> prefs[VU_METERS] = enabled }
}
private companion object { private companion object {
val PALETTE = stringPreferencesKey("palette") val PALETTE = stringPreferencesKey("palette")
val RECORD_DIR = stringPreferencesKey("recordDirUri") val RECORD_DIR = stringPreferencesKey("recordDirUri")
val RECORD_TAIL = intPreferencesKey("recordTailBars") val RECORD_TAIL = intPreferencesKey("recordTailBars")
val KBD_CHANNEL = intPreferencesKey("kbdChannel") val KBD_CHANNEL = intPreferencesKey("kbdChannel")
val KBD_OCTAVE = intPreferencesKey("kbdOctave") val KBD_OCTAVE = intPreferencesKey("kbdOctave")
val VU_METERS = booleanPreferencesKey("vuMeters")
} }
} }

View File

@@ -97,6 +97,15 @@ class AppViewModel(
var kbdOctave by mutableIntStateOf(3); private set var kbdOctave by mutableIntStateOf(3); private set
/** Velocity for keyboard notes (0..127). */ /** Velocity for keyboard notes (0..127). */
var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set
/** Whether the mixer's fader VU meters are shown (Settings toggle). */
var vuMeters by mutableStateOf(true); private set
/** Enable/disable the mixer fader VU meters and persist the choice. */
fun updateVuMeters(enabled: Boolean) {
vuMeters = enabled
viewModelScope.launch { settingsStore.saveVuMeters(enabled) }
touched()
}
fun updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) } fun updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) }
fun togglePunchIn() { punchInMode = !punchInMode } fun togglePunchIn() { punchInMode = !punchInMode }
@@ -780,6 +789,7 @@ class AppViewModel(
// Restore the on-screen keyboard's channel + octave. // Restore the on-screen keyboard's channel + octave.
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL) kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
kbdOctave = s.kbdOctave.coerceIn(0, 8) kbdOctave = s.kbdOctave.coerceIn(0, 8)
vuMeters = s.vuMeters
engine.recTempDir = appContext.cacheDir engine.recTempDir = appContext.cacheDir
engine.setRecordTailBars(recordTailBars) engine.setRecordTailBars(recordTailBars)
engine.onRecordingStarted = { engine.onRecordingStarted = {

View File

@@ -70,9 +70,16 @@ fun MixerScreen(vm: AppViewModel) {
// stable state objects so only the fader's draw phase (which reads them) repaints — // stable state objects so only the fader's draw phase (which reads them) repaints —
// the strips themselves never recompose from meter movement. Writing an unchanged // the strips themselves never recompose from meter movement. Writing an unchanged
// value is a no-op, so an idle (silent) mixer costs nothing but the frame tick. // value is a no-op, so an idle (silent) mixer costs nothing but the frame tick.
// The whole poll loop is skipped when VU meters are turned off in Settings.
val vuOn = vm.vuMeters
val levels = remember { List(Pattern.TRACK_COUNT) { mutableFloatStateOf(0f) } } val levels = remember { List(Pattern.TRACK_COUNT) { mutableFloatStateOf(0f) } }
val clips = remember { List(Pattern.TRACK_COUNT) { mutableStateOf(false) } } val clips = remember { List(Pattern.TRACK_COUNT) { mutableStateOf(false) } }
LaunchedEffect(Unit) { LaunchedEffect(vuOn) {
if (!vuOn) {
// Clear any lingering values so nothing shows if re-enabled while silent.
for (ch in 0 until Pattern.TRACK_COUNT) { levels[ch].floatValue = 0f; clips[ch].value = false }
return@LaunchedEffect
}
while (true) { while (true) {
withFrameNanos { } withFrameNanos { }
for (ch in 0 until Pattern.TRACK_COUNT) { for (ch in 0 until Pattern.TRACK_COUNT) {
@@ -89,7 +96,7 @@ fun MixerScreen(vm: AppViewModel) {
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
vm.project.mixer.channels.forEach { channel -> vm.project.mixer.channels.forEach { channel ->
ChannelStrip(vm, channel, levels[channel.index], clips[channel.index], ChannelStrip(vm, channel, levels[channel.index], clips[channel.index], vuOn,
Modifier.weight(1f).fillMaxHeight()) Modifier.weight(1f).fillMaxHeight())
} }
} }
@@ -157,6 +164,7 @@ private fun ChannelStrip(
channel: MixerChannel, channel: MixerChannel,
level: MutableFloatState, level: MutableFloatState,
clipped: State<Boolean>, clipped: State<Boolean>,
showVu: Boolean,
modifier: Modifier, modifier: Modifier,
) { ) {
val c = LocalRetro.current val c = LocalRetro.current
@@ -182,12 +190,12 @@ private fun ChannelStrip(
Column( Column(
Modifier.weight(1f).fillMaxHeight(), Modifier.weight(1f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ChannelLevels(vm, channel, level, clipped) } ) { ChannelLevels(vm, channel, level, clipped, showVu) }
} }
} else { } else {
// Portrait: one column, the fader filling the leftover height. // Portrait: one column, the fader filling the leftover height.
ChannelRouting(vm, channel) ChannelRouting(vm, channel)
ChannelLevels(vm, channel, level, clipped) ChannelLevels(vm, channel, level, clipped, showVu)
} }
} }
} }
@@ -244,6 +252,7 @@ private fun ColumnScope.ChannelLevels(
channel: MixerChannel, channel: MixerChannel,
level: MutableFloatState, level: MutableFloatState,
clipped: State<Boolean>, clipped: State<Boolean>,
showVu: Boolean,
) { ) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh @Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
SectionLabel("Volume") SectionLabel("Volume")
@@ -253,6 +262,7 @@ private fun ColumnScope.ChannelLevels(
onValueChange = { channel.volume = it; vm.bumpForToolbar() }, onValueChange = { channel.volume = it; vm.bumpForToolbar() },
level = level, level = level,
clipped = clipped, clipped = clipped,
showVu = showVu,
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
) )
// Per-bus soft-clip limiter, sitting above mute/solo. // Per-bus soft-clip limiter, sitting above mute/solo.
@@ -285,6 +295,7 @@ private fun VerticalFader(
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
level: MutableFloatState, level: MutableFloatState,
clipped: State<Boolean>, clipped: State<Boolean>,
showVu: Boolean,
modifier: Modifier, modifier: Modifier,
) { ) {
val c = LocalRetro.current val c = LocalRetro.current
@@ -308,10 +319,11 @@ private fun VerticalFader(
// VU meter, contained within the fill. Colour maps to ABSOLUTE level // VU meter, contained within the fill. Colour maps to ABSOLUTE level
// over the full track height (bottom green → top red), so a taller bar // over the full track height (bottom green → top red), so a taller bar
// is visibly hotter; the gradient's red tip is what "hard clipping turns // is visibly hotter; the gradient's red tip is what "hard clipping turns
// to a red hue" looks like as the signal climbs. // to a red hue" looks like as the signal climbs. Skipped when the VU
val lvl = level.floatValue.coerceIn(0f, 1f) // meters are turned off in Settings.
val lvl = if (showVu) level.floatValue.coerceIn(0f, 1f) else 0f
val meterFrac = if (lvl < vol) lvl else vol // stay inside the filled area val meterFrac = if (lvl < vol) lvl else vol // stay inside the filled area
if (meterFrac > 0.001f) { if (showVu && meterFrac > 0.001f) {
val mH = meterFrac * h val mH = meterFrac * h
val top = h - mH val top = h - mH
if (clipped.value) { if (clipped.value) {

View File

@@ -47,6 +47,7 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -93,6 +94,7 @@ fun SettingsScreen(vm: AppViewModel) {
item { PanicButton(vm) } item { PanicButton(vm) }
item { ProjectCard(vm) } item { ProjectCard(vm) }
item { AudioDevicesCard(vm) } item { AudioDevicesCard(vm) }
item { InterfaceCard(vm) }
item { ThemeCard(vm) } item { ThemeCard(vm) }
item { item {
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) { NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
@@ -368,6 +370,29 @@ private fun AudioDevicesCard(vm: AppViewModel) {
} }
} }
/** Interface options: currently the mixer VU-meter toggle. */
@Composable
private fun InterfaceCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
SettingsCard("Interface", subtitle = "On-screen display options") {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Mixer VU meters", style = MaterialTheme.typography.bodyMedium)
Text(
"Show live level meters inside the mixer volume faders.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = vm.vuMeters, onCheckedChange = { vm.updateVuMeters(it) })
}
}
}
@Composable @Composable
private fun ThemeCard(vm: AppViewModel) { private fun ThemeCard(vm: AppViewModel) {
val context = LocalContext.current val context = LocalContext.current