diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb9f923..6e92faf 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 = 25 - versionName = "0.9.0" + versionCode = 26 + versionName = "0.9.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/io/SettingsStore.kt b/app/src/main/java/space/rcmd/android/sizzle/io/SettingsStore.kt index eb86a1c..068863e 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/io/SettingsStore.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/io/SettingsStore.kt @@ -6,6 +6,7 @@ package space.rcmd.android.sizzle.io import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -26,6 +27,8 @@ data class AppSettings( val kbdChannel: Int = 0, /** Base octave for the on-screen keyboards. */ 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, kbdChannel = prefs[KBD_CHANNEL] ?: 0, 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 { val PALETTE = stringPreferencesKey("palette") val RECORD_DIR = stringPreferencesKey("recordDirUri") val RECORD_TAIL = intPreferencesKey("recordTailBars") val KBD_CHANNEL = intPreferencesKey("kbdChannel") val KBD_OCTAVE = intPreferencesKey("kbdOctave") + val VU_METERS = booleanPreferencesKey("vuMeters") } } 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 802d82f..7708852 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 @@ -97,6 +97,15 @@ class AppViewModel( var kbdOctave by mutableIntStateOf(3); private set /** Velocity for keyboard notes (0..127). */ 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 togglePunchIn() { punchInMode = !punchInMode } @@ -780,6 +789,7 @@ class AppViewModel( // Restore the on-screen keyboard's channel + octave. kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL) kbdOctave = s.kbdOctave.coerceIn(0, 8) + vuMeters = s.vuMeters engine.recTempDir = appContext.cacheDir engine.setRecordTailBars(recordTailBars) engine.onRecordingStarted = { diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt index 7767b4e..7921afe 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt @@ -70,9 +70,16 @@ fun MixerScreen(vm: AppViewModel) { // 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 // 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 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) { withFrameNanos { } for (ch in 0 until Pattern.TRACK_COUNT) { @@ -89,7 +96,7 @@ fun MixerScreen(vm: AppViewModel) { horizontalArrangement = Arrangement.spacedBy(6.dp), ) { 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()) } } @@ -157,6 +164,7 @@ private fun ChannelStrip( channel: MixerChannel, level: MutableFloatState, clipped: State, + showVu: Boolean, modifier: Modifier, ) { val c = LocalRetro.current @@ -182,12 +190,12 @@ private fun ChannelStrip( Column( Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(4.dp), - ) { ChannelLevels(vm, channel, level, clipped) } + ) { ChannelLevels(vm, channel, level, clipped, showVu) } } } else { // Portrait: one column, the fader filling the leftover height. ChannelRouting(vm, channel) - ChannelLevels(vm, channel, level, clipped) + ChannelLevels(vm, channel, level, clipped, showVu) } } } @@ -244,6 +252,7 @@ private fun ColumnScope.ChannelLevels( channel: MixerChannel, level: MutableFloatState, clipped: State, + showVu: Boolean, ) { @Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh SectionLabel("Volume") @@ -253,6 +262,7 @@ private fun ColumnScope.ChannelLevels( onValueChange = { channel.volume = it; vm.bumpForToolbar() }, level = level, clipped = clipped, + showVu = showVu, modifier = Modifier.weight(1f).fillMaxWidth(), ) // Per-bus soft-clip limiter, sitting above mute/solo. @@ -285,6 +295,7 @@ private fun VerticalFader( onValueChange: (Float) -> Unit, level: MutableFloatState, clipped: State, + showVu: Boolean, modifier: Modifier, ) { val c = LocalRetro.current @@ -308,10 +319,11 @@ private fun VerticalFader( // VU meter, contained within the fill. Colour maps to ABSOLUTE level // 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 - // to a red hue" looks like as the signal climbs. - val lvl = level.floatValue.coerceIn(0f, 1f) + // to a red hue" looks like as the signal climbs. Skipped when the VU + // 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 - if (meterFrac > 0.001f) { + if (showVu && meterFrac > 0.001f) { val mH = meterFrac * h val top = h - mH if (clipped.value) { diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt index cce6336..23ee0ed 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -93,6 +94,7 @@ fun SettingsScreen(vm: AppViewModel) { item { PanicButton(vm) } item { ProjectCard(vm) } item { AudioDevicesCard(vm) } + item { InterfaceCard(vm) } item { ThemeCard(vm) } item { 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 private fun ThemeCard(vm: AppViewModel) { val context = LocalContext.current