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