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. */
|
/** One insert-effect instance bound to the toolbox slot that configures it.
|
||||||
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect) {
|
* [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
|
// Last slot version / tempo pushed into the effect, so [updateFxParams] can
|
||||||
// skip re-parsing params (which allocates) when nothing changed.
|
// skip re-parsing params (which allocates) when nothing changed.
|
||||||
var lastVersion = -1
|
var lastVersion = -1
|
||||||
var lastTempo = Float.NaN
|
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).
|
/** Per-channel effect chains (mixer FX slots resolved to real processors).
|
||||||
* Rebuilt when routing changes; parameters refresh live every block. Stored as
|
* Rebuilt when routing changes; parameters refresh live every block. Stored as
|
||||||
* arrays (not lists) because the audio thread iterates them per sample, and a
|
* arrays (not lists) because the audio thread iterates them per sample, and a
|
||||||
@@ -257,7 +272,12 @@ class AudioEngine(
|
|||||||
if (slotIndex < 0) continue
|
if (slotIndex < 0) continue
|
||||||
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
||||||
val type = slot.type ?: 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()
|
next[ch] = units.toTypedArray()
|
||||||
}
|
}
|
||||||
@@ -520,7 +540,16 @@ class AudioEngine(
|
|||||||
val mono = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
val mono = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
||||||
io[0] = mono; io[1] = mono
|
io[0] = mono; io[1] = mono
|
||||||
val chain = chains[ch]
|
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
|
// Isolate a misbehaving effect: a non-finite sample from one
|
||||||
// channel's FX must not poison the whole master sum.
|
// channel's FX must not poison the whole master sum.
|
||||||
var l = io[0]; var r = io[1]
|
var l = io[0]; var r = io[1]
|
||||||
@@ -553,6 +582,7 @@ class AudioEngine(
|
|||||||
val v = u.slot.version
|
val v = u.slot.version
|
||||||
if (v != u.lastVersion || tempo != u.lastTempo) {
|
if (v != u.lastVersion || tempo != u.lastTempo) {
|
||||||
u.effect.update(u.slot, tempo)
|
u.effect.update(u.slot, tempo)
|
||||||
|
u.effectR?.update(u.slot, tempo) // keep the right-channel copy in sync
|
||||||
u.lastVersion = v
|
u.lastVersion = v
|
||||||
u.lastTempo = tempo
|
u.lastTempo = tempo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,21 @@ interface AudioEffect {
|
|||||||
/** Process one mono sample. */
|
/** Process one mono sample. */
|
||||||
fun process(x: Float): Float
|
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,
|
* 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
|
* 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
|
* channels and writes it to both — so a mono effect passes audio through as
|
||||||
* mono, collapsing any upstream stereo width. Only effects that genuinely
|
* mono, collapsing any upstream stereo width. It's used when stereo-FX mode is
|
||||||
* produce width (the tape delay's per-head panning) override this, so to keep
|
* off; when on, the engine calls [process] per channel on separate instances
|
||||||
* a delay's stereo place it last in the chain.
|
* instead. Only [nativeStereo] effects override this to produce real width.
|
||||||
*/
|
*/
|
||||||
fun processStereo(io: FloatArray) {
|
fun processStereo(io: FloatArray) {
|
||||||
val m = process((io[0] + io[1]) * 0.5f)
|
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
|
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).
|
// Mono path delegates to the stereo one (the chain always calls processStereo).
|
||||||
override fun process(x: Float): Float {
|
override fun process(x: Float): Float {
|
||||||
stereoScratch[0] = x; stereoScratch[1] = x
|
stereoScratch[0] = x; stereoScratch[1] = x
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.reactorcoremeltdown.sizzletracker.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
|
||||||
@@ -23,6 +24,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,
|
||||||
|
/** 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,
|
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,
|
||||||
|
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 {
|
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 STEREO_FX = booleanPreferencesKey("stereoFx")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -660,6 +660,15 @@ class AppViewModel(
|
|||||||
var nativeAudioError by mutableStateOf<String?>(null); private set
|
var nativeAudioError by mutableStateOf<String?>(null); private set
|
||||||
fun dismissNativeAudioError() { nativeAudioError = null }
|
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. */
|
/** Choose the colour theme and persist the choice. */
|
||||||
fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
|
fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
|
||||||
palette = p
|
palette = p
|
||||||
@@ -738,6 +747,9 @@ 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)
|
||||||
|
// Restore the experimental stereo-FX mode (rebuilds the FX chains).
|
||||||
|
stereoFx = s.stereoFx
|
||||||
|
engine.setStereoFx(s.stereoFx)
|
||||||
engine.recTempDir = appContext.cacheDir
|
engine.recTempDir = appContext.cacheDir
|
||||||
engine.setRecordTailBars(recordTailBars)
|
engine.setRecordTailBars(recordTailBars)
|
||||||
engine.onRecordingStarted = {
|
engine.onRecordingStarted = {
|
||||||
|
|||||||
@@ -44,6 +44,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
|
||||||
@@ -362,6 +363,24 @@ private fun AudioDevicesCard(vm: AppViewModel) {
|
|||||||
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
|
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
|
package com.reactorcoremeltdown.sizzletracker.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
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.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The retro look-and-feel. Two ideas drive everything:
|
* The retro look-and-feel. Two ideas drive everything:
|
||||||
@@ -98,6 +104,24 @@ fun SizzleTheme(
|
|||||||
onSurface = palette.text,
|
onSurface = palette.text,
|
||||||
error = palette.danger,
|
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) {
|
CompositionLocalProvider(LocalRetro provides palette) {
|
||||||
MaterialTheme(colorScheme = scheme, typography = retroTypography, content = content)
|
MaterialTheme(colorScheme = scheme, typography = retroTypography, content = content)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user