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"
}
}

View 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&nbsp;1 = <code>C-2</code>, MIDI&nbsp;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>