diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt index b441909..093635e 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt @@ -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 } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt index c91ad0d..07a3160 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt @@ -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 diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt index 1fb2126..3b91944 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt @@ -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") } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt index cf89b85..ab0d057 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt @@ -660,6 +660,15 @@ class AppViewModel( var nativeAudioError by mutableStateOf(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 = { diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt index 6149de1..516b912 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt @@ -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) }) + } } } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt index 3899b77..c59d787 100644 --- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt @@ -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) }