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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user