Improve light-theme readability; drop experimental stereo FX; add sample-pack builder tool

- Theme: pick a light or dark Material base scheme by palette luminance and map
  the palette onto the container/variant/outline/secondary roles components
  actually read, so cards and secondary text stay legible on bright themes.
- Audio: remove the experimental stereo-FX rendering and the tape-delay per-head
  balance (both worked poorly); each channel runs a single mono FX chain again.
  Master output and recording remain stereo (dual-mono content).
- Tools: add tools/samplepack-builder.html, a standalone page for assembling
  Sampler .zip preset bundles (16 note-labelled drag-n-drop pads, audition,
  16-bit mono WAV re-encode, duplicate-sample detection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-16 20:37:44 +02:00
parent f01a91f0ec
commit 61d7de1f25
9 changed files with 404 additions and 199 deletions

View File

@@ -104,29 +104,14 @@ class AudioEngine(
}
}
/** 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) {
/** One insert-effect instance bound to the toolbox slot that configures it. */
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect) {
// 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
@@ -273,10 +258,7 @@ class AudioEngine(
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
val type = slot.type ?: continue
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))
units.add(FxUnit(slot, fx))
}
}
next[ch] = units.toTypedArray()
@@ -490,17 +472,14 @@ class AudioEngine(
}
}
/** Per-channel FX stereo scratch (audio-thread only): io[0]=L, io[1]=R. */
private val fxIo = FloatArray(2)
/**
* Render [frames] frames of the whole mix into [out] as INTERLEAVED STEREO —
* out[2*i] = left, out[2*i+1] = right, so [out] must hold at least frames*2
* samples. This is the single source of truth for audio: it is called by the
* Kotlin AudioTrack loop AND by the native Oboe callback (via [NativeAudioBridge]),
* so both backends produce identical sound. Runs on an audio thread — must not
* allocate. Voices are mono and feed a mono per-channel sum; stereo width comes
* from stereo-capable insert effects (the tape delay's per-head panning).
* allocate. Voices and inserts are mono, so both master channels carry the same
* sum; the output/recording path is nonetheless stereo (2 interleaved channels).
*/
fun fillBlock(out: FloatArray, frames: Int) {
val proj = project
@@ -517,7 +496,6 @@ class AudioEngine(
// Snapshot the volatile chains once so a concurrent rebuild can't swap them
// mid-block; indexing + array iteration below then allocate nothing.
val chains = channelChains
val io = fxIo
for (i in 0 until frames) {
if (playing && proj != null) advanceSequencer(proj)
@@ -537,27 +515,17 @@ class AudioEngine(
}
for (ch in 0 until Pattern.TRACK_COUNT) {
val mc = proj.mixer.channels[ch]
val mono = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
io[0] = mono; io[1] = mono
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
val chain = chains[ch]
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)
}
}
for (u in chain) s = u.effect.process(s)
// 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]
if (!l.isFinite()) l = 0f
if (!r.isFinite()) r = 0f
if (!s.isFinite()) s = 0f
// Optional per-bus soft-clip limiter (toolbar toggle).
if (mc.limiter) { l = softClip(l); r = softClip(r) }
mixL += l; mixR += r
if (mc.limiter) s = softClip(s)
// Voices and inserts are mono; both master channels get the same
// sum. The master output/recording path stays stereo (2 channels).
mixL += s; mixR += s
}
}
for (lv in liveVoices) { val v = lv.render() * LIVE_GAIN; mixL += v; mixR += v }
@@ -582,7 +550,6 @@ 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,28 +29,6 @@ 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. 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)
io[0] = m
io[1] = m
}
companion object {
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
ToolboxType.DELAY -> TapeDelay(sampleRate)
@@ -77,8 +55,6 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
private var writeIndex = 0
private val delaySamples = IntArray(DelayDivisions.HEADS) { sampleRate / 2 }
private val headOn = BooleanArray(DelayDivisions.HEADS)
private val balance = FloatArray(DelayDivisions.HEADS) // -1 = full left, +1 = full right
private val stereoScratch = FloatArray(2) // for the mono process() fallback
private var feedback = 0.4f
private var dryWet = 0.35f
// Tape character:
@@ -98,7 +74,6 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
val idx = slot.float(HEAD_DIV_KEYS[h], DelayDivisions.DEFAULT.toFloat()).toInt()
delaySamples[h] = (DelayDivisions.beatFraction(idx) * samplesPerBeat)
.toInt().coerceIn(1, buffer.size - 2)
balance[h] = slot.float(HEAD_BAL_KEYS[h], 0f).coerceIn(-1f, 1f)
}
feedback = slot.float("feedback", 0.4f).coerceIn(0f, DelayDivisions.MAX_FEEDBACK)
dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
@@ -109,26 +84,15 @@ 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
processStereo(stereoScratch)
return (stereoScratch[0] + stereoScratch[1]) * 0.5f
}
override fun processStereo(io: FloatArray) {
val x = (io[0] + io[1]) * 0.5f
flutterPhase += flutterInc
if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
val flutter = sin(flutterPhase).toFloat() * flutterDepth
// Read each active head (fractional read + linear interpolation for flutter)
// and pan it by its balance. Linear pan (gainL+gainR == 1) keeps the summed
// level — and the feedback loop gain — independent of head count and pan.
var wetL = 0f
var wetR = 0f
// Read each active head (fractional read + linear interpolation for flutter).
// The wet signal is the AVERAGE of the active heads, so the summed level — and
// the feedback loop gain — stays independent of how many heads are on.
var wet = 0f
var active = 0
for (h in 0 until DelayDivisions.HEADS) {
if (!headOn[h]) continue
@@ -138,31 +102,24 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
val i0 = pos.toInt() % buffer.size
val i1 = (i0 + 1) % buffer.size
val frac = pos - pos.toInt()
val s = buffer[i0] + (buffer[i1] - buffer[i0]) * frac
val b = balance[h]
wetL += s * (1f - b) * 0.5f
wetR += s * (1f + b) * 0.5f
wet += buffer[i0] + (buffer[i1] - buffer[i0]) * frac
}
if (active > 0) { wetL /= active; wetR /= active }
if (active > 0) wet /= active
// Feedback uses the mono wet (wetL + wetR == the pre-pan head average), so the
// loop behaves exactly as the mono delay did regardless of panning.
var fb = (wetL + wetR) * feedback
var fb = wet * feedback
fb += satBlend * (tanh(fb) - fb)
lpState += lpAlpha * (fb - lpState)
fb = lpState
buffer[writeIndex] = x + fb
writeIndex = (writeIndex + 1) % buffer.size
io[0] = x * (1f - dryWet) + wetL * dryWet
io[1] = x * (1f - dryWet) + wetR * dryWet
return x * (1f - dryWet) + wet * dryWet
}
private companion object {
const val FLUTTER_HZ = 5.0
val HEAD_ON_KEYS = Array(DelayDivisions.HEADS) { "head${it}On" }
val HEAD_DIV_KEYS = Array(DelayDivisions.HEADS) { "head${it}Div" }
val HEAD_BAL_KEYS = Array(DelayDivisions.HEADS) { "head${it}Bal" }
}
}

View File

@@ -3,7 +3,6 @@ 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
@@ -24,8 +23,6 @@ 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,
)
/**
@@ -47,7 +44,6 @@ 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,
)
}
@@ -74,17 +70,11 @@ 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

@@ -118,8 +118,6 @@ enum class ToolboxType(
add(ParamSpec("head${h}On", "Head ${h + 1} On", if (h == 0) 1f else 0f, 0f, 1f))
add(ParamSpec("head${h}Div", "Head ${h + 1} Div",
headDefaults[h].toFloat(), 0f, (DelayDivisions.size - 1).toFloat()))
// Per-head stereo balance: -1 = full left, 0 = centre, +1 = full right.
add(ParamSpec("head${h}Bal", "Head ${h + 1} Bal", 0f, -1f, 1f))
}
},
),

View File

@@ -660,15 +660,6 @@ 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
@@ -747,9 +738,6 @@ 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,7 +44,6 @@ 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
@@ -363,24 +362,6 @@ 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

@@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
@@ -92,17 +93,55 @@ private val retroTypography = Typography(
@Composable
fun SizzleTheme(
palette: RetroPalette = RetroPalette.AMBER,
@Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), // always dark; kept for API symmetry
@Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), // follows the palette, not the system
content: @Composable () -> Unit,
) {
val scheme = darkColorScheme(
background = palette.background,
surface = palette.surface,
// Whether this palette reads as a light theme drives which Material base scheme
// we start from. That matters because Material components (Card, ElevatedCard,
// ListItem, DropdownMenu…) pull their container/variant colours from roles we
// don't override — and the dark defaults look wrong (dark cards, muddy dividers)
// on a bright palette. Starting from the matching base keeps those defaults sane;
// then we paint the palette over every role components actually read.
val isLight = palette.background.luminance() > 0.5f
// Text/icon colour to sit *on top of* the accent and danger fills — chosen for
// contrast against the fill itself, not the page, so buttons stay legible on any
// palette (a light accent gets near-black ink, a dark accent gets near-white).
val onAccent = if (palette.accent.luminance() > 0.5f) Color(0xFF0B0D0E) else Color(0xFFF7F7F7)
val onDanger = if (palette.danger.luminance() > 0.5f) Color(0xFF0B0D0E) else Color(0xFFF7F7F7)
val base = if (isLight) lightColorScheme() else darkColorScheme()
val scheme = base.copy(
primary = palette.accent,
onPrimary = palette.background,
onPrimary = onAccent,
primaryContainer = palette.accent,
onPrimaryContainer = onAccent,
secondary = palette.accent,
onSecondary = onAccent,
background = palette.background,
onBackground = palette.text,
surface = palette.surface,
onSurface = palette.text,
// Card / sheet / menu container roles all map to the panel colour so every
// surface matches the toolbar instead of falling back to Material greys.
surfaceVariant = palette.surface,
onSurfaceVariant = palette.textDim,
surfaceContainerLowest = palette.surface,
surfaceContainerLow = palette.surface,
surfaceContainer = palette.surface,
surfaceContainerHigh = palette.surface,
surfaceContainerHighest = palette.surface,
surfaceBright = palette.surface,
surfaceDim = palette.surface,
outline = palette.grid,
outlineVariant = palette.grid,
error = palette.danger,
onError = onDanger,
errorContainer = palette.danger,
onErrorContainer = onDanger,
// Disable Material's automatic elevation tint so cards keep the exact panel
// colour rather than being tinted toward the primary at higher elevations.
surfaceTint = palette.surface,
)
// Tint the system navigation bar to match the app's bottom tab bar (also

View File

@@ -135,60 +135,5 @@ private fun DelayHead(vm: AppViewModel, slot: ToolboxSlot, head: Int, rev: Int,
color = if (on) c.text else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 10.sp,
)
// Horizontal stereo balance for this head: left … centre … right.
HeadBalance(vm, slot, head, on)
}
}
/** A compact horizontal balance (pan) slider for one tape head. Drag/tap to set;
* snaps to centre near the middle. */
@Composable
private fun HeadBalance(vm: AppViewModel, slot: ToolboxSlot, head: Int, on: Boolean) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so the thumb/label refresh
val bal = slot.float("head${head}Bal", 0f).coerceIn(-1f, 1f)
fun setBal(x: Float, w: Float) {
var b = (x / w * 2f - 1f).coerceIn(-1f, 1f)
if (kotlin.math.abs(b) < 0.06f) b = 0f // centre detent
slot.set("head${head}Bal", b); vm.bumpForToolbar()
}
Canvas(
Modifier
.fillMaxWidth()
.height(16.dp)
.background(c.surface)
.border(1.dp, if (on) c.accent else c.grid, RectangleShape)
.pointerInput(head, slot.index) {
detectTapGestures { off -> setBal(off.x, size.width.toFloat()) }
}
.pointerInput(head, slot.index) {
detectDragGestures { change, _ -> change.consume(); setBal(change.position.x, size.width.toFloat()) }
},
) {
val w = size.width
val h = size.height
drawLine(c.grid, Offset(w / 2f, 0f), Offset(w / 2f, h), strokeWidth = 1f) // centre tick
val tx = ((bal + 1f) / 2f) * w
drawRect(
if (on) c.accent else c.textDim,
Offset((tx - 2f).coerceIn(0f, w - 4f), 0f), Size(4f, h),
)
}
Text(
balanceLabel(bal),
color = if (on) c.text else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
)
}
private fun balanceLabel(bal: Float): String {
val pct = kotlin.math.round(bal * 100f).toInt()
return when {
pct == 0 -> "C"
pct < 0 -> "L${-pct}"
else -> "R$pct"
}
}