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.
|
/** 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
|
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect) {
|
||||||
* mode (null otherwise, and always null for natively-stereo effects like the
|
|
||||||
* delay); [effect] then drives the left. */
|
|
||||||
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect, val effectR: AudioEffect? = null) {
|
|
||||||
// Last slot version / tempo pushed into the effect, so [updateFxParams] can
|
// Last slot version / tempo pushed into the effect, so [updateFxParams] can
|
||||||
// skip re-parsing params (which allocates) when nothing changed.
|
// skip re-parsing params (which allocates) when nothing changed.
|
||||||
var lastVersion = -1
|
var lastVersion = -1
|
||||||
var lastTempo = Float.NaN
|
var lastTempo = Float.NaN
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Experimental: process every insert in true stereo (a mono effect runs one copy
|
|
||||||
* per channel) so upstream stereo — e.g. a delay's width — survives into later
|
|
||||||
* effects like the reverb. Higher CPU; off by default. */
|
|
||||||
@Volatile var stereoFx = false; private set
|
|
||||||
|
|
||||||
/** Toggle stereo-FX mode and rebuild the chains (adds/removes the R-instances). */
|
|
||||||
fun setStereoFx(enabled: Boolean) {
|
|
||||||
if (stereoFx == enabled) return
|
|
||||||
stereoFx = enabled
|
|
||||||
rebuildFxChains()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-channel effect chains (mixer FX slots resolved to real processors).
|
/** Per-channel effect chains (mixer FX slots resolved to real processors).
|
||||||
* Rebuilt when routing changes; parameters refresh live every block. Stored as
|
* Rebuilt when routing changes; parameters refresh live every block. Stored as
|
||||||
* arrays (not lists) because the audio thread iterates them per sample, and a
|
* arrays (not lists) because the audio thread iterates them per sample, and a
|
||||||
@@ -273,10 +258,7 @@ class AudioEngine(
|
|||||||
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
||||||
val type = slot.type ?: continue
|
val type = slot.type ?: continue
|
||||||
AudioEffect.create(type, sampleRate)?.let { fx ->
|
AudioEffect.create(type, sampleRate)?.let { fx ->
|
||||||
// In stereo-FX mode a mono effect gets a second instance for the
|
units.add(FxUnit(slot, fx))
|
||||||
// right channel; natively-stereo effects stay single-instance.
|
|
||||||
val right = if (stereoFx && !fx.nativeStereo) AudioEffect.create(type, sampleRate) else null
|
|
||||||
units.add(FxUnit(slot, fx, right))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next[ch] = units.toTypedArray()
|
next[ch] = units.toTypedArray()
|
||||||
@@ -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 —
|
* 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
|
* 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
|
* 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]),
|
* Kotlin AudioTrack loop AND by the native Oboe callback (via [NativeAudioBridge]),
|
||||||
* so both backends produce identical sound. Runs on an audio thread — must not
|
* 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
|
* allocate. Voices and inserts are mono, so both master channels carry the same
|
||||||
* from stereo-capable insert effects (the tape delay's per-head panning).
|
* sum; the output/recording path is nonetheless stereo (2 interleaved channels).
|
||||||
*/
|
*/
|
||||||
fun fillBlock(out: FloatArray, frames: Int) {
|
fun fillBlock(out: FloatArray, frames: Int) {
|
||||||
val proj = project
|
val proj = project
|
||||||
@@ -517,7 +496,6 @@ class AudioEngine(
|
|||||||
// Snapshot the volatile chains once so a concurrent rebuild can't swap them
|
// Snapshot the volatile chains once so a concurrent rebuild can't swap them
|
||||||
// mid-block; indexing + array iteration below then allocate nothing.
|
// mid-block; indexing + array iteration below then allocate nothing.
|
||||||
val chains = channelChains
|
val chains = channelChains
|
||||||
val io = fxIo
|
|
||||||
|
|
||||||
for (i in 0 until frames) {
|
for (i in 0 until frames) {
|
||||||
if (playing && proj != null) advanceSequencer(proj)
|
if (playing && proj != null) advanceSequencer(proj)
|
||||||
@@ -537,27 +515,17 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
for (ch in 0 until Pattern.TRACK_COUNT) {
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
val mc = proj.mixer.channels[ch]
|
val mc = proj.mixer.channels[ch]
|
||||||
val mono = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
||||||
io[0] = mono; io[1] = mono
|
|
||||||
val chain = chains[ch]
|
val chain = chains[ch]
|
||||||
for (u in chain) {
|
for (u in chain) s = u.effect.process(s)
|
||||||
val er = u.effectR
|
|
||||||
if (er != null) {
|
|
||||||
// Stereo-FX mode, mono effect: a copy per channel keeps width.
|
|
||||||
io[0] = u.effect.process(io[0])
|
|
||||||
io[1] = er.process(io[1])
|
|
||||||
} else {
|
|
||||||
u.effect.processStereo(io)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Isolate a misbehaving effect: a non-finite sample from one
|
// Isolate a misbehaving effect: a non-finite sample from one
|
||||||
// channel's FX must not poison the whole master sum.
|
// channel's FX must not poison the whole master sum.
|
||||||
var l = io[0]; var r = io[1]
|
if (!s.isFinite()) s = 0f
|
||||||
if (!l.isFinite()) l = 0f
|
|
||||||
if (!r.isFinite()) r = 0f
|
|
||||||
// Optional per-bus soft-clip limiter (toolbar toggle).
|
// Optional per-bus soft-clip limiter (toolbar toggle).
|
||||||
if (mc.limiter) { l = softClip(l); r = softClip(r) }
|
if (mc.limiter) s = softClip(s)
|
||||||
mixL += l; mixR += r
|
// 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 }
|
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
|
val v = u.slot.version
|
||||||
if (v != u.lastVersion || tempo != u.lastTempo) {
|
if (v != u.lastVersion || tempo != u.lastTempo) {
|
||||||
u.effect.update(u.slot, tempo)
|
u.effect.update(u.slot, tempo)
|
||||||
u.effectR?.update(u.slot, tempo) // keep the right-channel copy in sync
|
|
||||||
u.lastVersion = v
|
u.lastVersion = v
|
||||||
u.lastTempo = tempo
|
u.lastTempo = tempo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,28 +29,6 @@ interface AudioEffect {
|
|||||||
/** Process one mono sample. */
|
/** Process one mono sample. */
|
||||||
fun process(x: Float): Float
|
fun process(x: Float): Float
|
||||||
|
|
||||||
/**
|
|
||||||
* True if this effect produces its own stereo width and must run as a SINGLE
|
|
||||||
* instance via [processStereo] (the tape delay's per-head panning). Effects that
|
|
||||||
* are inherently mono return false: in the experimental stereo-FX mode the engine
|
|
||||||
* runs two copies of them (one per channel) to keep upstream stereo intact.
|
|
||||||
*/
|
|
||||||
val nativeStereo: Boolean get() = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process one STEREO frame in place ([io] is a 2-element scratch: io[0]=L,
|
|
||||||
* 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 {
|
companion object {
|
||||||
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
|
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
|
||||||
ToolboxType.DELAY -> TapeDelay(sampleRate)
|
ToolboxType.DELAY -> TapeDelay(sampleRate)
|
||||||
@@ -77,8 +55,6 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
|
|||||||
private var writeIndex = 0
|
private var writeIndex = 0
|
||||||
private val delaySamples = IntArray(DelayDivisions.HEADS) { sampleRate / 2 }
|
private val delaySamples = IntArray(DelayDivisions.HEADS) { sampleRate / 2 }
|
||||||
private val headOn = BooleanArray(DelayDivisions.HEADS)
|
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 feedback = 0.4f
|
||||||
private var dryWet = 0.35f
|
private var dryWet = 0.35f
|
||||||
// Tape character:
|
// 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()
|
val idx = slot.float(HEAD_DIV_KEYS[h], DelayDivisions.DEFAULT.toFloat()).toInt()
|
||||||
delaySamples[h] = (DelayDivisions.beatFraction(idx) * samplesPerBeat)
|
delaySamples[h] = (DelayDivisions.beatFraction(idx) * samplesPerBeat)
|
||||||
.toInt().coerceIn(1, buffer.size - 2)
|
.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)
|
feedback = slot.float("feedback", 0.4f).coerceIn(0f, DelayDivisions.MAX_FEEDBACK)
|
||||||
dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
|
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
|
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 {
|
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
|
flutterPhase += flutterInc
|
||||||
if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
|
if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
|
||||||
val flutter = sin(flutterPhase).toFloat() * flutterDepth
|
val flutter = sin(flutterPhase).toFloat() * flutterDepth
|
||||||
|
|
||||||
// Read each active head (fractional read + linear interpolation for flutter)
|
// Read each active head (fractional read + linear interpolation for flutter).
|
||||||
// and pan it by its balance. Linear pan (gainL+gainR == 1) keeps the summed
|
// The wet signal is the AVERAGE of the active heads, so the summed level — and
|
||||||
// level — and the feedback loop gain — independent of head count and pan.
|
// the feedback loop gain — stays independent of how many heads are on.
|
||||||
var wetL = 0f
|
var wet = 0f
|
||||||
var wetR = 0f
|
|
||||||
var active = 0
|
var active = 0
|
||||||
for (h in 0 until DelayDivisions.HEADS) {
|
for (h in 0 until DelayDivisions.HEADS) {
|
||||||
if (!headOn[h]) continue
|
if (!headOn[h]) continue
|
||||||
@@ -138,31 +102,24 @@ private class TapeDelay(private val sampleRate: Int) : AudioEffect {
|
|||||||
val i0 = pos.toInt() % buffer.size
|
val i0 = pos.toInt() % buffer.size
|
||||||
val i1 = (i0 + 1) % buffer.size
|
val i1 = (i0 + 1) % buffer.size
|
||||||
val frac = pos - pos.toInt()
|
val frac = pos - pos.toInt()
|
||||||
val s = buffer[i0] + (buffer[i1] - buffer[i0]) * frac
|
wet += buffer[i0] + (buffer[i1] - buffer[i0]) * frac
|
||||||
val b = balance[h]
|
|
||||||
wetL += s * (1f - b) * 0.5f
|
|
||||||
wetR += s * (1f + b) * 0.5f
|
|
||||||
}
|
}
|
||||||
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
|
var fb = wet * feedback
|
||||||
// loop behaves exactly as the mono delay did regardless of panning.
|
|
||||||
var fb = (wetL + wetR) * feedback
|
|
||||||
fb += satBlend * (tanh(fb) - fb)
|
fb += satBlend * (tanh(fb) - fb)
|
||||||
lpState += lpAlpha * (fb - lpState)
|
lpState += lpAlpha * (fb - lpState)
|
||||||
fb = lpState
|
fb = lpState
|
||||||
|
|
||||||
buffer[writeIndex] = x + fb
|
buffer[writeIndex] = x + fb
|
||||||
writeIndex = (writeIndex + 1) % buffer.size
|
writeIndex = (writeIndex + 1) % buffer.size
|
||||||
io[0] = x * (1f - dryWet) + wetL * dryWet
|
return x * (1f - dryWet) + wet * dryWet
|
||||||
io[1] = x * (1f - dryWet) + wetR * dryWet
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FLUTTER_HZ = 5.0
|
const val FLUTTER_HZ = 5.0
|
||||||
val HEAD_ON_KEYS = Array(DelayDivisions.HEADS) { "head${it}On" }
|
val HEAD_ON_KEYS = Array(DelayDivisions.HEADS) { "head${it}On" }
|
||||||
val HEAD_DIV_KEYS = Array(DelayDivisions.HEADS) { "head${it}Div" }
|
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 android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
@@ -24,8 +23,6 @@ data class AppSettings(
|
|||||||
val kbdChannel: Int = 0,
|
val kbdChannel: Int = 0,
|
||||||
/** Base octave for the on-screen keyboards. */
|
/** Base octave for the on-screen keyboards. */
|
||||||
val kbdOctave: Int = 3,
|
val kbdOctave: Int = 3,
|
||||||
/** Experimental: process every insert effect in true stereo (higher CPU). */
|
|
||||||
val stereoFx: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +44,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
recordTailBars = prefs[RECORD_TAIL] ?: 2,
|
recordTailBars = prefs[RECORD_TAIL] ?: 2,
|
||||||
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
|
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
|
||||||
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
|
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
|
||||||
stereoFx = prefs[STEREO_FX] ?: false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
private companion object {
|
||||||
val PALETTE = stringPreferencesKey("palette")
|
val PALETTE = stringPreferencesKey("palette")
|
||||||
val RECORD_DIR = stringPreferencesKey("recordDirUri")
|
val RECORD_DIR = stringPreferencesKey("recordDirUri")
|
||||||
val RECORD_TAIL = intPreferencesKey("recordTailBars")
|
val RECORD_TAIL = intPreferencesKey("recordTailBars")
|
||||||
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
|
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
|
||||||
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
|
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
|
||||||
val STEREO_FX = booleanPreferencesKey("stereoFx")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}On", "Head ${h + 1} On", if (h == 0) 1f else 0f, 0f, 1f))
|
||||||
add(ParamSpec("head${h}Div", "Head ${h + 1} Div",
|
add(ParamSpec("head${h}Div", "Head ${h + 1} Div",
|
||||||
headDefaults[h].toFloat(), 0f, (DelayDivisions.size - 1).toFloat()))
|
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
|
var nativeAudioError by mutableStateOf<String?>(null); private set
|
||||||
fun dismissNativeAudioError() { nativeAudioError = null }
|
fun dismissNativeAudioError() { nativeAudioError = null }
|
||||||
|
|
||||||
/** Experimental: process every insert effect in true stereo (higher CPU). */
|
|
||||||
var stereoFx by mutableStateOf(false); private set
|
|
||||||
fun applyStereoFx(enabled: Boolean) {
|
|
||||||
stereoFx = enabled
|
|
||||||
engine.setStereoFx(enabled)
|
|
||||||
viewModelScope.launch { settingsStore.saveStereoFx(enabled) }
|
|
||||||
touched()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Choose the colour theme and persist the choice. */
|
/** Choose the colour theme and persist the choice. */
|
||||||
fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
|
fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
|
||||||
palette = p
|
palette = p
|
||||||
@@ -747,9 +738,6 @@ class AppViewModel(
|
|||||||
// Restore the on-screen keyboard's channel + octave.
|
// Restore the on-screen keyboard's channel + octave.
|
||||||
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
|
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
|
||||||
kbdOctave = s.kbdOctave.coerceIn(0, 8)
|
kbdOctave = s.kbdOctave.coerceIn(0, 8)
|
||||||
// Restore the experimental stereo-FX mode (rebuilds the FX chains).
|
|
||||||
stereoFx = s.stereoFx
|
|
||||||
engine.setStereoFx(s.stereoFx)
|
|
||||||
engine.recTempDir = appContext.cacheDir
|
engine.recTempDir = appContext.cacheDir
|
||||||
engine.setRecordTailBars(recordTailBars)
|
engine.setRecordTailBars(recordTailBars)
|
||||||
engine.onRecordingStarted = {
|
engine.onRecordingStarted = {
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import androidx.compose.material3.ListItemDefaults
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -363,24 +362,6 @@ private fun AudioDevicesCard(vm: AppViewModel) {
|
|||||||
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
|
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
|
||||||
HorizontalDivider()
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text("Stereo effects (experimental)", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Text(
|
|
||||||
"Process every insert in true stereo, so a delay's width carries " +
|
|
||||||
"into later effects (e.g. delay → reverb). Higher CPU — may cause " +
|
|
||||||
"dropouts on some devices.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(checked = vm.stereoFx, onCheckedChange = { vm.applyStereoFx(it) })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
@@ -92,17 +93,55 @@ private val retroTypography = Typography(
|
|||||||
@Composable
|
@Composable
|
||||||
fun SizzleTheme(
|
fun SizzleTheme(
|
||||||
palette: RetroPalette = RetroPalette.AMBER,
|
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,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scheme = darkColorScheme(
|
// Whether this palette reads as a light theme drives which Material base scheme
|
||||||
background = palette.background,
|
// we start from. That matters because Material components (Card, ElevatedCard,
|
||||||
surface = palette.surface,
|
// 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,
|
primary = palette.accent,
|
||||||
onPrimary = palette.background,
|
onPrimary = onAccent,
|
||||||
|
primaryContainer = palette.accent,
|
||||||
|
onPrimaryContainer = onAccent,
|
||||||
|
secondary = palette.accent,
|
||||||
|
onSecondary = onAccent,
|
||||||
|
background = palette.background,
|
||||||
onBackground = palette.text,
|
onBackground = palette.text,
|
||||||
|
surface = palette.surface,
|
||||||
onSurface = palette.text,
|
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,
|
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
|
// 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,
|
color = if (on) c.text else c.textDim,
|
||||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
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