Add experimental stereo-effects toggle; theme the system nav bar

Stereo effects (experimental), off by default and persisted:
- When on, each mono insert effect runs a second instance for the right
  channel so upstream stereo (e.g. a delay's width) carries into later
  effects like the reverb; the delay stays single-instance (nativeStereo).
- Settings toggle under Audio Devices; rebuilds the FX chains on change.

Seamless system bars:
- SizzleTheme tints the system navigation bar to the palette's surface
  (matching the bottom tab bar) and the status bar to the background, with
  light/dark icon appearance by luminance, re-applied on theme change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-16 16:14:17 +02:00
parent c630dc3694
commit f01a91f0ec
6 changed files with 112 additions and 7 deletions

View File

@@ -104,14 +104,29 @@ class AudioEngine(
}
}
/** One insert-effect instance bound to the toolbox slot that configures it. */
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect) {
/** One insert-effect instance bound to the toolbox slot that configures it.
* [effectR] is a second copy driving the RIGHT channel in experimental stereo-FX
* mode (null otherwise, and always null for natively-stereo effects like the
* delay); [effect] then drives the left. */
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect, val effectR: AudioEffect? = null) {
// Last slot version / tempo pushed into the effect, so [updateFxParams] can
// skip re-parsing params (which allocates) when nothing changed.
var lastVersion = -1
var lastTempo = Float.NaN
}
/** Experimental: process every insert in true stereo (a mono effect runs one copy
* per channel) so upstream stereo — e.g. a delay's width — survives into later
* effects like the reverb. Higher CPU; off by default. */
@Volatile var stereoFx = false; private set
/** Toggle stereo-FX mode and rebuild the chains (adds/removes the R-instances). */
fun setStereoFx(enabled: Boolean) {
if (stereoFx == enabled) return
stereoFx = enabled
rebuildFxChains()
}
/** Per-channel effect chains (mixer FX slots resolved to real processors).
* Rebuilt when routing changes; parameters refresh live every block. Stored as
* arrays (not lists) because the audio thread iterates them per sample, and a
@@ -257,7 +272,12 @@ class AudioEngine(
if (slotIndex < 0) continue
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
val type = slot.type ?: continue
AudioEffect.create(type, sampleRate)?.let { units.add(FxUnit(slot, it)) }
AudioEffect.create(type, sampleRate)?.let { fx ->
// In stereo-FX mode a mono effect gets a second instance for the
// right channel; natively-stereo effects stay single-instance.
val right = if (stereoFx && !fx.nativeStereo) AudioEffect.create(type, sampleRate) else null
units.add(FxUnit(slot, fx, right))
}
}
next[ch] = units.toTypedArray()
}
@@ -520,7 +540,16 @@ class AudioEngine(
val mono = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
io[0] = mono; io[1] = mono
val chain = chains[ch]
for (u in chain) u.effect.processStereo(io)
for (u in chain) {
val er = u.effectR
if (er != null) {
// Stereo-FX mode, mono effect: a copy per channel keeps width.
io[0] = u.effect.process(io[0])
io[1] = er.process(io[1])
} else {
u.effect.processStereo(io)
}
}
// Isolate a misbehaving effect: a non-finite sample from one
// channel's FX must not poison the whole master sum.
var l = io[0]; var r = io[1]
@@ -553,6 +582,7 @@ class AudioEngine(
val v = u.slot.version
if (v != u.lastVersion || tempo != u.lastTempo) {
u.effect.update(u.slot, tempo)
u.effectR?.update(u.slot, tempo) // keep the right-channel copy in sync
u.lastVersion = v
u.lastTempo = tempo
}

View File

@@ -29,13 +29,21 @@ interface AudioEffect {
/** Process one mono sample. */
fun process(x: Float): Float
/**
* True if this effect produces its own stereo width and must run as a SINGLE
* instance via [processStereo] (the tape delay's per-head panning). Effects that
* are inherently mono return false: in the experimental stereo-FX mode the engine
* runs two copies of them (one per channel) to keep upstream stereo intact.
*/
val nativeStereo: Boolean get() = false
/**
* Process one STEREO frame in place ([io] is a 2-element scratch: io[0]=L,
* io[1]=R). The default runs the mono [process] on the average of the two
* channels and writes it to both — so a mono effect passes audio through as
* mono, collapsing any upstream stereo width. Only effects that genuinely
* produce width (the tape delay's per-head panning) override this, so to keep
* a delay's stereo place it last in the chain.
* mono, collapsing any upstream stereo width. It's used when stereo-FX mode is
* off; when on, the engine calls [process] per channel on separate instances
* instead. Only [nativeStereo] effects override this to produce real width.
*/
fun processStereo(io: FloatArray) {
val m = process((io[0] + io[1]) * 0.5f)
@@ -101,6 +109,8 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
flutterDepth = tape * sampleRate * 0.0015f // up to ~1.5 ms wow/flutter
}
override val nativeStereo: Boolean get() = true // produces width via per-head panning
// Mono path delegates to the stereo one (the chain always calls processStereo).
override fun process(x: Float): Float {
stereoScratch[0] = x; stereoScratch[1] = x

View File

@@ -3,6 +3,7 @@ package com.reactorcoremeltdown.sizzletracker.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
@@ -23,6 +24,8 @@ data class AppSettings(
val kbdChannel: Int = 0,
/** Base octave for the on-screen keyboards. */
val kbdOctave: Int = 3,
/** Experimental: process every insert effect in true stereo (higher CPU). */
val stereoFx: Boolean = false,
)
/**
@@ -44,6 +47,7 @@ class SettingsStore(private val context: Context) {
recordTailBars = prefs[RECORD_TAIL] ?: 2,
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
stereoFx = prefs[STEREO_FX] ?: false,
)
}
@@ -70,11 +74,17 @@ class SettingsStore(private val context: Context) {
}
}
/** Persist the experimental stereo-effects toggle. */
suspend fun saveStereoFx(enabled: Boolean) {
context.settingsDataStore.edit { prefs -> prefs[STEREO_FX] = 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 STEREO_FX = booleanPreferencesKey("stereoFx")
}
}

View File

@@ -660,6 +660,15 @@ class AppViewModel(
var nativeAudioError by mutableStateOf<String?>(null); private set
fun dismissNativeAudioError() { nativeAudioError = null }
/** Experimental: process every insert effect in true stereo (higher CPU). */
var stereoFx by mutableStateOf(false); private set
fun applyStereoFx(enabled: Boolean) {
stereoFx = enabled
engine.setStereoFx(enabled)
viewModelScope.launch { settingsStore.saveStereoFx(enabled) }
touched()
}
/** Choose the colour theme and persist the choice. */
fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
palette = p
@@ -738,6 +747,9 @@ class AppViewModel(
// Restore the on-screen keyboard's channel + octave.
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
kbdOctave = s.kbdOctave.coerceIn(0, 8)
// Restore the experimental stereo-FX mode (rebuilds the FX chains).
stereoFx = s.stereoFx
engine.setStereoFx(s.stereoFx)
engine.recTempDir = appContext.cacheDir
engine.setRecordTailBars(recordTailBars)
engine.onRecordingStarted = {

View File

@@ -44,6 +44,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
@@ -362,6 +363,24 @@ private fun AudioDevicesCard(vm: AppViewModel) {
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
HorizontalDivider()
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
HorizontalDivider()
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Stereo effects (experimental)", style = MaterialTheme.typography.bodyMedium)
Text(
"Process every insert in true stereo, so a delay's width carries " +
"into later effects (e.g. delay → reverb). Higher CPU — may cause " +
"dropouts on some devices.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = vm.stereoFx, onCheckedChange = { vm.applyStereoFx(it) })
}
}
}

View File

@@ -1,17 +1,23 @@
package com.reactorcoremeltdown.sizzletracker.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
/**
* The retro look-and-feel. Two ideas drive everything:
@@ -98,6 +104,24 @@ fun SizzleTheme(
onSurface = palette.text,
error = palette.danger,
)
// Tint the system navigation bar to match the app's bottom tab bar (also
// `surface`) so the gesture handle below it looks seamless — and re-tint when the
// palette changes. The status bar keeps the window background for the same reason.
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.navigationBarColor = palette.surface.toArgb()
window.statusBarColor = palette.background.toArgb()
val lightSurface = palette.surface.luminance() > 0.5f
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightNavigationBars = lightSurface
isAppearanceLightStatusBars = palette.background.luminance() > 0.5f
}
}
}
CompositionLocalProvider(LocalRetro provides palette) {
MaterialTheme(colorScheme = scheme, typography = retroTypography, content = content)
}