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"
|
||||
}
|
||||
}
|
||||
|
||||
340
tools/samplepack-builder.html
Normal file
340
tools/samplepack-builder.html
Normal file
@@ -0,0 +1,340 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sizzletracker Sample Pack Builder</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0B0D0E; --surface:#15181A; --grid:#23282B; --text:#CFE8D8;
|
||||
--dim:#6E7C74; --accent:#F2C14E; --play:#7CFC9A; --danger:#E05A4B;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--text);
|
||||
font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:14px}
|
||||
body{padding:16px;max-width:900px;margin:0 auto}
|
||||
h1{font-size:18px;color:var(--accent);margin:0 0 2px;letter-spacing:.5px}
|
||||
.sub{color:var(--dim);font-size:11px;margin:0 0 16px}
|
||||
.bar{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;
|
||||
background:var(--surface);border:1px solid var(--grid);padding:12px;margin-bottom:16px}
|
||||
label{display:block;font-size:10px;color:var(--dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
|
||||
input[type=text]{background:var(--bg);border:1px solid var(--grid);color:var(--text);
|
||||
font-family:inherit;font-size:14px;padding:8px 10px;width:220px;outline:none}
|
||||
input[type=text]:focus{border-color:var(--accent)}
|
||||
button{font-family:inherit;font-size:12px;background:var(--surface);color:var(--text);
|
||||
border:1px solid var(--grid);padding:9px 14px;cursor:pointer;letter-spacing:.5px}
|
||||
button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
button.primary{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:bold}
|
||||
button.primary:hover{filter:brightness(1.1)}
|
||||
button:disabled{opacity:.4;cursor:not-allowed;border-color:var(--grid);color:var(--dim)}
|
||||
.spacer{flex:1}
|
||||
.count{font-size:11px;color:var(--dim);align-self:center}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
||||
.pad{position:relative;background:var(--surface);border:1px solid var(--grid);
|
||||
min-height:104px;padding:8px;display:flex;flex-direction:column;cursor:pointer;
|
||||
transition:border-color .08s,background .08s;user-select:none;overflow:hidden}
|
||||
.pad:hover{border-color:var(--dim)}
|
||||
.pad.loaded{border-color:var(--accent);background:rgba(242,193,78,.10)}
|
||||
/* Pads holding a sample identical to another pad's — flagged in red. */
|
||||
.pad.dup{border-color:var(--danger);background:rgba(224,90,75,.18)}
|
||||
.pad.dup .note{color:var(--danger)}
|
||||
.pad.dup .dupmark{display:block}
|
||||
.pad.over{border-color:var(--play);background:rgba(124,252,154,.14)}
|
||||
.pad.playing{border-color:var(--play)}
|
||||
.pad .note{font-size:15px;color:var(--accent);font-weight:bold}
|
||||
.pad.empty .note{color:var(--dim)}
|
||||
.pad .dupmark{display:none;position:absolute;top:26px;right:9px;font-size:8px;
|
||||
color:var(--danger);letter-spacing:.5px}
|
||||
.pad .idx{font-size:9px;color:var(--dim);position:absolute;top:8px;right:9px}
|
||||
.pad .body{flex:1;display:flex;align-items:center;justify-content:center;
|
||||
text-align:center;font-size:10px;color:var(--dim);word-break:break-all;line-height:1.35;padding:4px 2px}
|
||||
.pad.loaded .body{color:var(--text)}
|
||||
.pad .meta{font-size:9px;color:var(--dim);text-align:center;min-height:12px}
|
||||
.pad .x{position:absolute;bottom:6px;right:8px;color:var(--danger);font-size:14px;
|
||||
display:none;padding:2px 4px;line-height:1}
|
||||
.pad.loaded .x{display:block}
|
||||
.pad .x:hover{color:#ff8a7a}
|
||||
.hint{margin-top:16px;font-size:11px;color:var(--dim);line-height:1.6}
|
||||
.hint code{color:var(--text)}
|
||||
.status{margin-top:10px;font-size:11px;min-height:16px}
|
||||
.status.err{color:var(--danger)}
|
||||
.status.ok{color:var(--play)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>▚ SIZZLETRACKER · SAMPLE PACK BUILDER</h1>
|
||||
<p class="sub">Drop a WAV / MP3 / audio file on a pad, tap to audition, then download a Sampler <code>.zip</code> bundle ready to import in the app.</p>
|
||||
|
||||
<div class="bar">
|
||||
<div>
|
||||
<label for="name">Preset name</label>
|
||||
<input id="name" type="text" value="My Sample Pack" spellcheck="false" autocomplete="off">
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="count" id="count">0 / 16 pads</span>
|
||||
<button id="clearAll">Clear all</button>
|
||||
<button id="download" class="primary" disabled>⤓ Download .zip</button>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<p class="hint">
|
||||
Pads are labelled with the note each triggers in the app (pad 1 = <code>C-2</code>, MIDI 36), reading left-to-right, top-to-bottom.
|
||||
Files are re-encoded to 16-bit mono WAV. Dropping several files on a pad fills it and the pads after it.
|
||||
Pads holding the <strong>same sample</strong> as another pad are tinted <span style="color:var(--danger)">red</span> (matched by audio content, not file name).
|
||||
Import the downloaded zip via the Sampler's preset toolbar (<code>Import</code>).
|
||||
</p>
|
||||
|
||||
<input id="filepick" type="file" accept="audio/*,.wav" multiple style="display:none">
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
// ---- Note names: exactly matches the app's Pitch.name (Music.kt) ----
|
||||
const NAMES = ["C-","C#","D-","D#","E-","F-","F#","G-","G#","A-","A#","B-"];
|
||||
const BASE_NOTE = 36; // pad 0 = C2 (SamplerPads.BASE_NOTE)
|
||||
function pitchName(midi){ return NAMES[midi % 12] + (Math.floor(midi/12) - 1); }
|
||||
|
||||
// ---- sanitize(): matches PresetLibrary.sanitize ----
|
||||
function sanitize(name){
|
||||
const t = (name || "").trim().replace(/[^A-Za-z0-9._ -]/g, "_");
|
||||
return t.length ? t : "preset";
|
||||
}
|
||||
|
||||
// ---- pad state ----
|
||||
const PADS = 16;
|
||||
const pads = new Array(PADS).fill(null); // {fileName, mono:Float32Array, sampleRate, buffer:AudioBuffer}
|
||||
let audioCtx = null;
|
||||
function ctx(){ return audioCtx || (audioCtx = new (window.AudioContext || window.webkitAudioContext)()); }
|
||||
|
||||
// ---- audio helpers ----
|
||||
function toMono(ab){
|
||||
const ch = ab.numberOfChannels, len = ab.length, out = new Float32Array(len);
|
||||
for (let c=0;c<ch;c++){ const d = ab.getChannelData(c); for (let i=0;i<len;i++) out[i]+=d[i]; }
|
||||
if (ch>1) for (let i=0;i<len;i++) out[i]/=ch;
|
||||
return out;
|
||||
}
|
||||
// Content signature for a sample: FNV-1a over the 16-bit-quantised PCM plus
|
||||
// length + rate. Two pads with the SAME audio produce the same signature
|
||||
// regardless of the file name they came from, so duplicates are detected by
|
||||
// content, not by name.
|
||||
function sigOf(mono, sampleRate){
|
||||
let h = 0x811c9dc5 >>> 0;
|
||||
const mix = b => { h = Math.imul(h ^ (b & 0xff), 0x01000193) >>> 0; };
|
||||
const n = mono.length;
|
||||
mix(n); mix(n >> 8); mix(n >> 16); mix(sampleRate); mix(sampleRate >> 8);
|
||||
for (let i=0;i<n;i++){ const v = (Math.max(-1, Math.min(1, mono[i])) * 32767) | 0; mix(v); mix(v >> 8); }
|
||||
return n.toString(16) + ":" + sampleRate.toString(16) + ":" + (h >>> 0).toString(16);
|
||||
}
|
||||
|
||||
function encodeWav(mono, sampleRate){
|
||||
const n = mono.length, buf = new ArrayBuffer(44 + n*2), dv = new DataView(buf);
|
||||
let p = 0;
|
||||
const str = s => { for (let i=0;i<s.length;i++) dv.setUint8(p++, s.charCodeAt(i)); };
|
||||
str("RIFF"); dv.setUint32(p,36+n*2,true); p+=4; str("WAVE");
|
||||
str("fmt "); dv.setUint32(p,16,true); p+=4;
|
||||
dv.setUint16(p,1,true); p+=2; // PCM
|
||||
dv.setUint16(p,1,true); p+=2; // mono
|
||||
dv.setUint32(p,sampleRate,true); p+=4;
|
||||
dv.setUint32(p,sampleRate*2,true); p+=4; // byteRate
|
||||
dv.setUint16(p,2,true); p+=2; // blockAlign
|
||||
dv.setUint16(p,16,true); p+=2; // bits
|
||||
str("data"); dv.setUint32(p,n*2,true); p+=4;
|
||||
for (let i=0;i<n;i++){ let v = Math.max(-1, Math.min(1, mono[i])); dv.setInt16(p, v<0 ? v*32768 : v*32767, true); p+=2; }
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
// ---- ZIP writer (STORE / no compression, self-contained) ----
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n=0;n<256;n++){ let c=n; for (let k=0;k<8;k++) c = (c&1) ? (0xEDB88320 ^ (c>>>1)) : (c>>>1); t[n]=c>>>0; }
|
||||
return t;
|
||||
})();
|
||||
function crc32(buf){
|
||||
let c = 0xFFFFFFFF;
|
||||
for (let i=0;i<buf.length;i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c>>>8);
|
||||
return (c ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
function zipStore(entries){ // entries: [{name, data:Uint8Array}]
|
||||
const enc = new TextEncoder();
|
||||
const parts = [], central = [];
|
||||
let offset = 0;
|
||||
for (const e of entries){
|
||||
const nb = enc.encode(e.name), crc = crc32(e.data), size = e.data.length;
|
||||
const lh = new DataView(new ArrayBuffer(30));
|
||||
lh.setUint32(0,0x04034b50,true); lh.setUint16(4,20,true); lh.setUint16(6,0,true);
|
||||
lh.setUint16(8,0,true); // method = store
|
||||
lh.setUint16(10,0,true); lh.setUint16(12,0x21,true); // time / date (1980-01-01)
|
||||
lh.setUint32(14,crc,true); lh.setUint32(18,size,true); lh.setUint32(22,size,true);
|
||||
lh.setUint16(26,nb.length,true); lh.setUint16(28,0,true);
|
||||
parts.push(new Uint8Array(lh.buffer), nb, e.data);
|
||||
const ch = new DataView(new ArrayBuffer(46));
|
||||
ch.setUint32(0,0x02014b50,true); ch.setUint16(4,20,true); ch.setUint16(6,20,true);
|
||||
ch.setUint16(8,0,true); ch.setUint16(10,0,true); ch.setUint16(12,0,true); ch.setUint16(14,0x21,true);
|
||||
ch.setUint32(16,crc,true); ch.setUint32(20,size,true); ch.setUint32(24,size,true);
|
||||
ch.setUint16(28,nb.length,true); ch.setUint16(30,0,true); ch.setUint16(32,0,true);
|
||||
ch.setUint16(34,0,true); ch.setUint16(36,0,true); ch.setUint32(38,0,true); ch.setUint32(42,offset,true);
|
||||
central.push({header:new Uint8Array(ch.buffer), name:nb});
|
||||
offset += 30 + nb.length + size;
|
||||
}
|
||||
const cdParts = []; let cdSize = 0;
|
||||
for (const c of central){ cdParts.push(c.header, c.name); cdSize += c.header.length + c.name.length; }
|
||||
const eo = new DataView(new ArrayBuffer(22));
|
||||
eo.setUint32(0,0x06054b50,true); eo.setUint16(4,0,true); eo.setUint16(6,0,true);
|
||||
eo.setUint16(8,central.length,true); eo.setUint16(10,central.length,true);
|
||||
eo.setUint32(12,cdSize,true); eo.setUint32(16,offset,true); eo.setUint16(20,0,true);
|
||||
return new Blob([...parts, ...cdParts, new Uint8Array(eo.buffer)], {type:"application/zip"});
|
||||
}
|
||||
|
||||
// ---- preset (.szp) text: matches PresetLibrary.exportSampler ----
|
||||
function buildSzp(name){
|
||||
const safe = sanitize(name);
|
||||
const lines = ["SIZZLE-PRESET 1", "TYPE=SAMPLER", "NAME=" + name, "volume=0.8"];
|
||||
for (let p=0;p<PADS;p++){
|
||||
if (!pads[p]) continue;
|
||||
const wn = safe + "_pad" + p + ".wav";
|
||||
lines.push("pad" + p + "File=" + wn);
|
||||
lines.push("pad" + p + "S=0.0");
|
||||
lines.push("pad" + p + "E=1.0");
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
const grid = document.getElementById("grid");
|
||||
const statusEl = document.getElementById("status");
|
||||
const countEl = document.getElementById("count");
|
||||
const downloadBtn = document.getElementById("download");
|
||||
const nameInput = document.getElementById("name");
|
||||
const filepick = document.getElementById("filepick");
|
||||
let pickTarget = 0;
|
||||
|
||||
function setStatus(msg, kind){ statusEl.textContent = msg || ""; statusEl.className = "status" + (kind ? " " + kind : ""); }
|
||||
|
||||
function refresh(){
|
||||
// Count how many pads share each content signature so duplicates can be tinted.
|
||||
const counts = {};
|
||||
for (let p=0;p<PADS;p++){ const s = pads[p]; if (s) counts[s.sig] = (counts[s.sig] || 0) + 1; }
|
||||
let n = 0, dups = 0;
|
||||
for (let p=0;p<PADS;p++){
|
||||
const el = document.getElementById("pad" + p);
|
||||
const s = pads[p];
|
||||
const dup = !!s && counts[s.sig] > 1;
|
||||
el.classList.toggle("loaded", !!s);
|
||||
el.classList.toggle("empty", !s);
|
||||
el.classList.toggle("dup", dup);
|
||||
el.querySelector(".body").textContent = s ? s.fileName : "drop / click";
|
||||
el.querySelector(".meta").textContent = s
|
||||
? (s.buffer.duration.toFixed(2) + "s · " + Math.round(s.sampleRate/1000*10)/10 + "k") : "";
|
||||
if (s) n++;
|
||||
if (dup) dups++;
|
||||
}
|
||||
countEl.textContent = n + " / 16 pads" + (dups ? " · " + dups + " duplicate" + (dups===1?"":"s") : "");
|
||||
downloadBtn.disabled = n === 0;
|
||||
}
|
||||
|
||||
async function loadFileToPad(pad, file){
|
||||
try{
|
||||
const buf = await file.arrayBuffer();
|
||||
const ab = await ctx().decodeAudioData(buf);
|
||||
const mono = toMono(ab);
|
||||
pads[pad] = { fileName: file.name, mono, sampleRate: ab.sampleRate, buffer: ab, sig: sigOf(mono, ab.sampleRate) };
|
||||
refresh();
|
||||
setStatus("Loaded “" + file.name + "” → pad " + (pad+1) + " (" + pitchName(BASE_NOTE+pad) + ")", "ok");
|
||||
}catch(e){
|
||||
setStatus("Could not decode “" + file.name + "” — not a supported audio file.", "err");
|
||||
}
|
||||
}
|
||||
|
||||
// Load a list of files starting at `pad`, filling consecutive pads.
|
||||
async function loadFiles(pad, fileList){
|
||||
const files = Array.from(fileList).filter(f => f);
|
||||
for (let i=0;i<files.length && (pad+i)<PADS;i++){
|
||||
await loadFileToPad(pad + i, files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function play(pad){
|
||||
const s = pads[pad];
|
||||
if (!s) return;
|
||||
const c = ctx();
|
||||
if (c.state === "suspended") c.resume();
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = s.buffer;
|
||||
src.connect(c.destination);
|
||||
src.start();
|
||||
const el = document.getElementById("pad" + pad);
|
||||
el.classList.add("playing");
|
||||
src.onended = () => el.classList.remove("playing");
|
||||
}
|
||||
|
||||
// Build the 16 pads.
|
||||
for (let p=0;p<PADS;p++){
|
||||
const el = document.createElement("div");
|
||||
el.className = "pad empty";
|
||||
el.id = "pad" + p;
|
||||
el.innerHTML =
|
||||
'<div class="note">' + pitchName(BASE_NOTE + p) + '</div>' +
|
||||
'<div class="idx">' + (p+1) + '</div>' +
|
||||
'<div class="dupmark">DUP</div>' +
|
||||
'<div class="body">drop / click</div>' +
|
||||
'<div class="meta"></div>' +
|
||||
'<div class="x" title="Clear pad">✕</div>';
|
||||
|
||||
el.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("x")) return;
|
||||
if (pads[p]) play(p);
|
||||
else { pickTarget = p; filepick.value = ""; filepick.click(); }
|
||||
});
|
||||
el.querySelector(".x").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
pads[p] = null; refresh(); setStatus("Cleared pad " + (p+1), "");
|
||||
});
|
||||
|
||||
["dragenter","dragover"].forEach(ev => el.addEventListener(ev, (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); el.classList.add("over");
|
||||
}));
|
||||
["dragleave","dragend"].forEach(ev => el.addEventListener(ev, () => el.classList.remove("over")));
|
||||
el.addEventListener("drop", (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); el.classList.remove("over");
|
||||
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) loadFiles(p, e.dataTransfer.files);
|
||||
});
|
||||
|
||||
grid.appendChild(el);
|
||||
}
|
||||
|
||||
filepick.addEventListener("change", () => { if (filepick.files.length) loadFiles(pickTarget, filepick.files); });
|
||||
|
||||
// Prevent the browser from navigating when a file is dropped outside a pad.
|
||||
["dragover","drop"].forEach(ev => window.addEventListener(ev, (e) => e.preventDefault()));
|
||||
|
||||
document.getElementById("clearAll").addEventListener("click", () => {
|
||||
for (let p=0;p<PADS;p++) pads[p] = null;
|
||||
refresh(); setStatus("Cleared all pads", "");
|
||||
});
|
||||
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
const name = nameInput.value.trim() || "Sample Pack";
|
||||
const safe = sanitize(name);
|
||||
const entries = [{ name: safe + ".szp", data: new TextEncoder().encode(buildSzp(name)) }];
|
||||
let n = 0;
|
||||
for (let p=0;p<PADS;p++){
|
||||
if (!pads[p]) continue;
|
||||
entries.push({ name: safe + "_pad" + p + ".wav", data: encodeWav(pads[p].mono, pads[p].sampleRate) });
|
||||
n++;
|
||||
}
|
||||
if (n === 0){ setStatus("Assign at least one pad first.", "err"); return; }
|
||||
const blob = zipStore(entries);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = safe + ".zip"; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
setStatus("Downloaded " + safe + ".zip — " + n + " pad" + (n===1?"":"s") + ".", "ok");
|
||||
});
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user