diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 278f41b..9372be5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -51,14 +51,14 @@ android {
)
}
- // A realistic-performance variant for testing UI responsiveness on-device.
- // The `debug` build is `debuggable = true`, which makes ART run the app
- // un-optimised and makes Compose several times slower — so measuring UI
- // "snappiness" on a debug build is misleading. This variant flips
+ // A realistic-performance "staging" variant for testing UI responsiveness
+ // on-device. The `debug` build is `debuggable = true`, which makes ART run
+ // the app un-optimised and makes Compose several times slower — so measuring
+ // UI "snappiness" on a debug build is misleading. This variant flips
// debuggability off (the single biggest perf lever) while reusing the debug
// signing key, so it still installs without a release keystore. R8/shrinking
// stays off to avoid needing JNI/Compose keep-rules for now.
- create("profile") {
+ create("staging") {
initWith(getByName("debug"))
isDebuggable = false
isJniDebuggable = false
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8ec8f06..9900bd5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -51,6 +51,18 @@
android:resource="@xml/usb_device_filter" />
+
+
+
+
+
#include
#include
+#include
#include
+#include
+
+// Optional profiling of the real-time render callback (render time, xruns, buffer
+// size), logged ~once/second under tag SizzleNative. Off by default; set to 1 to
+// diagnose audio glitches.
+#define SIZZLE_PROFILE 0
#define LOG_TAG "SizzleNative"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
@@ -22,6 +29,19 @@ jobject g_callback = nullptr; // global ref to the NativeAudioBridge
jmethodID g_renderMethod = nullptr;
jfloatArray g_buffer = nullptr; // reused Java float[] of g_frames length
int g_frames = 0;
+int32_t g_prevXRuns = 0; // last-seen underrun count, for adaptive sizing
+int32_t g_sampleRate = 48000; // remembered so the stream can be reopened on disconnect
+std::atomic g_wantRunning{false}; // false once nativeStop is called, so a
+ // concurrent disconnect doesn't reopen a stream
+ // the app deliberately shut down
+
+// How glitch-resistant the output buffer starts (in device bursts). The buffer
+// then grows adaptively (see onAudioReady) if underruns still occur. Profiling
+// showed the JVM render is fast on average (<1 ms) but spikes to 3-7 ms when a GC
+// pause freezes the callback thread; 4 bursts (~8 ms on a 96-frame device) absorbs
+// those from the first note instead of glitching until adaptive growth catches up.
+constexpr int32_t kInitialBursts = 8;
+constexpr int32_t kMaxBufferFrames = 8192; // cap for the reused Java buffer
// Fetch a JNIEnv for the (persistent) audio callback thread, attaching once.
JNIEnv *callbackEnv() {
@@ -37,6 +57,22 @@ public:
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData,
int32_t numFrames) override {
auto *out = static_cast(audioData);
+
+ // Always time the render — the adaptive buffer sizing below relies on it
+ // (this device reports getXRunCount()==0, so an xrun-only heuristic never
+ // fires even under real underruns).
+ struct timespec ts0;
+ clock_gettime(CLOCK_MONOTONIC, &ts0);
+
+ // Adaptive buffer sizing (xrun path): if AAudio reports an underrun, grow the
+ // buffer by one burst (up to capacity). Kept for devices where it works.
+ auto xr = stream->getXRunCount();
+ if (xr && xr.value() > g_prevXRuns) {
+ g_prevXRuns = xr.value();
+ stream->setBufferSizeInFrames(
+ stream->getBufferSizeInFrames() + stream->getFramesPerBurst());
+ }
+
JNIEnv *env = callbackEnv();
if (!env || !g_callback || !g_renderMethod || !g_buffer) {
for (int i = 0; i < numFrames; ++i) out[i] = 0.0f;
@@ -45,9 +81,59 @@ public:
const int n = numFrames < g_frames ? numFrames : g_frames;
// Ask Kotlin to render n frames into the reused Java buffer ...
env->CallVoidMethod(g_callback, g_renderMethod, g_buffer, n);
- // ... then copy them out to Oboe (and pad any remainder with silence).
+ // ... then copy them out to Oboe (and pad any remainder with silence — the
+ // Java buffer is sized to the stream capacity, so this should not trigger).
env->GetFloatArrayRegion(g_buffer, 0, n, out);
for (int i = n; i < numFrames; ++i) out[i] = 0.0f;
+
+ // Adaptive buffer sizing (timing path): a callback that renders slower than
+ // its deadline will drain the buffer; a couple in a row means we're about to
+ // (or did) underrun, so grow the buffer a burst up to capacity. This is what
+ // actually absorbs the crackle here, since getXRunCount() stays 0.
+ {
+ struct timespec ts1;
+ clock_gettime(CLOCK_MONOTONIC, &ts1);
+ const double renderMs =
+ (ts1.tv_sec - ts0.tv_sec) * 1e3 + (ts1.tv_nsec - ts0.tv_nsec) / 1e6;
+ const double budgetMs = numFrames * 1000.0 / (double) stream->getSampleRate();
+ static int sConsecutiveOver = 0;
+ if (renderMs > budgetMs) {
+ if (++sConsecutiveOver >= 2) {
+ const int32_t cap = stream->getBufferCapacityInFrames();
+ const int32_t cur = stream->getBufferSizeInFrames();
+ const int32_t burst = stream->getFramesPerBurst();
+ if (cur + burst <= cap) stream->setBufferSizeInFrames(cur + burst);
+ sConsecutiveOver = 0;
+ }
+ } else {
+ sConsecutiveOver = 0;
+ }
+ }
+
+#if SIZZLE_PROFILE
+ {
+ struct timespec ts1;
+ clock_gettime(CLOCK_MONOTONIC, &ts1);
+ double renderMs = (ts1.tv_sec - ts0.tv_sec) * 1e3 + (ts1.tv_nsec - ts0.tv_nsec) / 1e6;
+ static double sMaxMs = 0, sSumMs = 0;
+ static int sCount = 0, sOver = 0;
+ static int32_t sBaseXRuns = -1;
+ sSumMs += renderMs;
+ if (renderMs > sMaxMs) sMaxMs = renderMs;
+ // Budget = one callback's worth of time; count overruns of the callback.
+ double budgetMs = numFrames * 1000.0 / (double) stream->getSampleRate();
+ if (renderMs > budgetMs) sOver++;
+ if (sBaseXRuns < 0 && xr) sBaseXRuns = xr.value();
+ if (++sCount >= 500) {
+ int32_t xrNow = xr ? xr.value() : -1;
+ LOGI("prof: cbSize=%d budget=%.2fms avg=%.2fms max=%.2fms over=%d xruns=%d(+%d) buf=%d",
+ numFrames, budgetMs, sSumMs / sCount, sMaxMs, sOver,
+ xrNow, (sBaseXRuns >= 0 ? xrNow - sBaseXRuns : -1),
+ stream->getBufferSizeInFrames());
+ sMaxMs = 0; sSumMs = 0; sCount = 0; sOver = 0; sBaseXRuns = xrNow;
+ }
+ }
+#endif
return oboe::DataCallbackResult::Continue;
}
};
@@ -55,6 +141,67 @@ public:
SizzleCallback g_dataCallback;
std::shared_ptr g_stream;
+bool openAndStartStream(); // fwd
+
+// Recovers from a stream disconnect — e.g. a voice assistant or a phone call grabs
+// the audio route, or headphones are (un)plugged. AAudio delivers ErrorDisconnected
+// and closes the stream; without reopening it, the data callback never fires again
+// and playback freezes (the sequencer only advances inside the callback). Oboe calls
+// this on its own error thread AFTER the stream is closed, so it is safe to open a
+// fresh one here (a few retries cover a route that is briefly still busy).
+class SizzleErrorCallback : public oboe::AudioStreamErrorCallback {
+public:
+ void onErrorAfterClose(oboe::AudioStream * /*stream*/, oboe::Result error) override {
+ LOGE("Oboe stream disconnected (%s) — reopening", oboe::convertToText(error));
+ g_prevXRuns = 0;
+ for (int attempt = 0; attempt < 10; ++attempt) {
+ if (!g_wantRunning) return; // app asked to stop while we were recovering
+ if (openAndStartStream()) {
+ LOGI("Oboe stream reopened after disconnect (attempt %d)", attempt + 1);
+ return;
+ }
+ struct timespec ts{0, 200 * 1000 * 1000}; // 200 ms
+ nanosleep(&ts, nullptr);
+ }
+ LOGE("Oboe reopen failed after retries; audio will stay silent until restart");
+ }
+};
+
+SizzleErrorCallback g_errorCallback;
+
+// Opens, buffers and starts the low-latency output stream. Used both at initial
+// start and to recover from a disconnect. Reuses the already-allocated g_buffer.
+bool openAndStartStream() {
+ oboe::AudioStreamBuilder builder;
+ builder.setDirection(oboe::Direction::Output)
+ ->setPerformanceMode(oboe::PerformanceMode::LowLatency)
+ ->setSharingMode(oboe::SharingMode::Shared)
+ ->setFormat(oboe::AudioFormat::Float)
+ ->setChannelCount(oboe::ChannelCount::Mono)
+ ->setSampleRate(g_sampleRate)
+ ->setDataCallback(&g_dataCallback)
+ ->setErrorCallback(&g_errorCallback);
+
+ oboe::Result result = builder.openStream(g_stream);
+ if (result != oboe::Result::OK) {
+ LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
+ return false;
+ }
+ const int32_t burst = g_stream->getFramesPerBurst();
+ g_stream->setBufferSizeInFrames(burst * kInitialBursts);
+ result = g_stream->requestStart();
+ if (result != oboe::Result::OK) {
+ LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
+ g_stream->close();
+ g_stream.reset();
+ return false;
+ }
+ LOGI("Oboe stream started (%d Hz, burst=%d, bufferSize=%d, capacity=%d, javaBuf=%d)",
+ g_sampleRate, burst, g_stream->getBufferSizeInFrames(),
+ g_stream->getBufferCapacityInFrames(), g_frames);
+ return true;
+}
+
} // namespace
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
@@ -65,41 +212,33 @@ extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
extern "C" JNIEXPORT jboolean JNICALL
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStart(
JNIEnv *env, jobject /*thiz*/, jobject callback, jint sampleRate, jint framesPerCallback) {
- g_frames = framesPerCallback;
g_callback = env->NewGlobalRef(callback);
jclass cls = env->GetObjectClass(callback);
g_renderMethod = env->GetMethodID(cls, "renderAudio", "([FI)V");
- g_buffer = static_cast(env->NewGlobalRef(env->NewFloatArray(framesPerCallback)));
+ g_prevXRuns = 0;
+ g_sampleRate = sampleRate;
+ g_wantRunning = true;
- oboe::AudioStreamBuilder builder;
- builder.setDirection(oboe::Direction::Output)
- ->setPerformanceMode(oboe::PerformanceMode::LowLatency)
- ->setSharingMode(oboe::SharingMode::Shared)
- ->setFormat(oboe::AudioFormat::Float)
- ->setChannelCount(oboe::ChannelCount::Mono)
- ->setSampleRate(sampleRate)
- ->setFramesPerDataCallback(framesPerCallback)
- ->setDataCallback(&g_dataCallback);
+ // Allocate the reused Java render buffer up front, sized to our maximum callback,
+ // so the stream can be transparently reopened on a disconnect without touching
+ // JNI from Oboe's (non-Java) error thread. The data callback only ever renders
+ // min(numFrames, g_frames) frames, and low-latency bursts are far smaller.
+ g_frames = kMaxBufferFrames;
+ if (g_frames < framesPerCallback) g_frames = framesPerCallback;
+ g_buffer = static_cast(env->NewGlobalRef(env->NewFloatArray(g_frames)));
- oboe::Result result = builder.openStream(g_stream);
- if (result != oboe::Result::OK) {
- LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
+ if (!openAndStartStream()) {
+ env->DeleteGlobalRef(g_buffer);
+ g_buffer = nullptr;
return JNI_FALSE;
}
- result = g_stream->requestStart();
- if (result != oboe::Result::OK) {
- LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
- g_stream->close();
- g_stream.reset();
- return JNI_FALSE;
- }
- LOGI("Oboe stream started (%d Hz, %d frames/callback)", sampleRate, framesPerCallback);
return JNI_TRUE;
}
extern "C" JNIEXPORT void JNICALL
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStop(
JNIEnv *env, jobject /*thiz*/) {
+ g_wantRunning = false; // stop the disconnect-recovery from reopening
if (g_stream) {
g_stream->stop();
g_stream->close();
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt
index 68e332a..aaf3542 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt
@@ -45,7 +45,12 @@ class MainActivity : ComponentActivity() {
this,
viewModelFactory {
initializer {
- AppViewModel(app.project, app.audioEngine, app.inputRouter, app.gamepadInput, app.midiInput)
+ AppViewModel(
+ app.project, app.audioEngine, app.inputRouter,
+ app.gamepadInput, app.midiInput, app.keyboardInput,
+ app.bindingStore, app.presetLibrary, app.songLibrary,
+ app.settingsStore, app.applicationContext,
+ )
}
},
)[AppViewModel::class.java]
@@ -83,25 +88,40 @@ class MainActivity : ComponentActivity() {
}
override fun onStop() {
+ // Autosave the project whenever we leave the foreground, so a later
+ // system-kill (or crash) resumes from here on next launch.
+ app.projectStore.save(app.project)
midi.stop()
super.onStop()
}
// ---- Hardware input forwarding ----
- // Physical keyboards and most gamepad buttons arrive as key events; we give
- // the gamepad handler first refusal, then the keyboard handler.
+ // Gamepad events are intercepted at DISPATCH time — before Compose's focus
+ // system can consume D-pad / button keys — so USB *and* Bluetooth pads work
+ // reliably. The gamepad handler ignores non-gamepad events, so anything else
+ // (keyboard keys, text-field typing, UI) flows through the normal path below.
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val handledByPad = when (event.action) {
+ KeyEvent.ACTION_DOWN -> gamepad.onKeyDown(event.keyCode, event)
+ KeyEvent.ACTION_UP -> gamepad.onKeyUp(event.keyCode, event)
+ else -> false
+ }
+ return handledByPad || super.dispatchKeyEvent(event)
+ }
+
+ // Analog sticks / triggers / D-pad hats arrive as generic motion events;
+ // intercept them the same way (the handler ignores non-joystick motion).
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean =
+ gamepad.onMotion(event) || super.dispatchGenericMotionEvent(event)
+
+ // Physical keyboard reaches here via the normal path (gamepad events were
+ // already handled in dispatchKeyEvent and never arrive here).
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean =
- gamepad.onKeyDown(keyCode, event) ||
- keyboard.onKeyDown(keyCode, event) ||
- super.onKeyDown(keyCode, event)
+ keyboard.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
keyboard.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
- // Analog sticks / triggers / D-pad hats arrive as generic motion events.
- override fun onGenericMotionEvent(event: MotionEvent): Boolean =
- gamepad.onMotion(event) || super.onGenericMotionEvent(event)
-
// ---- (helper removed; viewModel.palette is read directly in setContent) ----
// ---- Permissions ----
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt
index 5c49435..f6444a7 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt
@@ -2,11 +2,18 @@ package com.reactorcoremeltdown.sizzletracker
import android.app.Application
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
+import com.reactorcoremeltdown.sizzletracker.input.BindingStore
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
import com.reactorcoremeltdown.sizzletracker.input.KeyboardInput
import com.reactorcoremeltdown.sizzletracker.input.MidiInput
+import com.reactorcoremeltdown.sizzletracker.io.PresetLibrary
+import com.reactorcoremeltdown.sizzletracker.io.ProjectStore
+import com.reactorcoremeltdown.sizzletracker.io.SettingsStore
+import com.reactorcoremeltdown.sizzletracker.io.SongLibrary
import com.reactorcoremeltdown.sizzletracker.model.Project
+import java.io.File
+import kotlinx.coroutines.runBlocking
/**
* The Application object is our (very small) dependency container. Instead of a
@@ -34,8 +41,48 @@ class SizzleApp : Application() {
val gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
val midiInput: MidiInput by lazy { MidiInput(this, inputRouter) }
+ /** Persists the input binding maps across restarts (DataStore-backed). */
+ val bindingStore: BindingStore by lazy { BindingStore(this) }
+
+ /** On-disk instrument/effect preset library in the app's scoped storage. */
+ val presetLibrary: PresetLibrary by lazy { PresetLibrary(File(filesDir, "presets")) }
+
+ /** On-disk `.sng` song library in the app's scoped storage. */
+ val songLibrary: SongLibrary by lazy { SongLibrary(File(filesDir, "songs")) }
+
+ /** Autosave of the whole project (crash recovery / resume). */
+ val projectStore: ProjectStore by lazy { ProjectStore(File(filesDir, "autosave.sng")) }
+
+ /** App-level settings persistence (theme, audio backend). */
+ val settingsStore: SettingsStore by lazy { SettingsStore(this) }
+
override fun onCreate() {
super.onCreate()
+ // Crash recovery: restore the last autosaved project before the engine or
+ // UI read it, so a crash / process-death resumes where the user left off.
+ projectStore.restore(project)
+ // Reload the sample audio for any preset-backed Sampler/SoundFont slots —
+ // the autosave keeps file references, not the decoded PCM, so this makes a
+ // selected preset's samples sound again on a fresh start.
+ presetLibrary.rehydrate(project)
+ // Write the built-in factory presets (e.g. the Ambience reverb spaces) to the
+ // on-disk library on first launch so they appear in the standard browser.
+ presetLibrary.seedFactoryPresets()
audioEngine.setProject(project)
+ // Restore saved bindings before any hardware event can arrive. This is a
+ // single small read; blocking briefly at cold start keeps the input maps
+ // authoritative from the first keypress rather than racing an async load.
+ runBlocking { bindingStore.loadInto(keyboardInput, gamepadInput, midiInput) }
+ installCrashAutosave()
+ }
+
+ /** Flush an autosave of the current project on any uncaught exception, then let
+ * the default handler crash/report as usual — so state survives a crash. */
+ private fun installCrashAutosave() {
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ runCatching { projectStore.save(project) }
+ previous?.uncaughtException(thread, throwable)
+ }
}
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AmbienceReverb.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AmbienceReverb.kt
new file mode 100644
index 0000000..534692f
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AmbienceReverb.kt
@@ -0,0 +1,412 @@
+package com.reactorcoremeltdown.sizzletracker.audio
+
+import com.reactorcoremeltdown.sizzletracker.model.AmbiencePresets
+import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
+import kotlin.math.abs
+import kotlin.math.exp
+import kotlin.math.floor
+import kotlin.math.log10
+import kotlin.math.tanh
+
+/**
+ * A mono port of OTODESK's "Ambience 1.0.1" algorithmic reverb — a 16-channel
+ * Feedback Delay Network (FDN). Faithful to the original's architecture within the
+ * limits of this app's mono, per-sample [AudioEffect] chain:
+ *
+ * - 16 delay lines at mutually-prime, log-distributed lengths scaled by Room Size.
+ * - A lossless feedback matrix: Fast Walsh-Hadamard Transform + the original's
+ * fixed sign-flip pattern (orthonormal → the only loss is the damping stage,
+ * so RT60 is set purely by decay/damping and the loop can never run away).
+ * - Per-channel decay gain derived from the Decay (RT60) target, plus a one-pole
+ * HF-damping low-pass and an LF-absorption tap (with per-algorithm intrinsic
+ * tone baked from the plugin's measured RT60 curves).
+ * - A 4-stage input diffuser and a nested allpass per channel (Diffusion).
+ * - Bandlimited-noise LFO modulation of the delay reads (Mod Amount / Mod Rate).
+ * - ISM early-reflection tap patterns per algorithm (Room/Hall only), fed to the
+ * wet mix and blended 15% into the FDN input, as in the original.
+ * - Wet saturation, the original's decay-compensated make-up gain, Wet/Dry mix,
+ * and an input-driven ducking envelope.
+ *
+ * Deliberately omitted (per the port request): the graphic/output EQ, lo/hi cut
+ * filters, and all visualizers; stereo width and Pro-mode per-band controls don't
+ * apply to a mono chain.
+ */
+class AmbienceReverb(sampleRate: Int) : AudioEffect {
+
+ private val fs = sampleRate.toFloat()
+
+ // ---- delay lines ----
+ private val preDelay = DelayLine((fs * 0.55f).toInt())
+ private val erLine = DelayLine((fs * 0.6f).toInt())
+ private val diffusers = Array(4) { DelayLine((fs * 0.03f).toInt()) }
+ private val fdn = Array(N) { DelayLine((fs * 0.3f).toInt()) }
+ private val apf = Array(N) { DelayLine((fs * 0.03f).toInt()) }
+
+ // ---- FDN feedback state ----
+ private val fbVec = FloatArray(N)
+ private val fbScratch = FloatArray(N)
+ private val nextFb = FloatArray(N)
+ private val baseDelay = FloatArray(N)
+ private val decayGain = FloatArray(N)
+ private val hfState = FloatArray(N)
+ private val lfState = FloatArray(N)
+ private val freqMod = FloatArray(N) { 0.5f + (1f - it / (N - 1f)) }
+ private val apfBase = FloatArray(N) { (2.3f + it * 0.37f) * 0.001f * fs }
+ private val diffuserSmp = FloatArray(4) { (3f + it * 2f) * 0.001f * fs }
+
+ // ---- per-channel noise LFO ----
+ private val lfoState = IntArray(N) { 12345 + it * 9876 }
+ private val lfoSmooth = FloatArray(N)
+ private val lfoCoeff = FloatArray(N)
+ private val lfoRateMul = FloatArray(N) { val a = it * PHI; 0.8f + (a - floor(a)) * 0.4f }
+
+ // ---- resolved routing (recomputed on structural change) ----
+ private var hfCoeff = 1f
+ private var lfCoeff = 0f
+ private var lfAmt = 0f
+ private var apfGain = 0.5f
+ private var diffusionSens = 1f
+ private var bypassER = false
+ private var makeup = 1f
+ private var satMul = 1f
+ private var modDepthScale = 1f
+ private var erCount = 0
+ private val erDelaySmp = FloatArray(MAX_ER)
+ private val erGain = FloatArray(MAX_ER)
+
+ // ---- per-block scalars ----
+ private var wetLin = 0.63f
+ private var dryLin = 1f
+ private var preDelaySmp = 480f
+ private var modDepth = 0f
+ private var diffGain = 0.63f
+ private var effApfGain = 0.4f
+ private var satAmt = 0f
+ private var erLevel = 0.6f
+ // ducking
+ private var duckAmtDb = 0f
+ private var duckThreshDb = -20f
+ private var duckThreshLin = 0.1f
+ private var duckAtt = 0.01f
+ private var duckRel = 0.001f
+ private var duckEnv = 0f
+
+ private var dc1 = 0f
+ private var dcY = 0f
+
+ // Last structural params seen, for a no-allocation dirty check (a String key here
+ // would allocate on the audio thread every block).
+ private var lastAlgo = -1
+ private var lastSize = Float.NaN
+ private var lastDecay = Float.NaN
+ private var lastHf = Float.NaN
+ private var lastLf = Float.NaN
+ private var lastDiffusion = Float.NaN
+
+ override fun update(slot: ToolboxSlot, tempoBpm: Float) {
+ val algo = AmbiencePresets.ALGORITHMS.indexOf(slot.string("algo", "Room 1")).coerceIn(0, 6)
+ val size = slot.float("roomsize", 1f).coerceIn(0.3f, 2f)
+ val decay = slot.float("decay", 1.5f).coerceIn(0.1f, 20f)
+ val hf = slot.float("hfdamp", 0f).coerceIn(0f, 1f)
+ val lf = slot.float("lfabsorb", 0f).coerceIn(0f, 1f)
+ val diffusion = slot.float("diffusion", 0.7f).coerceIn(0f, 1f)
+
+ // Cheap per-block scalars.
+ wetLin = db2lin(slot.float("wet", -4f))
+ dryLin = db2lin(slot.float("dry", 0f))
+ preDelaySmp = slot.float("predelay", 10f).coerceIn(0f, 500f) * 0.001f * fs
+ erLevel = slot.float("erlevel", 0.6f).coerceIn(0f, 1f)
+ satAmt = slot.float("saturation", 0f).coerceIn(0f, 1f)
+ val modAmt = slot.float("modamt", 0.25f).coerceIn(0f, 1f)
+ val modRate = slot.float("modrate", 0.5f).coerceIn(0.05f, 2f)
+ modDepth = modAmt * modAmt * 0.001f * fs * modDepthScale
+ for (i in 0 until N) {
+ lfoCoeff[i] = (1f - exp(-TWO_PI * modRate * lfoRateMul[i] / fs)).coerceIn(0.0001f, 0.9999f)
+ }
+ // Ducking.
+ duckAmtDb = slot.float("duckamt", 0f).coerceIn(0f, 20f)
+ duckThreshDb = slot.float("duckthresh", -20f)
+ duckThreshLin = db2lin(duckThreshDb)
+ duckAtt = 1f - exp(-1f / (fs * slot.float("duckattack", 10f).coerceAtLeast(0.1f) * 0.001f))
+ duckRel = 1f - exp(-1f / (fs * slot.float("duckrelease", 200f).coerceAtLeast(0.1f) * 0.001f))
+ val effDiff = diffusion * diffusionSens
+ diffGain = 0.25f + effDiff * 0.55f
+ effApfGain = apfGain * (0.6f + effDiff * 0.4f) * 0.78f
+
+ // Structural recompute only when a delay/tone-affecting param changes
+ // (primitive compare — no per-block allocation).
+ if (algo != lastAlgo || size != lastSize || decay != lastDecay ||
+ hf != lastHf || lf != lastLf || diffusion != lastDiffusion) {
+ lastAlgo = algo; lastSize = size; lastDecay = decay
+ lastHf = hf; lastLf = lf; lastDiffusion = diffusion
+ recompute(algo, size, decay, hf, lf, diffusion)
+ // effApfGain/diffGain depend on diffusionSens which recompute() may change.
+ val d2 = diffusion * diffusionSens
+ diffGain = 0.25f + d2 * 0.55f
+ effApfGain = apfGain * (0.6f + d2 * 0.4f) * 0.78f
+ }
+ }
+
+ private fun recompute(algo: Int, size: Float, decay: Float, hf: Float, lf: Float, diffusion: Float) {
+ // Topology routing (per the original updateTopologyAndRouting()).
+ when (algo) {
+ 0, 1 -> { apfGain = if (algo == 0) 0.3f else 0.3f; diffusionSens = 1f; bypassER = false }
+ 2, 3 -> { apfGain = 0.618f; diffusionSens = 1f; bypassER = false }
+ 4 -> { apfGain = 0.7f; diffusionSens = 0.7f; bypassER = true }
+ 5 -> { apfGain = 0.5f; diffusionSens = 0.5f; bypassER = true }
+ else -> { apfGain = 0.75f; diffusionSens = 0.8f; bypassER = true }
+ }
+ // Room1/Hall share topology gains but Room uses 0.3; keep Room=0.3, Hall=0.618.
+ if (algo == 0 || algo == 1) apfGain = 0.3f
+
+ // Prime delay lengths: log-distributed between size-dependent bounds.
+ val sizeCoeff = (size + 0.5f).coerceIn(0.5f, 2f)
+ val minMs = 15f + sizeCoeff * 7.5f
+ val maxMs = 50f + sizeCoeff * 75f
+ val minS = kotlin.math.max(11, (minMs * 0.001f * fs).toInt())
+ val maxS = (maxMs * 0.001f * fs).toInt().coerceAtLeast(minS + 1)
+ val logMin = kotlin.math.ln(minS.toFloat())
+ val logMax = kotlin.math.ln(maxS.toFloat())
+ val used = IntArray(N)
+ for (i in 0 until N) {
+ val t = i / (N - 1f)
+ val target = exp(logMin + t * (logMax - logMin)).toInt()
+ val p = nearestUniquePrime(target, used, i)
+ used[i] = p
+ baseDelay[i] = p.toFloat().coerceAtMost(fdn[i].maxDelay())
+ }
+
+ // Decay → per-channel broadband feedback gain: g = 10^(-3 T / RT60).
+ for (i in 0 until N) {
+ val tSec = baseDelay[i] / fs
+ decayGain[i] = pow10(-3f * tSec / decay).coerceIn(0f, 0.9995f)
+ }
+
+ // HF damping = user + algorithm-intrinsic; maps to a one-pole LP cutoff.
+ val totalHf = (hf + ALGO_HF[algo]).coerceIn(0f, 0.95f)
+ val hfCut = 16000f * (1f - totalHf) + 700f * totalHf
+ hfCoeff = (1f - exp(-TWO_PI * hfCut / fs)).coerceIn(0.02f, 1f)
+ lfCoeff = (1f - exp(-TWO_PI * 200f / fs)).coerceIn(0f, 1f)
+ lfAmt = lf * 0.9f
+
+ // Modulation deepens with longer decay (anti-metallic), as in the original.
+ modDepthScale = 1f + ((decay - 1f) * 0.5f).coerceIn(0f, 2f)
+
+ // Make-up gain: base 16 dB + decay compensation + per-algorithm offset.
+ val decayCompDb = 7f * log10(decay.coerceAtLeast(0.1f))
+ makeup = db2lin(16f + decayCompDb + ALGO_OFFSET_DB[algo])
+ satMul = ALGO_SAT_MUL[algo]
+
+ // Early reflections (Room/Hall only), scaled by room size.
+ val pattern = ER_PATTERNS[algo]
+ erCount = if (bypassER) 0 else pattern.size / 2
+ val erSizeScale = 0.5f + size
+ for (t in 0 until erCount) {
+ erDelaySmp[t] = (pattern[t * 2] * 0.001f * fs * erSizeScale).coerceIn(1f, erLine.maxDelay())
+ erGain[t] = pattern[t * 2 + 1]
+ }
+ }
+
+ override fun process(x: Float): Float {
+ // Pre-delay feeds both ER and FDN paths.
+ preDelay.write(x)
+ val din = if (preDelaySmp > 0.5f) preDelay.read(preDelaySmp) else x
+
+ // Ducking envelope from the dry input.
+ val peak = abs(x)
+ duckEnv += (peak - duckEnv) * (if (peak > duckEnv) duckAtt else duckRel)
+ var duckGain = 1f
+ if (duckAmtDb > 0.001f && duckEnv > duckThreshLin) {
+ val overDb = 20f * log10(kotlin.math.max(duckEnv, 1e-6f)) - duckThreshDb
+ duckGain = db2lin(-kotlin.math.min(overDb, duckAmtDb))
+ }
+
+ // Input diffuser (4 series allpasses).
+ var fdnIn = din
+ for (i in 0 until 4) {
+ val d = diffusers[i].read(diffuserSmp[i])
+ val w = fdnIn + diffGain * d
+ diffusers[i].write(w)
+ fdnIn = d - diffGain * w
+ }
+
+ // Early reflections.
+ var er = 0f
+ if (!bypassER) {
+ erLine.write(din)
+ for (t in 0 until erCount) er += erLine.read(erDelaySmp[t]) * erGain[t] * 0.5f
+ fdnIn += er * 0.15f
+ }
+
+ // Lossless feedback matrix on the previous frame's output.
+ System.arraycopy(fbVec, 0, fbScratch, 0, N)
+ fwht(fbScratch)
+ for (i in 0 until N) fbScratch[i] *= SIGN_FLIP[i]
+
+ val inPerCh = fdnIn * 0.25f
+ var out = 0f
+ for (i in 0 until N) {
+ val lfo = nextLfo(i)
+ var d = fdn[i].read(baseDelay[i] + lfo * modDepth * freqMod[i])
+ // Broadband decay + HF damping (DC-unity LP) + LF absorption.
+ d *= decayGain[i]
+ hfState[i] += hfCoeff * (d - hfState[i]); d = hfState[i]
+ lfState[i] += lfCoeff * (d - lfState[i]); d -= lfAmt * lfState[i]
+ // Nested allpass (late-field density).
+ val ad = apf[i].read(apfBase[i] + lfo * modDepth * 0.1f * freqMod[i])
+ val aw = d + effApfGain * ad
+ apf[i].write(aw)
+ val apfOut = ad - effApfGain * aw
+
+ nextFb[i] = apfOut
+ fdn[i].write(inPerCh + fbScratch[i])
+ out += apfOut
+ }
+ System.arraycopy(nextFb, 0, fbVec, 0, N)
+
+ // Late field: sum → make-up gain → saturation.
+ var late = out * 0.125f * makeup
+ val amt = (satAmt * satMul).coerceIn(0f, 1f)
+ if (amt > 0.0001f) late += amt * (tanh(late * 2f) * 0.5f - late)
+
+ // DC blocker on the wet output.
+ val wetRaw = (if (bypassER) 0f else er * erLevel) + late
+ val dcOut = wetRaw - dc1 + 0.999f * dcY
+ dc1 = wetRaw; dcY = dcOut
+
+ val wet = softClip(dcOut * wetLin * duckGain)
+ return x * dryLin + wet
+ }
+
+ // xorshift white noise → one-pole lowpass, per channel.
+ private fun nextLfo(i: Int): Float {
+ var s = lfoState[i]
+ s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5)
+ lfoState[i] = s
+ val n = (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f * 2f - 1f
+ lfoSmooth[i] += (n - lfoSmooth[i]) * lfoCoeff[i]
+ return lfoSmooth[i]
+ }
+
+ /** A power-of-two circular buffer with fractional (linear-interpolated) reads. */
+ private class DelayLine(size: Int) {
+ private val buf: FloatArray
+ private val mask: Int
+ private var w = 0
+ init {
+ var p = 1
+ while (p < size) p *= 2
+ buf = FloatArray(p)
+ mask = p - 1
+ }
+ fun maxDelay(): Float = (mask - 2).toFloat()
+ fun write(x: Float) { buf[w] = x; w = (w + 1) and mask }
+ fun read(delay: Float): Float {
+ val d = delay.coerceIn(1f, mask.toFloat() - 1f)
+ val rp = w - d
+ val i0f = floor(rp)
+ val frac = rp - i0f
+ val i0 = i0f.toInt() and mask
+ val i1 = (i0 + 1) and mask
+ return buf[i0] * (1f - frac) + buf[i1] * frac
+ }
+ }
+
+ private companion object {
+ const val N = 16
+ const val MAX_ER = 12
+ const val PHI = 1.6180339887f
+ const val TWO_PI = 6.28318530718f
+
+ // Per-algorithm intrinsic HF damping, derived from the plugin's measured
+ // RT60 curves (rt60[8kHz]/rt60[500Hz]): darker spaces damp highs more.
+ val ALGO_HF = floatArrayOf(0.00f, 0.26f, 0.26f, 0.22f, 0.36f, 0.14f, 0.30f)
+ // Make-up gain offsets and saturation multipliers, per the original.
+ val ALGO_OFFSET_DB = floatArrayOf(0.8f, 0.9f, 0.5f, 0.5f, 1.5f, 0.6f, 0.6f)
+ val ALGO_SAT_MUL = floatArrayOf(0.90f, 0.90f, 0.93f, 0.93f, 1.00f, 1.05f, 1.02f)
+
+ // Original's fixed sign-flip pattern for the FWHT feedback matrix.
+ val SIGN_FLIP = floatArrayOf(
+ 1f, -1f, 1f, -1f, -1f, 1f, -1f, 1f,
+ 1f, 1f, -1f, -1f, -1f, -1f, 1f, 1f,
+ )
+
+ // ISM early-reflection tap patterns (delayMs, gain)… per algorithm. Plate,
+ // Spring and Goldfoil are non-rooms → no ER (empty).
+ val ER_PATTERNS: Array = arrayOf(
+ // Room 1
+ floatArrayOf(
+ 5.2f, 0.65f, 8.7f, 0.58f, 12.4f, 0.52f, 15.8f, 0.46f, 19.3f, 0.42f, 24.1f, 0.36f,
+ 28.6f, 0.32f, 33.5f, 0.28f, 38.9f, 0.24f, 45.2f, 0.20f, 52.8f, 0.16f, 62.4f, 0.13f,
+ ),
+ // Room 2
+ floatArrayOf(
+ 7.5f, 0.62f, 12.3f, 0.55f, 17.1f, 0.49f, 22.8f, 0.43f, 28.5f, 0.38f, 34.2f, 0.33f,
+ 41.6f, 0.28f, 49.3f, 0.24f, 57.8f, 0.20f, 67.5f, 0.17f, 78.2f, 0.14f, 90.6f, 0.11f,
+ ),
+ // Hall 1
+ floatArrayOf(
+ 12.0f, 0.58f, 18.5f, 0.52f, 25.7f, 0.47f, 33.4f, 0.42f, 42.1f, 0.38f, 51.6f, 0.33f,
+ 62.3f, 0.29f, 73.9f, 0.25f, 86.5f, 0.21f, 99.8f, 0.18f, 113.4f, 0.15f, 128.7f, 0.12f,
+ ),
+ // Hall 2
+ floatArrayOf(
+ 16.5f, 0.55f, 24.8f, 0.50f, 33.7f, 0.45f, 43.5f, 0.40f, 54.2f, 0.36f, 65.8f, 0.32f,
+ 78.4f, 0.28f, 92.1f, 0.24f, 107.3f, 0.21f, 123.6f, 0.18f, 141.2f, 0.15f, 160.5f, 0.12f,
+ ),
+ floatArrayOf(), // Plate
+ floatArrayOf(), // Spring
+ floatArrayOf(), // Goldfoil
+ )
+
+ fun db2lin(db: Float): Float = pow10(db / 20f)
+ fun pow10(x: Float): Float = exp(x * 2.302585093f)
+
+ /** In-place size-16 Fast Walsh-Hadamard transform, normalised (÷4) → orthonormal. */
+ fun fwht(v: FloatArray) {
+ var h = 1
+ while (h < N) {
+ var i = 0
+ while (i < N) {
+ var j = i
+ while (j < i + h) {
+ val a = v[j]; val b = v[j + h]
+ v[j] = a + b; v[j + h] = a - b
+ j++
+ }
+ i += h * 2
+ }
+ h *= 2
+ }
+ for (k in 0 until N) v[k] *= 0.25f
+ }
+
+ fun isPrime(n: Int): Boolean {
+ if (n < 2) return false
+ if (n == 2) return true
+ if (n % 2 == 0) return false
+ var i = 3
+ while (i * i <= n) { if (n % i == 0) return false; i += 2 }
+ return true
+ }
+
+ fun nearestUniquePrime(target: Int, used: IntArray, count: Int): Int {
+ val t = kotlin.math.max(target, 2)
+ var offset = 0
+ while (offset < 100000) {
+ val hi = t + offset
+ if (isPrime(hi) && (0 until count).none { used[it] == hi }) return hi
+ val lo = t - offset
+ if (offset > 0 && lo >= 2 && isPrime(lo) && (0 until count).none { used[it] == lo }) return lo
+ offset++
+ }
+ return t
+ }
+
+ fun softClip(x: Float): Float = when {
+ x > 1f -> 1f - 1f / (x + 1f)
+ x < -1f -> -1f + 1f / (-x + 1f)
+ else -> x
+ }
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt
index 81a91f9..094e68e 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt
@@ -6,6 +6,7 @@ import android.media.AudioTrack
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
import com.reactorcoremeltdown.sizzletracker.model.Pattern
import com.reactorcoremeltdown.sizzletracker.model.Project
+import com.reactorcoremeltdown.sizzletracker.model.SamplerPads
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
@@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.thread
import kotlin.math.abs
import kotlin.math.sin
+import kotlin.math.tanh
/**
* The real-time sound engine. It owns one [AudioTrack] and a dedicated render
@@ -35,13 +37,15 @@ import kotlin.math.sin
* - PATTERN-LOOP mode (when the arrangement is empty): the currently-edited
* pattern loops, so the Tracker tab is always audible while you edit.
*
- * Voices form a [LANE_COUNT] x [TRACK_COUNT] matrix so lanes layer polyphonically
- * while each (lane, track) stays monophonic (classic tracker channel behaviour).
- * A voice's mixer channel is its track index, so mute/solo/volume apply per track.
+ * Voices are pooled PER MIXER BUS: each of the [TRACK_COUNT] buses owns a pool of
+ * [POLYPHONY] synth + [POLYPHONY] sample voices, so a single instrument can sound
+ * up to [POLYPHONY] notes at once (chords, overlapping release tails) rather than
+ * one. Notes are allocated round-robin, stealing the oldest voice when the pool is
+ * full — the requested "N sounds per instrument" cap.
*
* INSTRUMENTS: NES synth and Sampler are rendered here; the SoundFont loader
- * still falls back to the synth. Each (lane, track) has both a [SynthVoice] and a
- * [SampleVoice]; the channel's instrument type decides which one a note triggers.
+ * still falls back to the synth. Each bus pool has both [SynthVoice]s and
+ * [SampleVoice]s; the channel's instrument type decides which one a note triggers.
* Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
* Kotlin/AudioTrack loop for an Oboe (C++/AAudio) engine is a later milestone.
@@ -49,25 +53,73 @@ import kotlin.math.sin
class AudioEngine(
private val sampleRate: Int = 48_000,
) {
- // Sequencer voices: one synth + one sample voice per (lane, track).
- private val seqVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SynthVoice(sampleRate) }
- private val sampleVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SampleVoice(sampleRate) }
+ // Sequencer voices: a per-mixer-bus pool of POLYPHONY synth + POLYPHONY sample
+ // voices, laid out as bus*POLYPHONY + i. A note plays whatever instrument its
+ // channel routes to, allocated from that bus's pool, so one instrument can sound
+ // several notes simultaneously instead of stealing its single voice every time.
+ private val seqVoices = Array(Pattern.TRACK_COUNT * POLYPHONY) { SynthVoice(sampleRate) }
+ private val sampleVoices = Array(Pattern.TRACK_COUNT * POLYPHONY) { SampleVoice(sampleRate) }
+ // Round-robin steal cursors per bus (which pool slot to reuse when all are busy).
+ private val synthAlloc = IntArray(Pattern.TRACK_COUNT)
+ private val sampleAlloc = IntArray(Pattern.TRACK_COUNT)
// ... plus a small pool for live auditioning from keyboard / MIDI / pads.
private val liveVoices = Array(LIVE_POLYPHONY) { SynthVoice(sampleRate) }
// Sample voices for previewing sampler instruments (toolbox keyboard / editor).
private val auditionSampleVoices = Array(AUDITION_POLYPHONY) { SampleVoice(sampleRate) }
+ // Live "mix bus" audition voices: polyphony per mixer channel, laid out as
+ // channel*BUS_POLYPHONY + i. Rendered INTO the channel sum (pre-FX) so playing
+ // one auditions the whole bus — the channel's instrument through its FX chain,
+ // volume, and mute/solo. Driven by the toolbox MIDI piano via busNoteOn/Off.
+ private val busVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SynthVoice(sampleRate) }
+ private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
// Per-(lane,track) arpeggiator state, driven by advanceArps.
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
- private fun seqVoice(lane: Int, track: Int) = seqVoices[lane * Pattern.TRACK_COUNT + track]
- private fun sampleVoice(lane: Int, track: Int) = sampleVoices[lane * Pattern.TRACK_COUNT + track]
+ /** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. */
+ private fun allocSynthVoice(bus: Int): SynthVoice {
+ val base = bus * POLYPHONY
+ for (i in 0 until POLYPHONY) seqVoices[base + i].let { if (!it.isActive) return it }
+ val idx = synthAlloc[bus]
+ synthAlloc[bus] = (idx + 1) % POLYPHONY
+ return seqVoices[base + idx].also { it.kill() }
+ }
+
+ /** Grab a free sample voice from [bus]'s pool, or steal the round-robin one.
+ * The stolen voice is NOT killed here — [SampleVoice.noteOn] declicks the hand-off
+ * itself (an abrupt kill of a loud voice would click). */
+ private fun allocSampleVoice(bus: Int): SampleVoice {
+ val base = bus * POLYPHONY
+ for (i in 0 until POLYPHONY) sampleVoices[base + i].let { if (!it.isActive) return it }
+ val idx = sampleAlloc[bus]
+ sampleAlloc[bus] = (idx + 1) % POLYPHONY
+ return sampleVoices[base + idx]
+ }
+
+ /** Release (fade out) every active voice on [bus] — a note-off cell on its channel. */
+ private fun releaseBus(bus: Int) {
+ val base = bus * POLYPHONY
+ for (i in 0 until POLYPHONY) {
+ seqVoices[base + i].noteOff()
+ sampleVoices[base + i].noteOff()
+ }
+ }
/** One insert-effect instance bound to the toolbox slot that configures it. */
- private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect)
+ 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
+ }
/** Per-channel effect chains (mixer FX slots resolved to real processors).
- * Rebuilt when routing changes; parameters refresh live every block. */
- private val channelChains = Array(Pattern.TRACK_COUNT) { mutableListOf() }
+ * Rebuilt when routing changes; parameters refresh live every block. Stored as
+ * arrays (not lists) because the audio thread iterates them per sample, and a
+ * `for (u in list)` allocates an Iterator every time — a per-block heap churn
+ * that caused GC-pause dropouts. Array for-loops allocate nothing. Each rebuild
+ * swaps in a fresh array (reference assignment is atomic), like [playlist]. */
+ @Volatile private var channelChains: Array> =
+ Array(Pattern.TRACK_COUNT) { emptyArray() }
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
@@ -87,12 +139,16 @@ class AudioEngine(
// ---- Arrangement mode ----
private var arrangementMode = false
- /** Beat indices to play, in order, with A/B repeats already expanded. */
- private var playlist: IntArray = IntArray(0)
+ /** Beat indices to play, in order, with A/B repeats already expanded. Volatile
+ * and only ever swapped as a whole reference, so it can be rebuilt live from
+ * the main thread (e.g. a repeat-count change) while the audio thread reads a
+ * consistent snapshot — see [refreshLoopPlaylist] and [advanceArrangement]. */
+ @Volatile private var playlist: IntArray = IntArray(0)
private var playlistIndex = 0
private var lineInBeat = 0
- private val laneCursor = IntArray(Arrangement.LANE_COUNT) // which beat of its pattern each lane is on
- private val lanePrevPattern = IntArray(Arrangement.LANE_COUNT) // pattern played on the previous beat (-2 = none)
+ /** Local tick of the *edited* block at the current beat, for the tracker
+ * playhead. Updated as that block sounds; retained when it isn't. */
+ private var arrPlayLine = 0
private val _transport = MutableStateFlow(TransportState())
val transport: StateFlow = _transport
@@ -100,6 +156,87 @@ class AudioEngine(
private var renderThread: Thread? = null
private var track: AudioTrack? = null
+ // ------------------------------------------------ master-bus recording
+ // Captures the final mixed output (post limiter) to a WAV file. Recording is
+ // "armed" from the mixer toolbar and actually begins when playback starts; it
+ // ends automatically a configurable FX-tail (in bars) after the sequencer
+ // stops — whether that stop is a pause/stop button or the arrangement reaching
+ // its end with looping off.
+ private val masterRecorder = MasterRecorder(sampleRate)
+
+ /** Directory for the staging temp WAV (the app's cache dir); set at startup. */
+ var recTempDir: java.io.File? = null
+
+ /** Invoked (on the writer thread) with the finished temp WAV and the recording's
+ * start epoch-millis when a take finalizes. The app copies it to its destination. */
+ var onRecordingComplete: ((java.io.File, Long) -> Unit)? = null
+
+ /** Invoked (on the main thread, from [play]) the moment a take starts capturing. */
+ var onRecordingStarted: (() -> Unit)? = null
+
+ @Volatile var recordArmed = false; private set
+ @Volatile private var recTailBars = 0
+
+ /** True while a master recording is capturing or finalizing. */
+ val isMasterRecording: Boolean get() = masterRecorder.isActive
+
+ /** Arm/disarm recording and set the FX-tail length (bars) captured after a stop. */
+ fun setRecordingArm(armed: Boolean, tailBars: Int) {
+ recordArmed = armed
+ recTailBars = tailBars.coerceIn(0, 8)
+ }
+
+ /** Update just the FX-tail length (bars) captured after the sequencer stops. */
+ fun setRecordTailBars(tailBars: Int) { recTailBars = tailBars.coerceIn(0, 8) }
+
+ // Audio-thread recording state machine (see [recordSample]). Initialized in
+ // [maybeStartRecording] before the volatile [recCapturing] flag publishes them.
+ @Volatile private var recCapturing = false
+ private var recWasPlaying = false
+ private var recTailRemaining = 0
+
+ /** If armed and a destination temp dir is set, start capturing the master bus. */
+ private fun maybeStartRecording() {
+ if (!recordArmed) return
+ val dir = recTempDir ?: return
+ if (masterRecorder.isActive) return // already recording (e.g. replay in the tail)
+ val temp = java.io.File(dir, "master-rec.wav")
+ val startMs = System.currentTimeMillis()
+ val cb = onRecordingComplete
+ val ok = masterRecorder.begin(temp) { f -> if (f != null) cb?.invoke(f, startMs) }
+ if (!ok) return
+ recTailRemaining = -1
+ recWasPlaying = true
+ recCapturing = true // volatile store publishes the two fields above to the audio thread
+ onRecordingStarted?.invoke()
+ }
+
+ /**
+ * Audio thread: feed one master sample to the recorder and run the stop/tail
+ * state machine. While the sequencer plays, samples stream straight through. On
+ * the play->stop edge we latch a tail countdown (bars -> samples at the current
+ * tempo/signature); the recorder keeps capturing the ringing FX/voice tail until
+ * it elapses, then finalizes the file. Voices and insert FX render every sample
+ * regardless of [playing], so the tail contains real decaying reverb/delay audio.
+ */
+ private fun recordSample(proj: Project?, sample: Float) {
+ val nowPlaying = playing
+ if (recWasPlaying && !nowPlaying) {
+ recTailRemaining = if (proj == null) 0
+ else (recTailBars * proj.timeSignature.linesPerBar * samplesPerLine(proj)).toInt()
+ }
+ recWasPlaying = nowPlaying
+ masterRecorder.write(sample)
+ if (!nowPlaying) {
+ if (recTailRemaining <= 0) {
+ recCapturing = false
+ masterRecorder.endCapture()
+ } else {
+ recTailRemaining--
+ }
+ }
+ }
+
fun setProject(p: Project) {
project = p
rebuildArrangement()
@@ -114,16 +251,18 @@ class AudioEngine(
*/
fun rebuildFxChains() {
val proj = project ?: return
+ val next = Array(Pattern.TRACK_COUNT) { emptyArray() }
for (ch in 0 until Pattern.TRACK_COUNT) {
- val chain = channelChains[ch]
- chain.clear()
+ val units = ArrayList(proj.mixer.channels[ch].fxSlots.size)
for (slotIndex in proj.mixer.channels[ch].fxSlots) {
if (slotIndex < 0) continue
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
val type = slot.type ?: continue
- AudioEffect.create(type, sampleRate)?.let { chain.add(FxUnit(slot, it)) }
+ AudioEffect.create(type, sampleRate)?.let { units.add(FxUnit(slot, it)) }
}
+ next[ch] = units.toTypedArray()
}
+ channelChains = next // atomic swap; audio thread sees old or new whole array
}
/**
@@ -138,16 +277,39 @@ class AudioEngine(
playlist = buildPlaylist(arr)
playlistIndex = 0
lineInBeat = 0
- laneCursor.fill(0)
- lanePrevPattern.fill(-2)
+ arrPlayLine = 0
+ }
+
+ /**
+ * Rebuild the playlist in place from the current loop settings WITHOUT resetting
+ * the play position (index, cursors, sample counter). Call from the main thread
+ * after a live edit that only changes the beat sequence — e.g. a loop-region
+ * repeat-count change — so playback keeps rolling instead of restarting.
+ *
+ * The audio thread reads [playlist] as a single volatile snapshot and bounds-
+ * checks its own index, so swapping the array under it is safe: growing the
+ * loop appends future repeats seamlessly; shrinking it simply wraps to the loop
+ * start at the next beat boundary rather than jumping the transport.
+ */
+ fun refreshLoopPlaylist() {
+ val proj = project ?: return
+ if (!arrangementMode) return // not looping the arrangement; nothing live to update
+ playlist = buildPlaylist(proj.arrangement)
}
/** Expand the arrangement (respecting loop + A/B regions) into a flat beat list. */
private fun buildPlaylist(arr: Arrangement): IntArray {
- if (!arr.loopEnabled) return IntArray(arr.lengthBeats) { it } // play once, front to back
val a = arr.regionA
val b = arr.regionB
- if (!a.isValid && !b.isValid) return IntArray(arr.lengthBeats) { it } // loop the whole song
+ // With no A/B loop regions in play, walk beats 0..last-filled — never out
+ // through the trailing empty beats up to lengthBeats. Whether this stops or
+ // repeats is then decided by loopEnabled in advanceArrangement: loop off
+ // plays it once and stops after the last filled block; loop on repeats the
+ // filled span. Regions only take effect while looping (as before).
+ if (!arr.loopEnabled || (!a.isValid && !b.isValid)) {
+ val end = (arr.lastFilledBeat() + 1).coerceAtLeast(0)
+ return IntArray(end) { it }
+ }
val out = ArrayList()
if (a.isValid) repeat(a.repeats) { for (beat in a.start until a.end) out.add(beat) }
if (b.isValid) repeat(b.repeats) { for (beat in b.start until b.end) out.add(beat) }
@@ -231,6 +393,9 @@ class AudioEngine(
/** Stop the engine entirely (called from the PlaybackService on shutdown). */
fun release() {
running = false
+ // Finalize any in-flight recording so the WAV header gets patched and the
+ // completion callback fires instead of leaving a truncated temp file.
+ if (recCapturing) { recCapturing = false; masterRecorder.endCapture() }
nativeBridge?.stop()
nativeBridge = null
renderThread?.join(500)
@@ -245,11 +410,29 @@ class AudioEngine(
rebuildFxChains()
pendingTrigger = true
playing = true
+ maybeStartRecording()
publish()
}
fun pause() { playing = false; allSeqVoicesOff(); publish() }
+ /**
+ * MIDI panic: immediately hard-silence every voice — sequencer, live (keyboard/
+ * MIDI/gamepad), and audition — and cancel all arpeggiators. Clears stuck or
+ * hung notes at once (e.g. a MIDI note-on whose note-off never arrived). Does
+ * not stop the transport; if the sequencer is playing it resumes on the next
+ * tick.
+ */
+ fun panic() {
+ seqVoices.forEach { it.kill() }
+ sampleVoices.forEach { it.kill() }
+ liveVoices.forEach { it.kill() }
+ auditionSampleVoices.forEach { it.kill() }
+ busVoices.forEach { it.kill() }
+ busSampleVoices.forEach { it.kill() }
+ arps.forEach { it.stop() }
+ }
+
/** Stop playback. If already stopped, rewind to the very beginning. */
fun stop() {
if (!playing && currentLine == 0 && playlistIndex == 0 && lineInBeat == 0) return
@@ -259,7 +442,7 @@ class AudioEngine(
if (wasStopped) {
currentLine = 0; samplesIntoLine = 0.0
playlistIndex = 0; lineInBeat = 0
- laneCursor.fill(0); lanePrevPattern.fill(-2)
+ arrPlayLine = 0
}
publish()
}
@@ -300,6 +483,9 @@ class AudioEngine(
updateFxParams(proj)
}
val anySolo = proj?.mixer?.anySolo() ?: false
+ // 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
for (i in 0 until frames) {
if (playing && proj != null) advanceSequencer(proj)
@@ -307,13 +493,19 @@ class AudioEngine(
var mix = 0f
if (proj != null) {
channelSum.fill(0f)
+ // Pools are laid out bus*POLYPHONY + i, so the bus is vi / POLYPHONY.
for (vi in seqVoices.indices) {
- channelSum[vi % Pattern.TRACK_COUNT] += seqVoices[vi].render() + sampleVoices[vi].render()
+ channelSum[vi / POLYPHONY] += seqVoices[vi].render() + sampleVoices[vi].render()
+ }
+ // Live bus-audition voices feed the same per-channel sum, so they
+ // run through the channel's FX/volume/mute just like the sequencer.
+ for (bi in busVoices.indices) {
+ channelSum[bi / BUS_POLYPHONY] += busVoices[bi].render() + busSampleVoices[bi].render()
}
for (ch in 0 until Pattern.TRACK_COUNT) {
val mc = proj.mixer.channels[ch]
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
- val chain = channelChains[ch]
+ val chain = chains[ch]
for (u in chain) s = u.effect.process(s)
mix += s
}
@@ -321,15 +513,26 @@ class AudioEngine(
for (lv in liveVoices) mix += lv.render() * LIVE_GAIN
for (av in auditionSampleVoices) mix += av.render() * LIVE_GAIN
out[i] = softClip(mix * MASTER_GAIN)
+ if (recCapturing) recordSample(proj, out[i])
}
}
- /** Push current slot parameters (+ tempo) into every live effect processor. */
+ /** Push slot parameters (+ tempo) into every live effect processor, but only when
+ * they actually changed — re-parsing unchanged params every block allocates and
+ * caused GC-pause dropouts when several effects were stacked. */
private fun updateFxParams(proj: Project) {
val tempo = proj.tempoBpm
+ val chains = channelChains
for (ch in 0 until Pattern.TRACK_COUNT) {
- val chain = channelChains[ch]
- for (u in chain) u.effect.update(u.slot, tempo)
+ val chain = chains[ch]
+ for (u in chain) {
+ val v = u.slot.version
+ if (v != u.lastVersion || tempo != u.lastTempo) {
+ u.effect.update(u.slot, tempo)
+ u.lastVersion = v
+ u.lastTempo = tempo
+ }
+ }
}
}
@@ -406,67 +609,126 @@ class AudioEngine(
}
private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
- // Pattern-loop plays on lane 0's voices (tracks 0..3 == channels 0..3).
+ // Pattern-loop plays on lane 0's voices; each cell is routed by its channel.
+ // Apply note-offs FIRST, then note-ons, so a note-off on one track never
+ // cancels a note that begins on the same line/bus (a note-off releases the
+ // whole bus; without this ordering it would kill a just-triggered voice).
for (t in 0 until Pattern.TRACK_COUNT) {
- triggerVoice(proj, lane = 0, track = t, cell = pattern.cell(t, line))
+ val cell = pattern.cell(t, line)
+ if (cell.isNoteOff) triggerCell(proj, lane = 0, cell = cell)
+ }
+ for (t in 0 until Pattern.TRACK_COUNT) {
+ val cell = pattern.cell(t, line)
+ if (cell.isPlayable) triggerCell(proj, lane = 0, cell = cell)
}
}
/**
- * Trigger a cell on (lane, track), applying the channel's MIDI effects:
- * Transposer shifts the pitch, Arpeggiator (if present) captures the note and
- * hands ongoing retriggering to [advanceArps] instead of sounding it once.
+ * Trigger one tracker [cell], routing it to the mixer bus(es) whose MIDI channel
+ * matches the cell's channel — NOT the source track. A note therefore plays
+ * whatever instrument sits on its channel's bus, wherever it is placed in the
+ * grid (this is what decouples tracks from buses). Each matching bus applies
+ * its own MIDI effects (Transposer shifts pitch; an Arpeggiator captures the
+ * note and hands retriggering to [advanceArps]).
*/
- private fun triggerVoice(proj: Project, lane: Int, track: Int, cell: com.reactorcoremeltdown.sizzletracker.model.Cell) {
- val arp = arps[lane * Pattern.TRACK_COUNT + track]
- when {
- cell.isPlayable -> {
- val velocity = cell.velocity.coerceIn(0, 127)
- val note = (cell.note + channelTranspose(proj, track)).coerceIn(0, 127)
- val arpSlot = channelArpSlot(proj, track)
- if (arpSlot != null) {
- val octaves = arpSlot.float("octaves", 1f).toInt()
- val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
- val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
- arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples)
- playNote(proj, lane, track, arp.currentNote(), velocity)
- } else {
+ private fun triggerCell(proj: Project, lane: Int, cell: com.reactorcoremeltdown.sizzletracker.model.Cell) {
+ for (bus in 0 until Pattern.TRACK_COUNT) {
+ if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
+ val arp = arps[lane * Pattern.TRACK_COUNT + bus]
+ when {
+ cell.isPlayable -> {
+ val velocity = cell.velocity.coerceIn(0, 127)
+ val note = (cell.note + channelTranspose(proj, bus)).coerceIn(0, 127)
+ val arpSlot = channelArpSlot(proj, bus)
+ if (arpSlot != null) {
+ val octaves = arpSlot.float("octaves", 1f).toInt()
+ val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
+ val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
+ arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples)
+ playNote(proj, bus, arp.currentNote(), velocity)
+ } else {
+ arp.stop()
+ playNote(proj, bus, note, velocity)
+ }
+ }
+ cell.isNoteOff -> {
arp.stop()
- playNote(proj, lane, track, note, velocity)
+ releaseBus(bus)
}
}
- cell.isNoteOff -> {
- arp.stop()
- seqVoice(lane, track).noteOff(); sampleVoice(lane, track).noteOff()
- }
}
}
/**
- * Actually sound a note on (lane, track): a [SampleVoice] when the channel's
- * instrument is a Sampler with a loaded sample, otherwise the NES synth. The
- * unused voice of the pair is killed so they never double up. Shared by the
- * sequencer and the arpeggiator.
+ * Sound a note on a mixer [bus]: allocate a [SampleVoice] from the bus pool when
+ * the channel's instrument is a Sampler/SoundFont with a loaded sample, otherwise
+ * a [SynthVoice] for the NES synth. Pooled allocation (up to [POLYPHONY] per bus,
+ * stealing the oldest when full) is what makes the instrument polyphonic. Shared
+ * by the sequencer and the arpeggiator.
*/
- private fun playNote(proj: Project, lane: Int, track: Int, note: Int, velocity: Int) {
- val synth = seqVoice(lane, track)
- val samp = sampleVoice(lane, track)
- val slot = proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot)
- val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
- if (sample != null && slot != null) {
- synth.kill()
- val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
- samp.noteOn(
- sample, note, velocity, root,
- slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f),
- )
- } else {
- samp.kill()
- val (wave, a, d, s, r) = synthParamsForTrack(proj, track)
- synth.noteOn(note, velocity, wave, a, d, s, r)
+ private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int) {
+ val slot = proj.toolbox.getOrNull(proj.mixer.channels[bus].instrumentSlot)
+ val vs = voiceSample(slot, note)
+ when {
+ vs != null ->
+ allocSampleVoice(bus).noteOn(vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
+ vs.attack, vs.decay, vs.sustain, vs.release)
+ // Empty bus (no instrument — e.g. the default channel 0) or a Sampler
+ // with an empty pad → silent, matching the live audition path.
+ slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
+ // NES synth, or a SoundFont with nothing loaded → the tone generator.
+ else -> {
+ val (wave, a, d, s, r) = synthParamsForTrack(proj, bus)
+ allocSynthVoice(bus).noteOn(note, velocity, wave, a, d, s, r)
+ }
}
}
+ /** A sample voice's source + pitch + amplitude-envelope info for a note. */
+ private class VoiceSample(
+ val sample: SampleStore.Sample, val root: Int,
+ val sliceStart: Float, val sliceEnd: Float, val volume: Float,
+ val attack: Float = 0.002f, val decay: Float = 0f,
+ val sustain: Float = 1f, val release: Float = 0.02f,
+ )
+
+ /**
+ * Resolve the PCM sample a note should play for [slot], or null if the slot
+ * isn't a loaded sample instrument (the caller then uses the synth).
+ * - Sampler: the pad [note] maps to (root == note, so it plays unpitched). Pads
+ * stay one-shot (default envelope), just carrying their per-pad volume/slice.
+ * - SoundFont: the loaded SF2/XI sample, pitched from its stored root note, with
+ * the user's volume ADSR envelope applied.
+ */
+ private fun voiceSample(slot: ToolboxSlot?, note: Int): VoiceSample? = when (slot?.type) {
+ ToolboxType.SAMPLER -> {
+ val pad = SamplerPads.padFor(note)
+ if (pad !in 0 until SamplerPads.COUNT) {
+ null
+ } else {
+ SampleStore.get(slot.string(SamplerPads.key(pad)))?.let {
+ VoiceSample(
+ it, note,
+ slot.float(SamplerPads.sliceStartKey(pad), 0f),
+ slot.float(SamplerPads.sliceEndKey(pad), 1f),
+ slot.float(SamplerPads.volumeKey(pad), SamplerPads.DEFAULT_VOLUME),
+ )
+ }
+ }
+ }
+ ToolboxType.SOUNDFONT ->
+ SampleStore.get(slot.string("sfSample"))?.let {
+ VoiceSample(
+ it, slot.float("sfRoot", 60f).toInt(), 0f, 1f, slot.float("volume", 0.8f),
+ attack = slot.float("attack", 0.005f),
+ decay = slot.float("decay", 0.10f),
+ sustain = slot.float("sustain", 1f),
+ release = slot.float("release", 0.20f),
+ )
+ }
+ else -> null
+ }
+
/** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
for (idx in proj.mixer.channels[track].fxSlots) {
@@ -485,7 +747,7 @@ class AudioEngine(
a.samplesLeft -= 1.0
if (a.samplesLeft <= 0.0) {
a.advance()
- playNote(proj, i / Pattern.TRACK_COUNT, i % Pattern.TRACK_COUNT, a.currentNote(), a.velocity)
+ playNote(proj, i % Pattern.TRACK_COUNT, a.currentNote(), a.velocity)
a.samplesLeft += a.stepSamples
}
}
@@ -537,16 +799,20 @@ class AudioEngine(
fun auditionSlotOn(slotIndex: Int, midi: Int) {
val proj = project ?: return
val slot = proj.toolbox.getOrNull(slotIndex)
- val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
- if (sample != null && slot != null) {
+ val vs = voiceSample(slot, midi)
+ if (vs != null) {
val v = auditionSampleVoices.firstOrNull { !it.isActive } ?: auditionSampleVoices[0].also { it.kill() }
- val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
- v.noteOn(sample, midi, 110, root, slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f))
- } else {
- val (wave, a, d, s, r) = synthParamsForSlot(slot)
- val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
- v.noteOn(midi, 110, wave, a, d, s, r)
+ v.noteOn(vs.sample, midi, 110, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
+ vs.attack, vs.decay, vs.sustain, vs.release)
+ return
}
+ // A Sampler with an empty pad stays silent (audition is a one-shot with no
+ // note-off, so a sustaining synth voice would ring forever). A SoundFont
+ // with nothing loaded still falls through to the synth preview below.
+ if (slot?.type == ToolboxType.SAMPLER) return
+ val (wave, a, d, s, r) = synthParamsForSlot(slot)
+ val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
+ v.noteOn(midi, 110, wave, a, d, s, r)
}
/** Release an auditioned note (matches both synth and sample audition pools). */
@@ -555,16 +821,66 @@ class AudioEngine(
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
}
+ // ---------------------------------------------- live mix-bus audition (MIDI piano)
+ /**
+ * Play [midi] on every mixer channel listening on [midiChannel], sounding each
+ * channel's instrument through its full insert chain, volume and mute/solo —
+ * i.e. auditioning the actual mix bus, not a raw toolbox slot.
+ */
+ fun busNoteOn(midiChannel: Int, midi: Int, velocity: Int) {
+ val proj = project ?: return
+ for (ch in 0 until Pattern.TRACK_COUNT) {
+ if (proj.mixer.channels[ch].midiChannel != midiChannel) continue
+ val slot = proj.toolbox.getOrNull(proj.mixer.channels[ch].instrumentSlot)
+ // Empty bus (no instrument assigned) → silent. Auditioning a bus should
+ // only sound when it actually has an instrument, never a fallback synth.
+ val type = slot?.type ?: continue
+ val base = ch * BUS_POLYPHONY
+ val vs = voiceSample(slot, midi)
+ when {
+ vs != null -> {
+ val v = (0 until BUS_POLYPHONY).map { busSampleVoices[base + it] }
+ .firstOrNull { !it.isActive } ?: busSampleVoices[base].also { it.kill() }
+ v.noteOn(vs.sample, midi, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
+ vs.attack, vs.decay, vs.sustain, vs.release)
+ }
+ // Sampler with an empty pad → silent (no synth fallback).
+ type == ToolboxType.SAMPLER -> {}
+ else -> {
+ val (wave, a, d, s, r) = synthParamsForSlot(slot)
+ val v = (0 until BUS_POLYPHONY).map { busVoices[base + it] }
+ .firstOrNull { !it.isActive } ?: busVoices[base].also { it.kill() }
+ v.noteOn(midi, velocity, wave, a, d, s, r)
+ }
+ }
+ }
+ }
+
+ /** Release [midi] on every mixer channel listening on [midiChannel]. */
+ fun busNoteOff(midiChannel: Int, midi: Int) {
+ val proj = project ?: return
+ for (ch in 0 until Pattern.TRACK_COUNT) {
+ if (proj.mixer.channels[ch].midiChannel != midiChannel) continue
+ val base = ch * BUS_POLYPHONY
+ for (i in 0 until BUS_POLYPHONY) {
+ busVoices[base + i].takeIf { it.isActive && it.pitch == midi }?.noteOff()
+ busSampleVoices[base + i].takeIf { it.isActive && it.pitch == midi }?.noteOff()
+ }
+ }
+ }
+
// ----- Arrangement mode -----
private fun advanceArrangement(proj: Project) {
val arr = proj.arrangement
- if (playlist.isEmpty()) { playing = false; publish(); return }
+ // Snapshot the volatile playlist once so a concurrent live rebuild (from
+ // refreshLoopPlaylist) can't change its size mid-computation.
+ val list = playlist
+ if (list.isEmpty()) { playing = false; publish(); return }
val linesPerBeat = proj.timeSignature.linesPerBeat
val samplesPerLine = samplesPerLine(proj)
- val beat = playlist[playlistIndex.coerceIn(0, playlist.size - 1)]
+ val beat = list[playlistIndex.coerceIn(0, list.size - 1)]
if (pendingTrigger) {
- if (lineInBeat == 0) advanceLaneCursors(proj, arr, beat, linesPerBeat)
triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
pendingTrigger = false
}
@@ -576,10 +892,9 @@ class AudioEngine(
if (lineInBeat >= linesPerBeat) {
lineInBeat = 0
playlistIndex++
- if (playlistIndex >= playlist.size) {
+ if (playlistIndex >= list.size) {
if (arr.loopEnabled) {
playlistIndex = 0
- lanePrevPattern.fill(-2) // fresh cycle: blocks restart from beat 0
} else {
playing = false; allSeqVoicesOff(); publish(); return
}
@@ -590,35 +905,40 @@ class AudioEngine(
}
}
- /** At each new beat, update every lane's within-pattern cursor. */
- private fun advanceLaneCursors(proj: Project, arr: Arrangement, beat: Int, linesPerBeat: Int) {
- for (lane in 0 until Arrangement.LANE_COUNT) {
- val p = arr.patternAt(lane, beat)
- if (p == Arrangement.EMPTY) {
- lanePrevPattern[lane] = Arrangement.EMPTY
- continue
- }
- if (p == lanePrevPattern[lane]) {
- val beats = ((proj.pattern(p)?.length ?: linesPerBeat) / linesPerBeat).coerceAtLeast(1)
- laneCursor[lane] = (laneCursor[lane] + 1) % beats
- } else {
- laneCursor[lane] = 0
- }
- lanePrevPattern[lane] = p
- }
- }
-
+ /**
+ * Play one tick of every block marked at [beat]. Mirrors the reference
+ * sizzletracker `playAt`: a marker means the block sounds *that beat*, and a
+ * contiguous run of markers plays the block from its start beat-by-beat (and
+ * restarts after any erased gap). The beat-within-block is derived STATELESSLY
+ * from the run's start each tick — no mutable cursor — so it stays correct
+ * across loop wraps and region loops (the earlier "only the first beat plays
+ * on loop" bug came from a cursor that reset every loop cycle).
+ */
private fun triggerArrangementLine(
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
) {
- for (lane in 0 until Arrangement.LANE_COUNT) {
- val p = arr.patternAt(lane, beat)
- if (p == Arrangement.EMPTY) continue
- val pat = proj.pattern(p) ?: continue
- val patLine = laneCursor[lane] * linesPerBeat + lineInBeat
- if (patLine >= pat.length) continue
- for (t in 0 until Pattern.TRACK_COUNT) {
- triggerVoice(proj, lane, t, pat.cell(t, patLine))
+ // Two passes across all lanes: note-offs first, then note-ons, so a note-off
+ // never cancels a note that begins on the same line/bus (a note-off releases
+ // the whole bus). See [triggerPatternLine].
+ for (pass in 0..1) {
+ for (lane in 0 until Arrangement.LANE_COUNT) {
+ val p = arr.patternAt(lane, beat)
+ if (p == Arrangement.EMPTY) continue
+ val pat = proj.pattern(p) ?: continue
+ // Walk back to the start of this block's contiguous run of markers so it
+ // plays from beat 0 wherever the run begins.
+ var runStart = beat
+ while (runStart > 0 && arr.patternAt(lane, runStart - 1) != Arrangement.EMPTY) runStart--
+ val blockBeats = (pat.length / linesPerBeat).coerceAtLeast(1)
+ val patLine = ((beat - runStart) % blockBeats) * linesPerBeat + lineInBeat
+ // The edited block drives the tracker playhead (set once, in pass 0).
+ if (pass == 0 && p == proj.activePatternId) arrPlayLine = patLine.coerceIn(0, pat.length - 1)
+ if (patLine >= pat.length) continue
+ for (t in 0 until Pattern.TRACK_COUNT) {
+ val cell = pat.cell(t, patLine)
+ if (pass == 0) { if (cell.isNoteOff) triggerCell(proj, lane, cell) }
+ else { if (cell.isPlayable) triggerCell(proj, lane, cell) }
+ }
}
}
}
@@ -659,10 +979,11 @@ class AudioEngine(
val proj = project
val linesPerBeat = proj?.timeSignature?.linesPerBeat ?: 4
if (arrangementMode) {
- val beat = if (playlist.isNotEmpty()) playlist[playlistIndex.coerceIn(0, playlist.size - 1)] else 0
+ val list = playlist
+ val beat = if (list.isNotEmpty()) list[playlistIndex.coerceIn(0, list.size - 1)] else 0
_transport.value = TransportState(
isPlaying = playing,
- currentLine = laneCursor[0] * linesPerBeat + lineInBeat,
+ currentLine = arrPlayLine,
currentBeat = beat,
)
} else {
@@ -681,17 +1002,27 @@ class AudioEngine(
companion object {
private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz
+ private const val POLYPHONY = 8 // max simultaneous voices per instrument (mixer bus)
private const val LIVE_POLYPHONY = 8
- private const val AUDITION_POLYPHONY = 6
+ private const val AUDITION_POLYPHONY = 8
+ private const val BUS_POLYPHONY = 8 // per-mixer-channel live audition
private const val MASTER_GAIN = 0.35f
private const val LIVE_GAIN = 0.5f
+ private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
private val DEFAULT_SYNTH = SynthParams(SynthVoice.Wave.PULSE_50, 0.01, 0.15, 0.6, 0.1)
- /** Cheap soft clipper to keep the mix from harsh digital clipping. */
- private fun softClip(x: Float): Float = when {
- x > 1f -> 1f - 1f / (x + 1f)
- x < -1f -> -1f + 1f / (-x + 1f)
- else -> x
+ /**
+ * Continuous soft-knee limiter: linear (transparent) up to [SOFT_KNEE], then
+ * smoothly saturating to +-1 above it. The previous clipper was discontinuous
+ * at |x|=1 (it snapped the output 1.0 -> 0.5 as the signal crossed full scale),
+ * which produced audible clicks whenever a hot bus pushed the mix past 1.0.
+ * This version is value- and slope-continuous, so no crossing can click.
+ */
+ private fun softClip(x: Float): Float {
+ val a = if (x < 0f) -x else x
+ if (a <= SOFT_KNEE) return x
+ val comp = SOFT_KNEE + (1f - SOFT_KNEE) * tanh((a - SOFT_KNEE) / (1f - SOFT_KNEE))
+ return if (x < 0f) -comp else comp
}
}
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt
index ee03ad1..ea2a877 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt
@@ -1,10 +1,13 @@
package com.reactorcoremeltdown.sizzletracker.audio
-import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
+import com.reactorcoremeltdown.sizzletracker.model.DelayDivisions
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
import kotlin.math.PI
+import kotlin.math.abs
import kotlin.math.cos
+import kotlin.math.exp
+import kotlin.math.max
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
@@ -29,7 +32,7 @@ interface AudioEffect {
companion object {
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
ToolboxType.DELAY -> TapeDelay(sampleRate)
- ToolboxType.REVERB -> Reverb(sampleRate)
+ ToolboxType.AMBIENCE -> AmbienceReverb(sampleRate)
ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
ToolboxType.BITCRUSHER -> Bitcrusher()
ToolboxType.EQ10 -> GraphicEq(sampleRate)
@@ -39,48 +42,132 @@ interface AudioEffect {
}
// ---------------------------------------------------------------------------
-// Tape delay: a single delay line with feedback, tempo-synced time and dry/wet.
-// (The "tape heads" count is modelled but rendered as one tap here.)
+// Multi-tap tape delay: one shared delay line read by up to four independent
+// "tape heads", each with its own tempo-synced division and on/off switch. A tape
+// character control adds saturation, per-repeat darkening and wow/flutter.
+//
+// The wet signal is the AVERAGE of the active heads and feedback is hard-capped
+// below 1, so the round-trip loop gain can never exceed the feedback amount — a
+// runaway positive-feedback loop is impossible regardless of how many heads are on.
// ---------------------------------------------------------------------------
private class TapeDelay(private val sampleRate: Int) : AudioEffect {
- private val buffer = FloatArray(sampleRate * 2) // up to 2 s of delay
+ private val buffer = FloatArray(sampleRate * 4) // up to 4 s of delay line
private var writeIndex = 0
- private var delaySamples = sampleRate / 2
+ private val delaySamples = IntArray(DelayDivisions.HEADS) { sampleRate / 2 }
+ private val headOn = BooleanArray(DelayDivisions.HEADS)
private var feedback = 0.4f
private var dryWet = 0.35f
+ // Tape character:
+ private var satBlend = 0f // 0 = clean, 1 = full soft saturation on peaks
+ private var lpAlpha = 1f // feedback-path one-pole LP: 1 = bright, →0 = dark
+ private var lpState = 0f
+ private var flutterInc = 0.0
+ private var flutterDepth = 0f
+ private var flutterPhase = 0.0
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
- val div = TimeDivision.fromIndex(slot.float("division", 3f).toInt())
- val seconds = div.beatFraction * (60.0 / tempoBpm)
- delaySamples = (seconds * sampleRate).toInt().coerceIn(1, buffer.size - 1)
- feedback = slot.float("feedback", 0.4f).coerceIn(0f, 0.95f)
+ val samplesPerBeat = (60.0 / tempoBpm) * sampleRate
+ for (h in 0 until DelayDivisions.HEADS) {
+ // Precomputed key strings — building "head${h}On" here would allocate on
+ // the audio thread every block (this runs from fillBlock).
+ headOn[h] = slot.float(HEAD_ON_KEYS[h], if (h == 0) 1f else 0f) >= 0.5f
+ val idx = slot.float(HEAD_DIV_KEYS[h], DelayDivisions.DEFAULT.toFloat()).toInt()
+ delaySamples[h] = (DelayDivisions.beatFraction(idx) * samplesPerBeat)
+ .toInt().coerceIn(1, buffer.size - 2)
+ }
+ feedback = slot.float("feedback", 0.4f).coerceIn(0f, DelayDivisions.MAX_FEEDBACK)
dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
+ val tape = slot.float("tape", 0.3f).coerceIn(0f, 1f)
+ satBlend = tape // more tape → more saturation
+ lpAlpha = 1f - tape * 0.85f // more tape → darker repeats
+ flutterInc = 2.0 * PI * FLUTTER_HZ / sampleRate
+ flutterDepth = tape * sampleRate * 0.0015f // up to ~1.5 ms wow/flutter
}
override fun process(x: Float): Float {
- val readIndex = (writeIndex - delaySamples + buffer.size) % buffer.size
- val delayed = buffer[readIndex]
- buffer[writeIndex] = x + delayed * feedback
+ flutterPhase += flutterInc
+ if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
+ val flutter = sin(flutterPhase).toFloat() * flutterDepth
+
+ // Average the active heads (fractional read + linear interpolation for the
+ // flutter offset). Averaging keeps the level — and the feedback loop gain —
+ // independent of how many heads are switched on.
+ var wet = 0f
+ var active = 0
+ for (h in 0 until DelayDivisions.HEADS) {
+ if (!headOn[h]) continue
+ active++
+ var pos = writeIndex - delaySamples[h] - flutter
+ while (pos < 0f) pos += buffer.size
+ val i0 = pos.toInt() % buffer.size
+ val i1 = (i0 + 1) % buffer.size
+ val frac = pos - pos.toInt()
+ wet += buffer[i0] + (buffer[i1] - buffer[i0]) * frac
+ }
+ if (active > 0) wet /= active
+
+ // Feedback path: soft saturation blended in so the small-signal gain stays 1,
+ // then tape darkening (a DC-unity one-pole low-pass). Neither raises loop gain.
+ var fb = wet * feedback
+ fb += satBlend * (tanh(fb) - fb)
+ lpState += lpAlpha * (fb - lpState)
+ fb = lpState
+
+ buffer[writeIndex] = x + fb
writeIndex = (writeIndex + 1) % buffer.size
- return x * (1f - dryWet) + delayed * 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" }
}
}
// ---------------------------------------------------------------------------
-// RBJ biquad filter with selectable LP / HP / BP response.
+// RBJ biquad filter with selectable LP / HP / BP response, an ADSR cutoff envelope
+// (triggered by signal activity on the channel) and analog-style warmth saturation.
// ---------------------------------------------------------------------------
private class BiquadFilterEffect(private val sampleRate: Int) : AudioEffect {
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
private var a1 = 0.0; private var a2 = 0.0
private var z1 = 0.0; private var z2 = 0.0 // transposed direct form II state
+ private val sr = sampleRate.toFloat()
+ private var type = "LPF"
+ private var baseCutoff = 0.7f
+ private var q = 1.0
+ private var warmth = 0f
+ private var envAmount = 0f
+
+ // Cutoff ADSR (0..1), retriggered by an input-level gate.
+ private enum class Phase { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
+ private var phase = Phase.IDLE
+ private var env = 0f
+ private var attackInc = 1f
+ private var decayInc = 1f
+ private var sustainLevel = 1f
+ private var releaseSeconds = 0.2f
+ private var releaseInc = 1f
+ private var levelEnv = 0f
+ private var levelCoeff = 0f
+ private var gateOpen = false
+ private var ctrl = 0
+
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
- val type = slot.string("type", "LPF")
- // Map 0..1 to ~20 Hz .. ~18 kHz logarithmically.
- val cutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
- val freq = 20.0 * 900.0.pow(cutoff.toDouble())
- val q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
- computeCoefficients(type, freq.coerceIn(20.0, sampleRate * 0.45), q)
+ type = slot.string("type", "LPF")
+ baseCutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
+ q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
+ warmth = slot.float("warmth", 0f).coerceIn(0f, 1f)
+ envAmount = slot.float("envamt", 0f).coerceIn(0f, 1f)
+ sustainLevel = slot.float("sustain", 1f).coerceIn(0f, 1f)
+ attackInc = 1f / (max(slot.float("attack", 0.005f), 0.0005f) * sr)
+ val decay = slot.float("decay", 0.10f)
+ decayInc = if (decay <= 0f) 1f else (1f - sustainLevel) / (decay * sr)
+ releaseSeconds = slot.float("release", 0.20f)
+ levelCoeff = 1f - exp(-1f / (sr * 0.005f)) // ~5 ms input-level follower
+ ctrl = 0 // force a coefficient refresh on the next sample
}
private fun computeCoefficients(type: String, freq: Double, q: Double) {
@@ -107,11 +194,51 @@ private class BiquadFilterEffect(private val sampleRate: Int) : AudioEffect {
}
override fun process(x: Float): Float {
+ // Gate the ADSR from the input level (hysteresis avoids chatter). A rising
+ // edge above GATE_HI (re)triggers attack; falling below GATE_LO releases.
+ levelEnv += (abs(x) - levelEnv) * levelCoeff
+ if (!gateOpen && levelEnv > GATE_HI) {
+ gateOpen = true; phase = Phase.ATTACK
+ } else if (gateOpen && levelEnv < GATE_LO) {
+ gateOpen = false
+ releaseInc = if (releaseSeconds <= 0f) 1f else env / (releaseSeconds * sr)
+ if (releaseInc <= 0f) releaseInc = 1f
+ phase = Phase.RELEASE
+ }
+ when (phase) {
+ Phase.ATTACK -> { env += attackInc; if (env >= 1f) { env = 1f; phase = Phase.DECAY } }
+ Phase.DECAY -> { env -= decayInc; if (env <= sustainLevel) { env = sustainLevel; phase = Phase.SUSTAIN } }
+ Phase.SUSTAIN -> {}
+ Phase.RELEASE -> { env -= releaseInc; if (env <= 0f) { env = 0f; phase = Phase.IDLE } }
+ Phase.IDLE -> {}
+ }
+
+ // Recompute the biquad at control rate as the envelope opens the cutoff (the
+ // envelope lifts the cutoff from its base toward fully open by Env Amount).
+ if (ctrl <= 0) {
+ ctrl = CTRL_INTERVAL
+ val eff = (baseCutoff + envAmount * env * (1f - baseCutoff)).coerceIn(0f, 1f)
+ val freq = 20.0 * 900.0.pow(eff.toDouble())
+ computeCoefficients(type, freq.coerceIn(20.0, sampleRate * 0.45), q)
+ }
+ ctrl--
+
val input = x.toDouble()
val out = b0 * input + z1
z1 = b1 * input - a1 * out + z2
z2 = b2 * input - a2 * out
- return out.toFloat()
+ var y = out.toFloat()
+
+ // Warmth: soft saturation blended in so the small-signal gain (and level)
+ // stays put — it only rounds off louder peaks, adding gentle harmonics.
+ if (warmth > 0.0001f) y += warmth * (tanh(y * 1.8f) / 1.8f - y)
+ return y
+ }
+
+ private companion object {
+ const val CTRL_INTERVAL = 32 // samples between cutoff-coefficient refreshes
+ const val GATE_HI = 0.010f // input level that opens the envelope gate
+ const val GATE_LO = 0.004f // input level that releases it (hysteresis)
}
}
@@ -144,68 +271,18 @@ private class Bitcrusher : AudioEffect {
}
}
-// ---------------------------------------------------------------------------
-// Schroeder/Freeverb-style mono reverb: parallel combs -> series allpasses.
-// ---------------------------------------------------------------------------
-private class Reverb(sampleRate: Int) : AudioEffect {
- private val scale = sampleRate / 44100.0
- private val combs = intArrayOf(1557, 1617, 1491, 1422).map { Comb((it * scale).toInt()) }
- private val allpasses = intArrayOf(225, 556).map { Allpass((it * scale).toInt()) }
- private var amount = 0.3f
-
- override fun update(slot: ToolboxSlot, tempoBpm: Float) {
- amount = slot.float("amount", 0.3f).coerceIn(0f, 1f)
- val damp = slot.float("tone", 0.5f).coerceIn(0f, 1f)
- combs.forEach { it.damp = damp; it.feedback = 0.84f }
- }
-
- override fun process(x: Float): Float {
- var y = 0f
- for (c in combs) y += c.process(x)
- y /= combs.size
- for (a in allpasses) y = a.process(y)
- return x * (1f - amount) + y * amount
- }
-
- private class Comb(size: Int) {
- private val buf = FloatArray(size.coerceAtLeast(1))
- private var idx = 0
- private var last = 0f
- var feedback = 0.84f
- var damp = 0.5f
- fun process(x: Float): Float {
- val out = buf[idx]
- last = out * (1f - damp) + last * damp
- buf[idx] = x + last * feedback
- idx = (idx + 1) % buf.size
- return out
- }
- }
-
- private class Allpass(size: Int) {
- private val buf = FloatArray(size.coerceAtLeast(1))
- private var idx = 0
- private val g = 0.5f
- fun process(x: Float): Float {
- val bufOut = buf[idx]
- val out = -x + bufOut
- buf[idx] = x + bufOut * g
- idx = (idx + 1) % buf.size
- return out
- }
- }
-}
-
// ---------------------------------------------------------------------------
// 10-band graphic EQ: ten peaking biquads at octave-spaced centre frequencies.
// ---------------------------------------------------------------------------
private class GraphicEq(private val sampleRate: Int) : AudioEffect {
private val centers = floatArrayOf(31f, 63f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
private val bands = Array(10) { PeakBiquad() }
+ // Precomputed keys — "band$i" would allocate on the audio thread every block.
+ private val bandKeys = Array(10) { "band$it" }
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
for (i in bands.indices) {
- val gainDb = slot.float("band$i", 0f).coerceIn(-1f, 1f) * 12f // -12..+12 dB
+ val gainDb = slot.float(bandKeys[i], 0f).coerceIn(-1f, 1f) * 12f // -12..+12 dB
bands[i].set(sampleRate, centers[i], gainDb)
}
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt
new file mode 100644
index 0000000..ce78175
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/MasterRecorder.kt
@@ -0,0 +1,137 @@
+package com.reactorcoremeltdown.sizzletracker.audio
+
+import java.io.File
+import java.io.RandomAccessFile
+import kotlin.concurrent.thread
+
+/**
+ * Records the engine's master mono output to a 16-bit PCM WAV file.
+ *
+ * The audio thread pushes one float per output sample via [write] into a
+ * preallocated ring buffer (single producer). A dedicated writer thread drains the
+ * ring to a temp WAV file (single consumer), so NO file I/O or allocation ever
+ * happens on the audio thread — the whole point, given how sensitive this engine is
+ * to audio-thread stalls.
+ *
+ * Lifecycle: [begin] opens the temp file and starts the writer. [endCapture] tells
+ * the writer to drain whatever is left, patch the WAV header sizes (via a
+ * RandomAccessFile seek — a `content://` stream isn't seekable, which is why we
+ * stage to a local temp file first) and then invoke the completion callback with the
+ * finished file. The caller copies that temp file to its final destination.
+ *
+ * The ring is single-producer / single-consumer with monotonically increasing
+ * positions, so plain @Volatile longs give correct visibility without locks:
+ * the audio thread only writes [writePos] and reads [readPos]; the writer thread
+ * only writes [readPos] and reads [writePos].
+ */
+class MasterRecorder(private val sampleRate: Int) {
+
+ private val ring = FloatArray(sampleRate.coerceAtLeast(1)) // ~1 s of headroom
+ @Volatile private var writePos = 0L // samples produced by the audio thread
+ @Volatile private var readPos = 0L // samples consumed by the writer thread
+ @Volatile private var capturing = false
+ @Volatile private var active = false // begin()..completion; guards re-entry
+ private var writer: Thread? = null
+
+ /** True while the audio thread should keep pushing samples. */
+ val isCapturing: Boolean get() = capturing
+
+ /** True from [begin] until the writer thread has fully finalized the file. */
+ val isActive: Boolean get() = active
+
+ /**
+ * Start a new recording into [temp]. Returns false if one is already running
+ * (so a fast replay during a previous take's tail can't clobber it).
+ * [onComplete] runs on the writer thread with the finished file (or null on
+ * failure).
+ */
+ fun begin(temp: File, onComplete: (File?) -> Unit): Boolean {
+ if (active) return false
+ writePos = 0L
+ readPos = 0L
+ active = true
+ capturing = true
+ writer = thread(name = "sizzle-master-rec", isDaemon = true) {
+ val file = runCatching { writeLoop(temp) }.getOrNull()
+ active = false
+ onComplete(file)
+ }
+ return true
+ }
+
+ /** Audio thread: append one sample. Never allocates; drops if the ring is full
+ * (only possible if the writer thread starved, which shouldn't happen). */
+ fun write(sample: Float) {
+ if (!capturing) return
+ val w = writePos
+ if (w - readPos >= ring.size) return
+ ring[(w % ring.size).toInt()] = sample
+ writePos = w + 1
+ }
+
+ /** Audio thread: stop accepting samples; the writer drains the rest and finalizes. */
+ fun endCapture() { capturing = false }
+
+ private fun writeLoop(temp: File): File {
+ val raf = RandomAccessFile(temp, "rw")
+ try {
+ raf.setLength(0)
+ writeHeader(raf, 0) // placeholder sizes; patched at the end
+ var frames = 0L
+ val chunk = 4096
+ val bytes = ByteArray(chunk * 2)
+ while (true) {
+ val w = writePos
+ val r = readPos
+ if (r < w) {
+ val n = minOf(w - r, chunk.toLong()).toInt()
+ for (i in 0 until n) {
+ val s = ring[((r + i) % ring.size).toInt()].coerceIn(-1f, 1f)
+ val v = (s * 32767f).toInt()
+ bytes[i * 2] = (v and 0xFF).toByte()
+ bytes[i * 2 + 1] = ((v shr 8) and 0xFF).toByte()
+ }
+ raf.write(bytes, 0, n * 2)
+ frames += n
+ readPos = r + n
+ } else if (!capturing) {
+ break // capture ended and the ring is fully drained
+ } else {
+ Thread.sleep(5) // wait for the audio thread to produce more
+ }
+ }
+ writeHeader(raf, frames) // rewrite the 44-byte header with the real sizes
+ } finally {
+ raf.close()
+ }
+ return temp
+ }
+
+ // ---- WAV (mono, 16-bit PCM) header ----
+ private fun writeHeader(raf: RandomAccessFile, frames: Long) {
+ val dataSize = frames * 2 // 2 bytes/sample, mono
+ raf.seek(0)
+ raf.writeBytes("RIFF")
+ writeIntLE(raf, (36 + dataSize).toInt())
+ raf.writeBytes("WAVE")
+ raf.writeBytes("fmt ")
+ writeIntLE(raf, 16) // PCM fmt chunk size
+ writeShortLE(raf, 1) // format = PCM
+ writeShortLE(raf, 1) // channels = mono
+ writeIntLE(raf, sampleRate)
+ writeIntLE(raf, sampleRate * 2) // byte rate = sampleRate * blockAlign
+ writeShortLE(raf, 2) // block align = channels * bytesPerSample
+ writeShortLE(raf, 16) // bits per sample
+ raf.writeBytes("data")
+ writeIntLE(raf, dataSize.toInt())
+ }
+
+ private fun writeIntLE(raf: RandomAccessFile, v: Int) {
+ raf.write(v and 0xFF); raf.write((v shr 8) and 0xFF)
+ raf.write((v shr 16) and 0xFF); raf.write((v shr 24) and 0xFF)
+ }
+
+ private fun writeShortLE(raf: RandomAccessFile, v: Int) {
+ raf.write(v and 0xFF); raf.write((v shr 8) and 0xFF)
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt
index 9d9b524..ef62af5 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt
@@ -92,6 +92,36 @@ object SampleStore {
return Sample(out, sampleRate)
}
+ /**
+ * Encode a [Sample] to a 16-bit mono PCM WAV file (the inverse of
+ * [decodeWav]). Used when a Sampler preset is saved so the audio travels with
+ * the preset as a plain, portable `.wav`.
+ */
+ fun encodeWav(sample: Sample): ByteArray {
+ val frames = sample.data.size
+ val bytesPerSample = 2
+ val channels = 1
+ val dataSize = frames * bytesPerSample * channels
+ val bb = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN)
+ bb.put("RIFF".toByteArray(Charsets.US_ASCII))
+ bb.putInt(36 + dataSize)
+ bb.put("WAVE".toByteArray(Charsets.US_ASCII))
+ bb.put("fmt ".toByteArray(Charsets.US_ASCII))
+ bb.putInt(16) // PCM fmt chunk size
+ bb.putShort(1) // audioFormat = PCM
+ bb.putShort(channels.toShort())
+ bb.putInt(sample.sampleRate)
+ bb.putInt(sample.sampleRate * channels * bytesPerSample) // byteRate
+ bb.putShort((channels * bytesPerSample).toShort()) // blockAlign
+ bb.putShort(16) // bitsPerSample
+ bb.put("data".toByteArray(Charsets.US_ASCII))
+ bb.putInt(dataSize)
+ for (v in sample.data) {
+ bb.putShort((v.coerceIn(-1f, 1f) * 32767f).toInt().toShort())
+ }
+ return bb.array()
+ }
+
private fun readTag(bb: ByteBuffer): String {
val b = ByteArray(4)
bb.get(b)
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt
index a3db2b1..e4a1f0c 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt
@@ -1,13 +1,21 @@
package com.reactorcoremeltdown.sizzletracker.audio
+import kotlin.math.exp
+
/**
* Plays back a decoded [SampleStore.Sample] as a pitched, one-shot voice for the
- * Sampler instrument. The note's distance from the sampler's root note sets the
+ * Sampler / SoundFont instruments. The note's distance from the root note sets the
* playback rate (so a single sample is pitch-stretched across the keyboard), and
* the slice start/end markers bound the region that plays.
*
- * Runs on the audio thread: [render] must not allocate. Linear interpolation
- * keeps pitched playback smooth; short fade-in/out ramps avoid clicks.
+ * Amplitude is shaped by a linear **ADSR envelope** (attack → decay → sustain →
+ * release), so a held note swells/sustains and a note-off releases it. The Sampler
+ * pads pass one-shot defaults (near-instant attack, full sustain, quick release) so
+ * they behave exactly as before; the SoundFont loader exposes the envelope as
+ * user-tunable sliders. A separate short end-of-sample fade prevents a click when
+ * the sample data runs out before the release finishes.
+ *
+ * Runs on the audio thread: [render] must not allocate.
*/
class SampleVoice(private val engineSampleRate: Int) {
private var sample: SampleStore.Sample? = null
@@ -16,21 +24,46 @@ class SampleVoice(private val engineSampleRate: Int) {
private var startIndex = 0
private var endIndex = 0
private var amp = 0f
- private var releasing = false
- private var gain = 0f // current fade gain 0..1
var pitch = -1; private set
+ // ---- ADSR envelope (linear ramps; levels 0..1) ----
+ private enum class Phase { ATTACK, DECAY, SUSTAIN, RELEASE }
+ private var phase = Phase.ATTACK
+ private var env = 0f
+ private var attackInc = 1f
+ private var decayInc = 1f
+ private var sustainLevel = 1f
+ private var releaseSeconds = 0.02f
+ private var releaseInc = 1f
+
+ // ---- anti-click end-of-sample fade (independent of the ADSR) ----
+ private var endGain = 1f
+
+ // ---- declick: when a still-sounding voice is stolen/retriggered, its last
+ // output is carried as a fast-decaying offset so the hand-off has no
+ // discontinuity (an abrupt reset would zero a loud voice in one sample = click).
+ private var lastY = 0f
+ private var declick = 0f
+ private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay
+
val isActive: Boolean get() = sample != null
/**
* @param root MIDI note at which the sample plays at its natural pitch.
* @param sliceStart/[sliceEnd] 0..1 fractions of the sample to play.
* @param volume 0..1 instrument volume.
+ * @param attack/[decay]/[release] envelope stage times in seconds.
+ * @param sustain sustain level 0..1 held after the decay stage.
*/
fun noteOn(
s: SampleStore.Sample, midi: Int, velocity: Int, root: Int,
sliceStart: Float, sliceEnd: Float, volume: Float,
+ attack: Float = 0.002f, decay: Float = 0f, sustain: Float = 1f, release: Float = 0.02f,
) {
+ // Carry the level we're interrupting so the new note starts continuously
+ // (the new note fades in from ~0; without this the sum would jump from the
+ // old voice's level straight to ~0 → a click when stealing a loud voice).
+ declick += lastY
sample = s
pitch = midi
val len = s.data.size
@@ -43,34 +76,74 @@ class SampleVoice(private val engineSampleRate: Int) {
val semis = (midi - root) / 12.0
rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate)
amp = (velocity / 127f).coerceIn(0f, 1f) * volume
- releasing = false
- gain = 0f
+
+ // Envelope: enforce a tiny minimum attack so the onset never clicks.
+ val sr = engineSampleRate.toFloat()
+ sustainLevel = sustain.coerceIn(0f, 1f)
+ attackInc = 1f / (maxOf(attack, MIN_ATTACK) * sr)
+ decayInc = if (decay <= 0f) 1f else (1f - sustainLevel) / (decay * sr)
+ releaseSeconds = release
+ env = 0f
+ phase = Phase.ATTACK
+ endGain = 1f
}
- fun noteOff() { releasing = true }
+ /** Enter the release stage: fade from the current level to 0 over the release time. */
+ fun noteOff() {
+ val sr = engineSampleRate.toFloat()
+ releaseInc = if (releaseSeconds <= 0f) 1f else env / (releaseSeconds * sr)
+ if (releaseInc <= 0f) releaseInc = 1f
+ phase = Phase.RELEASE
+ }
- fun kill() { sample = null; pitch = -1; gain = 0f }
+ fun kill() { sample = null; pitch = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f }
+
+ /** One sample of the decaying declick offset (used on the paths that stop the
+ * voice, so any carried level rings out smoothly instead of snapping to 0). */
+ private fun declickTail(): Float {
+ if (declick == 0f) { lastY = 0f; return 0f }
+ val y = declick
+ declick *= declickCoeff
+ if (declick < 1e-5f && declick > -1e-5f) declick = 0f
+ lastY = y
+ return y
+ }
fun render(): Float {
- val s = sample ?: return 0f
+ val s = sample ?: return declickTail() // idle → 0; ring out any declick tail
val data = s.data
val i = position.toInt()
- if (i >= endIndex - 1 || i >= data.size - 1) { kill(); return 0f }
+ if (i >= endIndex - 1 || i >= data.size - 1) { sample = null; pitch = -1; return declickTail() }
+
+ // Anti-click fade as the slice end approaches (in OUTPUT samples, so it holds
+ // regardless of playback rate).
+ if ((endIndex - i) / rate <= FADE_SAMPLES) {
+ endGain = (endGain - FADE_STEP).coerceAtLeast(0f)
+ if (endGain <= 0f) { sample = null; pitch = -1; return declickTail() }
+ }
+
+ // Advance the ADSR envelope one output sample.
+ when (phase) {
+ Phase.ATTACK -> { env += attackInc; if (env >= 1f) { env = 1f; phase = Phase.DECAY } }
+ Phase.DECAY -> { env -= decayInc; if (env <= sustainLevel) { env = sustainLevel; phase = Phase.SUSTAIN } }
+ Phase.SUSTAIN -> {}
+ Phase.RELEASE -> { env -= releaseInc; if (env <= 0f) { sample = null; pitch = -1; return declickTail() } }
+ }
// Linear interpolation between neighbouring samples.
val frac = (position - i).toFloat()
val out = data[i] + (data[i + 1] - data[i]) * frac
- // Fade in quickly, and fade out when releasing, to avoid clicks.
- gain = if (releasing) (gain - FADE_STEP).coerceAtLeast(0f)
- else (gain + FADE_STEP).coerceAtMost(1f)
- if (releasing && gain <= 0f) { kill(); return 0f }
-
position += rate
- return out * amp * gain
+ val y = out * amp * env * endGain + declick
+ declick *= declickCoeff
+ lastY = y
+ return y
}
companion object {
- private const val FADE_STEP = 0.01f // ~2 ms ramp at 48 kHz
+ private const val FADE_STEP = 0.01f // ~2 ms end fade at 48 kHz
+ private const val FADE_SAMPLES = 1.0 / FADE_STEP // output samples the fade spans
+ private const val MIN_ATTACK = 0.0005f // ~0.5 ms minimum onset ramp (anti-click)
}
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SoundFontLoader.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SoundFontLoader.kt
new file mode 100644
index 0000000..00e97a0
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SoundFontLoader.kt
@@ -0,0 +1,152 @@
+package com.reactorcoremeltdown.sizzletracker.audio
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Loads a SoundFont (`.sf2`) or FastTracker II instrument (`.xi`) file and extracts
+ * a single playable PCM sample plus the MIDI note at which it plays natural pitch.
+ * The result feeds the existing [SampleStore] / [SampleVoice] pitched-playback path
+ * (the same one the Sampler uses), so a SoundFont slot plays like a pitched
+ * single-sample instrument across the keyboard.
+ *
+ * This is intentionally pragmatic rather than a full synthesiser: SF2 preset/zone
+ * layering, per-key sample maps and loop points are not honoured — we take the
+ * file's first sample (SF2 `shdr` / XI first sample) and pitch it from its root.
+ * A plain WAV is also accepted as a convenience. Returns null if the bytes are not
+ * a format we understand (the engine then keeps its synth fallback).
+ */
+object SoundFontLoader {
+
+ class Loaded(val sample: SampleStore.Sample, val rootNote: Int, val kind: String)
+
+ /** Frames cap so a pathologically large sample region can't exhaust memory. */
+ private const val MAX_FRAMES = 48_000 * 30
+
+ fun load(bytes: ByteArray): Loaded? = when {
+ looksLikeSf2(bytes) -> runCatching { loadSf2(bytes) }.getOrNull()
+ looksLikeXi(bytes) -> runCatching { loadXi(bytes) }.getOrNull()
+ else -> SampleStore.decodeWav(bytes)?.let { Loaded(it, 60, "WAV") }
+ }
+
+ // ------------------------------------------------------------------- SF2
+
+ private fun looksLikeSf2(b: ByteArray): Boolean =
+ b.size > 12 && b.tagAt(0) == "RIFF" && b.tagAt(8) == "sfbk"
+
+ private fun loadSf2(bytes: ByteArray): Loaded? {
+ val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
+ if (readTag(bb) != "RIFF") return null
+ bb.int // riff size
+ if (readTag(bb) != "sfbk") return null
+
+ var smplOff = -1; var shdrOff = -1; var shdrBytes = 0
+ while (bb.remaining() >= 8) {
+ val id = readTag(bb)
+ val size = bb.int
+ val chunkEnd = (bb.position() + size).coerceAtMost(bb.limit())
+ if (id == "LIST" && bb.remaining() >= 4) {
+ val listType = readTag(bb)
+ while (bb.position() + 8 <= chunkEnd) {
+ val sid = readTag(bb)
+ val ssize = bb.int
+ val dataPos = bb.position()
+ if (listType == "sdta" && sid == "smpl") smplOff = dataPos
+ if (listType == "pdta" && sid == "shdr") { shdrOff = dataPos; shdrBytes = ssize }
+ bb.position((dataPos + ssize + (ssize and 1)).coerceAtMost(bb.limit()))
+ }
+ }
+ bb.position((chunkEnd + (size and 1)).coerceAtMost(bb.limit()))
+ }
+ if (smplOff < 0 || shdrOff < 0 || shdrBytes < SHDR_REC) return null
+
+ // First sample header record (46 bytes): 20-byte name, then the fields.
+ val h = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).apply { position(shdrOff + 20) }
+ val start = h.int.toLong() and 0xFFFFFFFFL
+ val end = h.int.toLong() and 0xFFFFFFFFL
+ h.int; h.int // start/end loop (unused)
+ val sampleRate = h.int
+ val originalKey = h.get().toInt() and 0xFF
+ if (end <= start) return null
+
+ val frames = (end - start).toInt().coerceAtMost(MAX_FRAMES)
+ val out = FloatArray(frames)
+ val db = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
+ .apply { position((smplOff + start.toInt() * 2).coerceIn(0, bytes.size)) }
+ var i = 0
+ while (i < frames && db.remaining() >= 2) { out[i] = db.short / 32768f; i++ }
+
+ val root = if (originalKey in 1..127) originalKey else 60
+ val rate = if (sampleRate in 8000..192000) sampleRate else 44100
+ return Loaded(SampleStore.Sample(out, rate), root, "SF2")
+ }
+
+ // -------------------------------------------------------------------- XI
+
+ private fun looksLikeXi(b: ByteArray): Boolean =
+ b.size > 300 && String(b, 0, 15, Charsets.US_ASCII) == "Extended Instru"
+
+ private fun loadXi(bytes: ByteArray): Loaded? {
+ // Fixed XI header layout: number-of-samples word at 296, first 40-byte
+ // sample header at 298, then delta-encoded sample data after all headers.
+ val numSamples = u16(bytes, 296)
+ if (numSamples < 1) return null
+ val hp = 298
+ if (hp + 40 > bytes.size) return null
+ val length = u32(bytes, hp).toInt() // sample length in bytes
+ val type = bytes[hp + 14].toInt() and 0xFF
+ val relativeNote = bytes[hp + 16].toInt() // signed
+ val is16 = (type and 0x10) != 0
+
+ val dataStart = hp + numSamples * 40
+ if (length <= 0 || dataStart >= bytes.size) return null
+ val byteLen = minOf(length, bytes.size - dataStart)
+
+ val out: FloatArray
+ if (is16) {
+ val n = (byteLen / 2).coerceAtMost(MAX_FRAMES)
+ out = FloatArray(n)
+ val db = ByteBuffer.wrap(bytes, dataStart, byteLen).order(ByteOrder.LITTLE_ENDIAN)
+ var old = 0
+ for (k in 0 until n) {
+ old = (old + db.short.toInt()) and 0xFFFF
+ out[k] = old.toShort() / 32768f
+ }
+ } else {
+ val n = byteLen.coerceAtMost(MAX_FRAMES)
+ out = FloatArray(n)
+ var old = 0
+ for (k in 0 until n) {
+ old = (old + bytes[dataStart + k]) and 0xFF
+ out[k] = old.toByte() / 128f
+ }
+ }
+ // XI/XM samples have no stored rate; C-4 plays at 8363 Hz by convention.
+ // relativeNote offsets the pitch: root = 60 - relativeNote so playing that
+ // note yields the sample's natural pitch and it transposes from there.
+ val root = (60 - relativeNote).coerceIn(0, 127)
+ return Loaded(SampleStore.Sample(out, 8363), root, "XI")
+ }
+
+ // ----------------------------------------------------------------- helpers
+
+ private const val SHDR_REC = 46
+
+ private fun readTag(bb: ByteBuffer): String {
+ val b = ByteArray(4)
+ bb.get(b)
+ return String(b, Charsets.US_ASCII)
+ }
+
+ private fun ByteArray.tagAt(off: Int): String =
+ if (off + 4 <= size) String(this, off, 4, Charsets.US_ASCII) else ""
+
+ private fun u16(b: ByteArray, off: Int): Int =
+ (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
+
+ private fun u32(b: ByteArray, off: Int): Long =
+ ((b[off].toInt() and 0xFF).toLong()) or
+ ((b[off + 1].toInt() and 0xFF).toLong() shl 8) or
+ ((b[off + 2].toInt() and 0xFF).toLong() shl 16) or
+ ((b[off + 3].toInt() and 0xFF).toLong() shl 24)
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/BindingStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/BindingStore.kt
new file mode 100644
index 0000000..d95eeaf
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/BindingStore.kt
@@ -0,0 +1,85 @@
+package com.reactorcoremeltdown.sizzletracker.input
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+/** The single DataStore file backing all persisted input bindings. */
+private val Context.bindingDataStore: DataStore by preferencesDataStore(name = "input_bindings")
+
+/**
+ * Persists the three user-editable binding maps — keyboard hotkeys, gamepad
+ * buttons/chords, and MIDI CCs — so custom bindings survive an app restart.
+ *
+ * Each device kind is stored as one small text blob (one `key=actionId` line
+ * apiece) in a Preferences [DataStore]. The in-memory maps on the input handlers
+ * remain the source of truth at runtime: [loadInto] seeds them once at startup
+ * and [save] rewrites the blobs after any edit. Only a device kind that has
+ * actually been stored replaces its defaults, so a fresh install keeps the
+ * built-in layout untouched.
+ */
+class BindingStore(private val context: Context) {
+
+ /** Overwrite each handler's binding map with the persisted one, if present.
+ * Call once at startup, before any hardware event can arrive. */
+ suspend fun loadInto(keyboard: KeyboardInput, gamepad: GamepadInput, midi: MidiInput) {
+ val prefs = context.bindingDataStore.data.first()
+ prefs[KEYBOARD_KEY]?.let { keyboard.bindings = decodeIntMap(it) }
+ prefs[MIDI_KEY]?.let { midi.ccBindings = decodeIntMap(it) }
+ prefs[GAMEPAD_KEY]?.let { gamepad.bindings = decodeChordMap(it) }
+ }
+
+ /** Snapshot all three binding maps to disk. Call after any binding change. */
+ suspend fun save(keyboard: KeyboardInput, gamepad: GamepadInput, midi: MidiInput) {
+ context.bindingDataStore.edit { prefs ->
+ prefs[KEYBOARD_KEY] = encodeIntMap(keyboard.bindings)
+ prefs[MIDI_KEY] = encodeIntMap(midi.ccBindings)
+ prefs[GAMEPAD_KEY] = encodeChordMap(gamepad.bindings)
+ }
+ }
+
+ // ---- (de)serialization: one "key=actionId" line per entry ----
+
+ private fun encodeIntMap(m: Map): String =
+ m.entries.joinToString("\n") { "${it.key}=${it.value}" }
+
+ private fun decodeIntMap(s: String): MutableMap {
+ val out = mutableMapOf()
+ for (line in s.lineSequence()) {
+ val eq = line.indexOf('=')
+ if (eq <= 0) continue
+ val code = line.substring(0, eq).toIntOrNull() ?: continue
+ out[code] = line.substring(eq + 1)
+ }
+ return out
+ }
+
+ // Gamepad keys serialize as "modifier:code=actionId" (modifier 0 == none).
+ private fun encodeChordMap(m: Map): String =
+ m.entries.joinToString("\n") { "${it.key.modifier}:${it.key.code}=${it.value}" }
+
+ private fun decodeChordMap(s: String): MutableMap {
+ val out = mutableMapOf()
+ for (line in s.lineSequence()) {
+ val eq = line.indexOf('=')
+ if (eq <= 0) continue
+ val chord = line.substring(0, eq)
+ val colon = chord.indexOf(':')
+ if (colon <= 0) continue
+ val mod = chord.substring(0, colon).toIntOrNull() ?: continue
+ val code = chord.substring(colon + 1).toIntOrNull() ?: continue
+ out[GamepadChord(mod, code)] = line.substring(eq + 1)
+ }
+ return out
+ }
+
+ private companion object {
+ val KEYBOARD_KEY = stringPreferencesKey("keyboard")
+ val GAMEPAD_KEY = stringPreferencesKey("gamepad")
+ val MIDI_KEY = stringPreferencesKey("midi")
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt
index c359cc6..bd30010 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt
@@ -5,98 +5,203 @@ import android.view.KeyEvent
import android.view.MotionEvent
/**
- * Translates USB / Bluetooth gamepad input into [InputAction]s. Two kinds of
- * events arrive:
- * - button presses -> [KeyEvent] (handled in [onKeyDown])
- * - stick / trigger / D-pad-as-hat movement -> [MotionEvent] (handled in [onMotion])
+ * A gamepad binding trigger: a main control [code], optionally combined with a
+ * held [modifier] control. [modifier] == [NONE] means "no modifier" (a plain,
+ * single-control binding). This lets one control act as a "shift key": e.g. bind
+ * A+Up and A+Down to different actions, and (by clearing A's own plain binding) A
+ * becomes a pure modifier that does nothing on its own.
*
- * The button map is user-rebindable: [bindings] maps an Android key-code to an
- * action id string, editable from the Settings tab. A sensible default layout is
- * provided so the pad works out of the box.
+ * A "control" is a button key-code, a D-pad direction (the DPAD_* key-codes, used
+ * whether the pad reports as keys or as a hat), OR an analog-stick direction (the
+ * synthetic `CODE_*STICK_*` codes). All three are interchangeable in chords.
+ */
+data class GamepadChord(val modifier: Int, val code: Int) {
+ companion object {
+ /** Sentinel for "no modifier". KEYCODE_UNKNOWN (0) is never a real control. */
+ const val NONE = 0
+ }
+}
+
+/**
+ * Translates USB / Bluetooth gamepad input into [InputAction]s. Buttons arrive as
+ * [KeyEvent]s; sticks, triggers and the D-pad hat arrive as [MotionEvent]s. Every
+ * one is funnelled through a single press/release pipeline ([controlDown] /
+ * [controlUp]) so buttons, the D-pad AND the analog sticks are all rebindable and
+ * can be freely mixed into chords (e.g. "A + Left-stick-up", "L1 + D-pad-left").
+ *
+ * Analog directions are edge-detected with hysteresis: pushing a stick/hat past
+ * [AXIS_ON] is a "press", returning inside [AXIS_OFF] is a "release".
+ *
+ * The map is user-rebindable ([bindings]); a sensible default layout is provided.
*/
class GamepadInput(
private val router: InputRouter,
- var bindings: MutableMap = defaultBindings(),
+ var bindings: MutableMap = defaultBindings(),
) {
- // Debounce hat/stick navigation so a held stick doesn't fire every frame.
- private var lastHatX = 0
- private var lastHatY = 0
+ // Current sign (-1/0/+1) of each directional axis, for edge detection. The
+ // D-pad hat reuses the DPAD_* key-codes so hat- and key-reported D-pads behave
+ // identically; the sticks use the synthetic CODE_*STICK_* codes.
+ private var hatX = 0
+ private var hatY = 0
+ private var lStickX = 0
+ private var lStickY = 0
+ private var rStickX = 0
+ private var rStickY = 0
+
+ /** Controls physically held right now, in press order (first held = preferred
+ * modifier). Kept current by [controlDown]/[controlUp]. */
+ private val heldButtons = LinkedHashSet()
+
+ /** During gamepad-learn, the ordered controls pressed in this capture so we can
+ * reconstruct "modifier then main" once they're released. */
+ private val learnSeq = ArrayList()
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
- if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD) &&
- !event.isFromSource(InputDevice.SOURCE_JOYSTICK)
- ) return false
-
- val actionId = bindings[keyCode]
- if (actionId == null) {
- // Unbound button: forward as raw so "learn" mode can capture it.
- if (router.learnMode) router.dispatch(InputAction.RawControl(InputSource.GAMEPAD, keyCode, 1f))
- return false
- }
- actionFromId(actionId)?.let { router.dispatch(it); return true }
- return false
+ if (!isGamepad(keyCode, event)) return false
+ return controlDown(keyCode)
}
- /** Handle analog sticks, triggers, and D-pad hat axes. */
+ fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
+ if (!isGamepad(keyCode, event)) return false
+ return controlUp(keyCode)
+ }
+
+ /**
+ * Handle the analog sticks and the D-pad hat. Each axis direction is
+ * edge-detected and driven through the SAME press/release path as buttons, so
+ * the D-pad and sticks can be bound and used in chords exactly like buttons.
+ */
fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
-
- // D-pad reported as HAT axes on many pads.
- val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X).toSign()
- val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y).toSign()
- if (hatX != lastHatX) {
- lastHatX = hatX
- when (hatX) { -1 -> router.dispatch(InputAction.NavLeft); 1 -> router.dispatch(InputAction.NavRight) }
- }
- if (hatY != lastHatY) {
- lastHatY = hatY
- when (hatY) { -1 -> router.dispatch(InputAction.NavUp); 1 -> router.dispatch(InputAction.NavDown) }
- }
-
- // Right stick vertical edits the focused value (up = increment).
- val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
- when {
- ry < -EDIT_THRESHOLD -> router.dispatch(InputAction.Increment(1))
- ry > EDIT_THRESHOLD -> router.dispatch(InputAction.Decrement(1))
- }
+ hatX = axis(event.getAxisValue(MotionEvent.AXIS_HAT_X), hatX, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT)
+ hatY = axis(event.getAxisValue(MotionEvent.AXIS_HAT_Y), hatY, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN)
+ lStickX = axis(event.getAxisValue(MotionEvent.AXIS_X), lStickX, CODE_LSTICK_LEFT, CODE_LSTICK_RIGHT)
+ lStickY = axis(event.getAxisValue(MotionEvent.AXIS_Y), lStickY, CODE_LSTICK_UP, CODE_LSTICK_DOWN)
+ rStickX = axis(event.getAxisValue(MotionEvent.AXIS_Z), rStickX, CODE_RSTICK_LEFT, CODE_RSTICK_RIGHT)
+ rStickY = axis(event.getAxisValue(MotionEvent.AXIS_RZ), rStickY, CODE_RSTICK_UP, CODE_RSTICK_DOWN)
return true
}
- private fun actionFromId(id: String): InputAction? = when (id) {
- "playPause" -> InputAction.PlayPause
- "stop" -> InputAction.Stop
- "loop" -> InputAction.ToggleLoop
- "up" -> InputAction.NavUp
- "down" -> InputAction.NavDown
- "left" -> InputAction.NavLeft
- "right" -> InputAction.NavRight
- "increment" -> InputAction.Increment(1)
- "decrement" -> InputAction.Decrement(1)
- "clear" -> InputAction.ClearCell
- "nextTab" -> InputAction.NextTab
- "prevTab" -> InputAction.PrevTab
- else -> null
+ // ---- core press/release pipeline (shared by buttons, D-pad and sticks) ----
+
+ /** @return true if the press was consumed (learning, or a bound control). */
+ private fun controlDown(code: Int): Boolean {
+ // Learn mode: record the press sequence and consume it (no action fires).
+ if (learningGamepad()) {
+ if (code !in learnSeq) learnSeq.add(code)
+ heldButtons.add(code)
+ return true
+ }
+ // Chord first: any already-held control may be the modifier.
+ for (mod in heldButtons) {
+ val id = bindings[GamepadChord(mod, code)]
+ if (id != null) {
+ heldButtons.add(code)
+ InputActions.fromId(id)?.let { router.dispatch(it) }
+ return true
+ }
+ }
+ heldButtons.add(code)
+ // Otherwise the plain (no-modifier) binding.
+ bindings[GamepadChord(GamepadChord.NONE, code)]?.let { id ->
+ InputActions.fromId(id)?.let { router.dispatch(it); return true }
+ }
+ return false
}
- private fun Float.toSign(): Int = when {
- this < -0.5f -> -1; this > 0.5f -> 1; else -> 0
+ private fun controlUp(code: Int): Boolean {
+ heldButtons.remove(code)
+ // Finalize a learn capture once every control in this combo is released.
+ if (learningGamepad() && learnSeq.isNotEmpty() && heldButtons.none { it in learnSeq }) {
+ val main = learnSeq.last()
+ val mod = if (learnSeq.size >= 2) learnSeq.first() else GamepadChord.NONE
+ router.dispatch(InputAction.RawControl(InputSource.GAMEPAD, main, 1f, modifier = mod))
+ learnSeq.clear()
+ return true
+ }
+ return false
}
+ /**
+ * Edge-detect one analog axis with hysteresis and drive [controlDown]/
+ * [controlUp] on direction changes. [neg] / [pos] are the control codes for the
+ * negative / positive directions (for vertical axes, negative == up). Returns
+ * the axis's new sign to store back.
+ */
+ private fun axis(value: Float, last: Int, neg: Int, pos: Int): Int {
+ val sign = when {
+ value <= -AXIS_ON -> -1
+ value >= AXIS_ON -> 1
+ value > -AXIS_OFF && value < AXIS_OFF -> 0
+ else -> last // dead band between OFF and ON: hold to avoid chatter
+ }
+ if (sign != last) {
+ when (last) { -1 -> controlUp(neg); 1 -> controlUp(pos) }
+ when (sign) { -1 -> controlDown(neg); 1 -> controlDown(pos) }
+ }
+ return sign
+ }
+
+ /**
+ * Whether a key event is from a gamepad. Besides the SOURCE_GAMEPAD/JOYSTICK
+ * flags, we accept any gamepad-button key code ([KeyEvent.isGamepadButton]):
+ * several Bluetooth controllers deliver their face/shoulder buttons with only a
+ * keyboard/HID source flag set. D-pad key-codes still require a gamepad/joystick
+ * source so a real keyboard's arrow keys are not captured here.
+ */
+ private fun isGamepad(keyCode: Int, event: KeyEvent): Boolean =
+ event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
+ event.isFromSource(InputDevice.SOURCE_JOYSTICK) ||
+ KeyEvent.isGamepadButton(keyCode)
+
+ private fun learningGamepad(): Boolean =
+ router.learnMode && router.learnSource == InputSource.GAMEPAD
+
companion object {
- private const val EDIT_THRESHOLD = 0.6f
+ private const val AXIS_ON = 0.6f // push past this = press
+ private const val AXIS_OFF = 0.4f // return inside this = release
+
+ // Synthetic control codes for analog-stick directions, well above the real
+ // Android key-code range so they never collide with a button/D-pad code.
+ const val CODE_LSTICK_LEFT = 0x20001
+ const val CODE_LSTICK_RIGHT = 0x20002
+ const val CODE_LSTICK_UP = 0x20003
+ const val CODE_LSTICK_DOWN = 0x20004
+ const val CODE_RSTICK_LEFT = 0x20005
+ const val CODE_RSTICK_RIGHT = 0x20006
+ const val CODE_RSTICK_UP = 0x20007
+ const val CODE_RSTICK_DOWN = 0x20008
+
+ /** Readable name for any gamepad control code (button, D-pad or stick). */
+ fun controlLabel(code: Int): String = when (code) {
+ CODE_LSTICK_LEFT -> "LS←"; CODE_LSTICK_RIGHT -> "LS→"
+ CODE_LSTICK_UP -> "LS↑"; CODE_LSTICK_DOWN -> "LS↓"
+ CODE_RSTICK_LEFT -> "RS←"; CODE_RSTICK_RIGHT -> "RS→"
+ CODE_RSTICK_UP -> "RS↑"; CODE_RSTICK_DOWN -> "RS↓"
+ else -> KeyEvent.keyCodeToString(code)
+ .removePrefix("KEYCODE_").removePrefix("BUTTON_")
+ .replace("DPAD_", "").replace('_', ' ')
+ }
/** Default Xbox/standard-layout mapping; every value is an action id. */
- fun defaultBindings(): MutableMap = mutableMapOf(
- KeyEvent.KEYCODE_BUTTON_A to "playPause",
- KeyEvent.KEYCODE_BUTTON_B to "stop",
- KeyEvent.KEYCODE_BUTTON_X to "clear",
- KeyEvent.KEYCODE_BUTTON_Y to "loop",
- KeyEvent.KEYCODE_BUTTON_L1 to "prevTab",
- KeyEvent.KEYCODE_BUTTON_R1 to "nextTab",
- KeyEvent.KEYCODE_DPAD_UP to "up",
- KeyEvent.KEYCODE_DPAD_DOWN to "down",
- KeyEvent.KEYCODE_DPAD_LEFT to "left",
- KeyEvent.KEYCODE_DPAD_RIGHT to "right",
+ fun defaultBindings(): MutableMap = mutableMapOf(
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_A) to "playPause",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_B) to "stop",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_X) to "clear",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_Y) to "loop",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_L1) to "prevTab",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_R1) to "nextTab",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_UP) to "up",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_DOWN) to "down",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_LEFT) to "left",
+ GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_RIGHT) to "right",
+ // Left stick mirrors the D-pad; right stick edits the focused value.
+ GamepadChord(GamepadChord.NONE, CODE_LSTICK_UP) to "up",
+ GamepadChord(GamepadChord.NONE, CODE_LSTICK_DOWN) to "down",
+ GamepadChord(GamepadChord.NONE, CODE_LSTICK_LEFT) to "left",
+ GamepadChord(GamepadChord.NONE, CODE_LSTICK_RIGHT) to "right",
+ GamepadChord(GamepadChord.NONE, CODE_RSTICK_UP) to "increment",
+ GamepadChord(GamepadChord.NONE, CODE_RSTICK_DOWN) to "decrement",
)
}
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt
index 55eb73c..6e05618 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt
@@ -45,8 +45,16 @@ sealed interface InputAction {
data object PrevTab : InputAction
// ----- A raw, unmapped control (e.g. an unbound MIDI CC). Handlers may
- // ignore it; the settings screen uses it to "learn" new bindings. -----
- data class RawControl(val source: InputSource, val code: Int, val value: Float) : InputAction
+ // ignore it; the settings screen uses it to "learn" new bindings. For a
+ // gamepad chord (a button pressed while a modifier button is held) [modifier]
+ // carries the held button's key-code; it is 0 for plain, single-control
+ // captures (MIDI CC, keyboard key, or a lone gamepad button). -----
+ data class RawControl(
+ val source: InputSource,
+ val code: Int,
+ val value: Float,
+ val modifier: Int = 0,
+ ) : InputAction
}
/** Which physical device an action came from — handy for on-screen feedback. */
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputActions.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputActions.kt
new file mode 100644
index 0000000..befa5d7
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputActions.kt
@@ -0,0 +1,33 @@
+package com.reactorcoremeltdown.sizzletracker.input
+
+/**
+ * The single source of truth mapping a stable action-id string to the neutral
+ * [InputAction] it produces. Both [GamepadInput] and [KeyboardInput] resolve
+ * their (user-rebindable) binding maps through here, and the Settings screen uses
+ * the same ids as binding keys — so an action id means exactly one thing across
+ * inputs and there is no per-source duplication of the vocabulary.
+ *
+ * A few ids are deliberately NOT here because they mutate input-local state
+ * rather than emitting an action (e.g. the keyboard's "octaveUp"/"octaveDown",
+ * which nudge [KeyboardInput.baseOctave]); those handlers special-case them.
+ */
+object InputActions {
+ fun fromId(id: String): InputAction? = when (id) {
+ "playPause" -> InputAction.PlayPause
+ "stop" -> InputAction.Stop
+ "loop" -> InputAction.ToggleLoop
+ "up" -> InputAction.NavUp
+ "down" -> InputAction.NavDown
+ "left" -> InputAction.NavLeft
+ "right" -> InputAction.NavRight
+ "increment" -> InputAction.Increment(1)
+ "decrement" -> InputAction.Decrement(1)
+ "clear" -> InputAction.ClearCell
+ "noteOff" -> InputAction.NoteOffCell
+ "nextColumn" -> InputAction.NextColumn
+ "prevColumn" -> InputAction.PrevColumn
+ "nextTab" -> InputAction.NextTab
+ "prevTab" -> InputAction.PrevTab
+ else -> null
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt
index b6ebe85..7bf89db 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt
@@ -21,6 +21,10 @@ class InputRouter {
/** True while the settings screen is waiting to learn a new binding. */
@Volatile var learnMode: Boolean = false
+ /** Which device kind is currently being learned, so only that source captures
+ * the next control (a keyboard press doesn't hijack a gamepad-learn, etc.). */
+ @Volatile var learnSource: InputSource? = null
+
fun dispatch(action: InputAction) {
// tryEmit never suspends; if the buffer is somehow full we drop the
// oldest by design (a missed nav key is harmless, a stall is not).
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt
index b24deaa..342424a 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt
@@ -8,12 +8,24 @@ import com.reactorcoremeltdown.sizzletracker.model.Pitch
* [MainActivity] forwards every hardware key here from its
* `onKeyDown` / `onKeyUp` overrides.
*
- * The letter rows form a two-octave "tracker piano" in the classic layout so you
- * can play and record notes without a MIDI device:
- * lower octave : Z S X D C V G B H N J M (C .. B)
- * upper octave : Q 2 W 3 E R 5 T 6 Y 7 U (C .. B, one octave up)
+ * Two layers share the keyboard:
+ * - [bindings]: user-rebindable HOTKEYS (nav / transport / editing), editable
+ * from Settings → Keyboard Hotkeys. Resolved through [InputActions] plus a few
+ * keyboard-local ids ("octaveUp"/"octaveDown", and Shift-aware "nextColumn").
+ * - [pianoMap]: the fixed two-octave "tracker piano" so you can play/record
+ * notes without a MIDI device. This is the instrument layout, not a hotkey, so
+ * it is intentionally not rebindable:
+ * lower octave : Z S X D C V G B H N J M (C .. B)
+ * upper octave : Q 2 W 3 E R 5 T 6 Y 7 U (C .. B, one octave up)
+ *
+ * Hotkeys win over the piano when a key is bound to both, so the defaults avoid
+ * the piano letters.
*/
-class KeyboardInput(private val router: InputRouter) {
+class KeyboardInput(
+ private val router: InputRouter,
+ /** Android key-code -> action id, editable from Settings (Keyboard Hotkeys). */
+ var bindings: MutableMap = defaultBindings(),
+) {
/** The base octave the keyboard piano plays in; adjustable with +/- keys. */
var baseOctave: Int = 4
@@ -32,36 +44,67 @@ class KeyboardInput(private val router: InputRouter) {
/** @return true if we consumed the event. */
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
- // Ignore auto-repeat for one-shot actions but let piano notes retrigger.
- val action: InputAction? = when (keyCode) {
- KeyEvent.KEYCODE_DPAD_UP -> InputAction.NavUp
- KeyEvent.KEYCODE_DPAD_DOWN -> InputAction.NavDown
- KeyEvent.KEYCODE_DPAD_LEFT -> InputAction.NavLeft
- KeyEvent.KEYCODE_DPAD_RIGHT -> InputAction.NavRight
- KeyEvent.KEYCODE_TAB -> if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn
- KeyEvent.KEYCODE_SPACE -> InputAction.PlayPause
- KeyEvent.KEYCODE_ENTER -> InputAction.Stop
- KeyEvent.KEYCODE_L -> InputAction.ToggleLoop
- KeyEvent.KEYCODE_PAGE_UP -> InputAction.Increment(1)
- KeyEvent.KEYCODE_PAGE_DOWN -> InputAction.Decrement(1)
- KeyEvent.KEYCODE_DEL -> InputAction.ClearCell
- KeyEvent.KEYCODE_GRAVE -> InputAction.NoteOffCell
- KeyEvent.KEYCODE_MINUS -> { baseOctave = (baseOctave - 1).coerceIn(0, 8); null }
- KeyEvent.KEYCODE_EQUALS -> { baseOctave = (baseOctave + 1).coerceIn(0, 8); null }
- else -> pianoMap[keyCode]?.let { offset ->
- val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
- .coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
- InputAction.NoteOn(pitch, velocity = 100)
+ // Keyboard-hotkey learn: capture this key for the armed action, consume it.
+ if (router.learnMode && router.learnSource == InputSource.KEYBOARD) {
+ router.dispatch(InputAction.RawControl(InputSource.KEYBOARD, keyCode, 1f))
+ return true
+ }
+
+ bindings[keyCode]?.let { id -> return handleBoundAction(id, event) }
+
+ // Piano fallback for unbound keys.
+ pianoMap[keyCode]?.let { offset ->
+ router.dispatch(InputAction.NoteOn(pianoPitch(offset), velocity = 100))
+ return true
+ }
+ return false
+ }
+
+ /** Run a bound hotkey. Handles the keyboard-local ids (octave nudge, and the
+ * Shift-aware column move) and delegates the rest to [InputActions]. */
+ private fun handleBoundAction(id: String, event: KeyEvent): Boolean {
+ when (id) {
+ "octaveDown" -> baseOctave = (baseOctave - 1).coerceIn(0, 8)
+ "octaveUp" -> baseOctave = (baseOctave + 1).coerceIn(0, 8)
+ // The column key doubles as prev-column when Shift is held (classic Tab).
+ "nextColumn" -> router.dispatch(
+ if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn,
+ )
+ else -> {
+ val action = InputActions.fromId(id) ?: return false
+ router.dispatch(action)
}
}
- return action?.let { router.dispatch(it); true } ?: false
+ return true
}
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean {
val offset = pianoMap[keyCode] ?: return false
- val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
- .coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
- router.dispatch(InputAction.NoteOff(pitch))
+ router.dispatch(InputAction.NoteOff(pianoPitch(offset)))
return true
}
+
+ private fun pianoPitch(offset: Int): Int =
+ (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
+
+ companion object {
+ /** Default hotkey layout, matching the app's historical hard-coded keys.
+ * Kept off the piano letters so note entry is unaffected out of the box. */
+ fun defaultBindings(): MutableMap = mutableMapOf(
+ KeyEvent.KEYCODE_DPAD_UP to "up",
+ KeyEvent.KEYCODE_DPAD_DOWN to "down",
+ KeyEvent.KEYCODE_DPAD_LEFT to "left",
+ KeyEvent.KEYCODE_DPAD_RIGHT to "right",
+ KeyEvent.KEYCODE_TAB to "nextColumn",
+ KeyEvent.KEYCODE_SPACE to "playPause",
+ KeyEvent.KEYCODE_ENTER to "stop",
+ KeyEvent.KEYCODE_L to "loop",
+ KeyEvent.KEYCODE_PAGE_UP to "increment",
+ KeyEvent.KEYCODE_PAGE_DOWN to "decrement",
+ KeyEvent.KEYCODE_DEL to "clear",
+ KeyEvent.KEYCODE_GRAVE to "noteOff",
+ KeyEvent.KEYCODE_MINUS to "octaveDown",
+ KeyEvent.KEYCODE_EQUALS to "octaveUp",
+ )
+ }
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt
index 80782cd..f369c5f 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt
@@ -19,7 +19,7 @@ import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
* for the on-disk preset library.
*/
object PresetIo {
- private const val HEADER = "SIZZLE-PRESET 1"
+ const val HEADER = "SIZZLE-PRESET 1"
/** Serialize a slot's device + parameters to text. */
fun export(slot: ToolboxSlot): String {
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetLibrary.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetLibrary.kt
new file mode 100644
index 0000000..ff67753
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetLibrary.kt
@@ -0,0 +1,327 @@
+package com.reactorcoremeltdown.sizzletracker.io
+
+import com.reactorcoremeltdown.sizzletracker.audio.SampleStore
+import com.reactorcoremeltdown.sizzletracker.model.AmbiencePresets
+import com.reactorcoremeltdown.sizzletracker.model.Project
+import com.reactorcoremeltdown.sizzletracker.model.SamplerPads
+import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
+import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
+import java.io.File
+import java.io.InputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+import java.util.zip.ZipOutputStream
+
+/**
+ * The on-disk preset library, living in the app's scoped storage. Presets are the
+ * same plain, human- and machine-readable [PresetIo] text used for clipboard
+ * copy/paste, laid out one directory per device type so a person browsing the
+ * files finds them sorted by instrument/effect:
+ *
+ * /NES_SYNTH/Lead.szp
+ * /SAMPLER/Kick.szp + /SAMPLER/Kick.wav
+ * /DELAY/Slapback.szp
+ *
+ * The Sampler is special: its preset references the audio as a `sampleFile=` path
+ * (relative to the preset, or absolute), and the actual WAV is stored next to the
+ * preset. That makes a sampler preset self-contained on disk, shareable as a
+ * `.zip` bundle (preset + wav), and importable the same way.
+ */
+class PresetLibrary(private val baseDir: File) {
+
+ /** The directory holding presets for one device [type] (created on demand). */
+ fun typeDir(type: ToolboxType): File = File(baseDir, type.name).apply { mkdirs() }
+
+ /**
+ * Write the built-in factory presets to disk (as plain [PresetIo] `.szp` files)
+ * so they appear in the standard preset browser next to user presets. Call once
+ * at startup. Idempotent and non-destructive: a per-type `.seeded` marker means
+ * a preset the user later renames or deletes is NOT resurrected on the next
+ * launch. Currently seeds the Ambience reverb's 21 factory spaces.
+ */
+ fun seedFactoryPresets() {
+ val type = ToolboxType.AMBIENCE
+ val dir = typeDir(type)
+ val marker = File(dir, SEEDED_MARKER)
+ if (marker.exists()) return
+ AmbiencePresets.FACTORY.forEach { preset ->
+ val file = presetFile(type, preset.name)
+ if (file.exists()) return@forEach
+ val slot = ToolboxSlot(0).apply {
+ fill(type)
+ AmbiencePresets.apply(this, preset)
+ name = preset.name
+ }
+ file.writeText(PresetIo.export(slot))
+ }
+ marker.writeText("1")
+ }
+
+ /** Names (without extension) of the presets stored for [type], alphabetically. */
+ fun list(type: ToolboxType): List =
+ typeDir(type).listFiles { f -> f.isFile && f.name.endsWith(PRESET_EXT) }
+ ?.map { it.name.removeSuffix(PRESET_EXT) }
+ ?.sorted()
+ ?: emptyList()
+
+ fun presetFile(type: ToolboxType, name: String): File =
+ File(typeDir(type), sanitize(name) + PRESET_EXT)
+
+ /**
+ * Save [slot] as a named preset. For the Sampler the referenced sample is
+ * written next to the preset as a WAV and referenced by a relative path, so
+ * the library copy is self-contained. Returns the written preset file.
+ */
+ fun save(slot: ToolboxSlot, name: String): File {
+ val type = slot.type ?: error("cannot save an empty slot")
+ val dir = typeDir(type)
+ val safe = sanitize(name)
+ slot.name = name
+ val preset = File(dir, "$safe$PRESET_EXT")
+ when (type) {
+ ToolboxType.SAMPLER -> {
+ // Write each assigned pad's sample as its own WAV, referenced
+ // relatively. Record the reference on the slot too so a restored
+ // project can rehydrate the audio from the library at startup.
+ val padRefs = LinkedHashMap()
+ for (p in 0 until SamplerPads.COUNT) {
+ val sample = SampleStore.get(slot.string(SamplerPads.key(p)))
+ if (sample == null) { slot.values.remove(padFileKey(p)); continue }
+ val wavName = "${safe}_pad$p.wav"
+ File(dir, wavName).writeBytes(SampleStore.encodeWav(sample))
+ padRefs[p] = wavName
+ slot.set(padFileKey(p), wavName)
+ }
+ preset.writeText(exportSampler(slot, padRefs))
+ }
+ ToolboxType.SOUNDFONT -> {
+ // The extracted SF2/XI sample travels with the preset as a WAV.
+ val sample = SampleStore.get(slot.string("sfSample"))
+ val rel = if (sample != null) {
+ File(dir, "${safe}_sf.wav").writeBytes(SampleStore.encodeWav(sample))
+ slot.set("sfFile", "${safe}_sf.wav")
+ "${safe}_sf.wav"
+ } else {
+ slot.values.remove("sfFile")
+ null
+ }
+ preset.writeText(exportSoundFont(slot, rel))
+ }
+ else -> preset.writeText(PresetIo.export(slot))
+ }
+ return preset
+ }
+
+ /**
+ * Delete the named preset for [type]. For the Sampler its co-located WAV is
+ * removed too (only when referenced by a relative path inside our storage — an
+ * absolute path pointing elsewhere is left untouched). Returns true if the
+ * preset existed.
+ */
+ fun delete(type: ToolboxType, name: String): Boolean {
+ val dir = typeDir(type)
+ val base = sanitize(name)
+ val preset = File(dir, "$base$PRESET_EXT")
+ if (!preset.exists()) return false
+ // Remove any co-located sample WAVs this preset referenced (relative only).
+ sampleFileRefs(preset).forEach { rel ->
+ if (!File(rel).isAbsolute) File(dir, rel).takeIf { it.exists() }?.delete()
+ }
+ if (type == ToolboxType.SAMPLER) {
+ for (p in 0 until SamplerPads.COUNT) File(dir, "${base}_pad$p.wav").delete()
+ } else if (type == ToolboxType.SOUNDFONT) {
+ File(dir, "${base}_sf.wav").delete()
+ }
+ return preset.delete()
+ }
+
+ /**
+ * Reload the sample audio referenced by every Sampler / SoundFont slot in
+ * [project] back into the [SampleStore]. Call at startup: a restored project's
+ * autosave keeps the sample file references (`padNFile` / `sfFile`) but not the
+ * decoded PCM, so without this the pads of a selected preset would be silent
+ * until re-imported. Missing files are skipped.
+ */
+ fun rehydrate(project: Project) {
+ project.toolbox.forEach { slot ->
+ when (slot.type) {
+ ToolboxType.SAMPLER -> resolvePads(slot, typeDir(ToolboxType.SAMPLER))
+ ToolboxType.SOUNDFONT -> resolveSoundFont(slot, typeDir(ToolboxType.SOUNDFONT))
+ else -> {}
+ }
+ }
+ }
+
+ /** Load the named preset for [type] into [slot]. */
+ fun load(slot: ToolboxSlot, type: ToolboxType, name: String): Boolean {
+ val file = File(typeDir(type), sanitize(name) + PRESET_EXT)
+ if (!file.exists()) return false
+ if (!PresetIo.import(slot, file.readText())) return false
+ val dir = file.parentFile ?: typeDir(type)
+ when (slot.type) {
+ ToolboxType.SAMPLER -> resolvePads(slot, dir)
+ ToolboxType.SOUNDFONT -> resolveSoundFont(slot, dir)
+ else -> {}
+ }
+ return true
+ }
+
+ /**
+ * Import a preset supplied as raw text (e.g. picked from Files). Loads it into
+ * [slot] and persists a copy in the library so it appears in the browser.
+ * Sampler samples referenced by an absolute path are pulled in; relative refs
+ * need the zip bundle form (see [importZip]).
+ */
+ fun importText(slot: ToolboxSlot, text: String): Boolean {
+ if (!PresetIo.import(slot, text)) return false
+ val type = slot.type ?: return false
+ when (type) {
+ ToolboxType.SAMPLER -> resolvePads(slot, typeDir(type))
+ ToolboxType.SOUNDFONT -> resolveSoundFont(slot, typeDir(type))
+ else -> {}
+ }
+ save(slot, slot.name.ifBlank { type.displayName })
+ return true
+ }
+
+ /**
+ * Import a preset packaged as a `.zip` bundle (a `.szp` plus its sample WAVs) —
+ * used for the Sampler and SoundFont. Entries are extracted to a scratch dir,
+ * the contained preset is loaded and its samples resolved, then it is re-saved
+ * into the proper type folder so it joins the library self-contained.
+ */
+ fun importZip(slot: ToolboxSlot, input: InputStream): Boolean {
+ val scratch = File(baseDir, IMPORT_DIR).apply { mkdirs(); listFiles()?.forEach { it.delete() } }
+ var presetText: String? = null
+ ZipInputStream(input).use { zis ->
+ var entry: ZipEntry? = zis.nextEntry
+ while (entry != null) {
+ if (!entry.isDirectory) {
+ // Use only the base name to guard against zip-slip path traversal.
+ val target = File(scratch, File(entry.name).name)
+ target.outputStream().use { zis.copyTo(it) }
+ if (target.name.endsWith(PRESET_EXT)) presetText = target.readText()
+ }
+ zis.closeEntry()
+ entry = zis.nextEntry
+ }
+ }
+ val text = presetText ?: return false
+ if (!PresetIo.import(slot, text)) return false
+ when (slot.type) {
+ ToolboxType.SAMPLER -> resolvePads(slot, scratch)
+ ToolboxType.SOUNDFONT -> resolveSoundFont(slot, scratch)
+ else -> {}
+ }
+ // Persist a self-contained copy into the real library folder.
+ save(slot, slot.name.ifBlank { slot.type?.displayName ?: "preset" })
+ return true
+ }
+
+ /**
+ * Produce a shareable file for a stored preset: a self-contained `.zip`
+ * (preset + sample WAVs) for the Sampler / SoundFont, or the plain `.szp` for
+ * everything else. The result lives under [baseDir] so the app's FileProvider
+ * can expose it. Returns null if the preset does not exist.
+ */
+ fun bundleForShare(type: ToolboxType, name: String): File? {
+ val preset = File(typeDir(type), sanitize(name) + PRESET_EXT)
+ if (!preset.exists()) return null
+ if (type != ToolboxType.SAMPLER && type != ToolboxType.SOUNDFONT) return preset
+
+ val bundles = File(baseDir, BUNDLE_DIR).apply { mkdirs() }
+ val zip = File(bundles, sanitize(name) + ".zip")
+ ZipOutputStream(zip.outputStream()).use { zos ->
+ addEntry(zos, preset, preset.name)
+ sampleFileRefs(preset).forEach { rel ->
+ val wav = File(rel).let { if (it.isAbsolute) it else File(preset.parentFile, rel) }
+ if (wav.exists()) addEntry(zos, wav, wav.name)
+ }
+ }
+ return zip
+ }
+
+ // ---------------------------------------------------------------- internals
+
+ /** Key under which a pad's sample file path is written in the preset text. */
+ private fun padFileKey(pad: Int): String = "pad${pad}File"
+
+ /** Serialize a Sampler slot: global volume, plus each assigned pad's sample
+ * file ([padRefs]) and its slice trim. */
+ private fun exportSampler(slot: ToolboxSlot, padRefs: Map): String = buildString {
+ appendLine(PresetIo.HEADER)
+ appendLine("TYPE=${ToolboxType.SAMPLER.name}")
+ appendLine("NAME=${slot.name}")
+ appendLine("volume=${slot.string("volume", "0.8")}")
+ for ((pad, ref) in padRefs) {
+ appendLine("${padFileKey(pad)}=$ref")
+ appendLine("${SamplerPads.sliceStartKey(pad)}=${slot.string(SamplerPads.sliceStartKey(pad), "0.0")}")
+ appendLine("${SamplerPads.sliceEndKey(pad)}=${slot.string(SamplerPads.sliceEndKey(pad), "1.0")}")
+ }
+ }
+
+ /** Resolve every `padNFile` reference (relative to [presetDir], or absolute)
+ * into decoded samples in the [SampleStore], mapping each to its pad. */
+ private fun resolvePads(slot: ToolboxSlot, presetDir: File) {
+ for (pad in 0 until SamplerPads.COUNT) {
+ val rel = slot.string(padFileKey(pad))
+ if (rel.isBlank()) continue
+ resolveWav(rel, presetDir)?.let { slot.set(SamplerPads.key(pad), it) }
+ }
+ }
+
+ /** Serialize a SoundFont slot: volume, root note, and its extracted sample WAV. */
+ private fun exportSoundFont(slot: ToolboxSlot, sampleRel: String?): String = buildString {
+ appendLine(PresetIo.HEADER)
+ appendLine("TYPE=${ToolboxType.SOUNDFONT.name}")
+ appendLine("NAME=${slot.name}")
+ appendLine("volume=${slot.string("volume", "0.8")}")
+ appendLine("sfRoot=${slot.string("sfRoot", "60.0")}")
+ if (sampleRel != null) appendLine("sfFile=$sampleRel")
+ }
+
+ /** Resolve a SoundFont preset's `sfFile` into the [SampleStore] and point the
+ * slot's `sfSample` at it (its `sfRoot` was already restored by PresetIo). */
+ private fun resolveSoundFont(slot: ToolboxSlot, presetDir: File) {
+ val rel = slot.string("sfFile")
+ if (rel.isBlank()) return
+ resolveWav(rel, presetDir)?.let { slot.set("sfSample", it) }
+ }
+
+ /** Decode a referenced WAV (relative to [presetDir], or absolute) into the
+ * [SampleStore] and return its id, or null if missing/undecodable. */
+ private fun resolveWav(rel: String, presetDir: File): String? {
+ val wav = File(rel).let { if (it.isAbsolute) it else File(presetDir, rel) }
+ if (!wav.exists()) return null
+ val sample = SampleStore.decodeWav(wav.readBytes()) ?: return null
+ val id = "file:" + wav.absolutePath
+ SampleStore.put(id, sample)
+ return id
+ }
+
+ /** Every sample file path a preset references (any `…File=` line — covers both
+ * the Sampler's `padNFile` and the SoundFont's `sfFile`). */
+ private fun sampleFileRefs(preset: File): List =
+ preset.readLines().mapNotNull { line ->
+ val t = line.trim()
+ val eq = t.indexOf('=')
+ if (eq > 0 && t.substring(0, eq).endsWith("File")) t.substring(eq + 1).trim() else null
+ }
+
+ private fun addEntry(zos: ZipOutputStream, file: File, entryName: String) {
+ zos.putNextEntry(ZipEntry(entryName))
+ file.inputStream().use { it.copyTo(zos) }
+ zos.closeEntry()
+ }
+
+ /** Keep names safe as file names while staying human-readable. */
+ private fun sanitize(name: String): String =
+ name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "preset" }
+
+ private companion object {
+ const val PRESET_EXT = ".szp"
+ const val BUNDLE_DIR = ".bundles"
+ const val IMPORT_DIR = ".import"
+ const val SEEDED_MARKER = ".seeded"
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt
index 8a988f4..43822f9 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt
@@ -88,7 +88,7 @@ object ProjectIo {
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY)
project.toolbox.forEach { it.clearSlot() }
project.mixer.channels.forEach { ch ->
- ch.instrumentSlot = -1; ch.fxSlots.fill(-1); ch.midiChannel = 1
+ ch.instrumentSlot = -1; ch.fxSlots.fill(-1); ch.midiChannel = 0
ch.volume = 0.8f; ch.mute = false; ch.solo = false
}
var section = ""
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectStore.kt
new file mode 100644
index 0000000..ae2871d
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectStore.kt
@@ -0,0 +1,34 @@
+package com.reactorcoremeltdown.sizzletracker.io
+
+import com.reactorcoremeltdown.sizzletracker.model.Project
+import java.io.File
+
+/**
+ * Autosaves the whole [Project] to a single file in the app's scoped storage and
+ * restores it on the next launch — this is the crash-recovery / resume-where-you-
+ * left-off layer. The payload is the full-fidelity [ProjectIo] text, so every
+ * musical, mixer and toolbox parameter is preserved.
+ *
+ * Note: the Sampler's actual audio lives in the in-memory SampleStore, not in the
+ * project text; pad assignments and params are restored, but re-import a sample
+ * (or load a saved preset) to hear it again after a cold restart.
+ */
+class ProjectStore(private val file: File) {
+
+ /** Write the current project. Never throws — a failed autosave must not crash. */
+ fun save(project: Project) {
+ runCatching {
+ file.parentFile?.mkdirs()
+ file.writeText(ProjectIo.save(project))
+ }
+ }
+
+ /** Restore the autosaved project into [project] in place. Returns true on
+ * success; a missing or corrupt file leaves [project] untouched. */
+ fun restore(project: Project): Boolean =
+ runCatching {
+ if (!file.exists()) return false
+ ProjectIo.loadInto(project, file.readText())
+ true
+ }.getOrDefault(false)
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt
new file mode 100644
index 0000000..d89b454
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SettingsStore.kt
@@ -0,0 +1,69 @@
+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
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+/** The DataStore file backing app-level settings. */
+private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "app_settings")
+
+/** App-level settings that persist across restarts (theme, audio backend). */
+data class AppSettings(
+ val paletteName: String? = null,
+ val nativeEngine: Boolean = false,
+ /** Persisted SAF tree URI for master-recording WAV output, or null if unset. */
+ val recordDirUri: String? = null,
+ /** FX-tail length (bars) captured after the sequencer stops. */
+ val recordTailBars: Int = 2,
+)
+
+/**
+ * Persists app-level UI/audio settings (the colour theme and the low-latency
+ * engine toggle). Song/mixer/toolbox parameters live in the project and are
+ * handled by [ProjectStore]; input bindings by
+ * [com.reactorcoremeltdown.sizzletracker.input.BindingStore].
+ *
+ * Audio output/input *device* selection is intentionally not persisted: Android
+ * device ids are session-scoped and don't identify the same hardware next launch.
+ */
+class SettingsStore(private val context: Context) {
+
+ suspend fun load(): AppSettings {
+ val prefs = context.settingsDataStore.data.first()
+ return AppSettings(
+ paletteName = prefs[PALETTE],
+ nativeEngine = prefs[NATIVE_ENGINE] ?: false,
+ recordDirUri = prefs[RECORD_DIR],
+ recordTailBars = prefs[RECORD_TAIL] ?: 2,
+ )
+ }
+
+ suspend fun save(paletteName: String, nativeEngine: Boolean) {
+ context.settingsDataStore.edit { prefs ->
+ prefs[PALETTE] = paletteName
+ prefs[NATIVE_ENGINE] = nativeEngine
+ }
+ }
+
+ /** Persist the master-recording output folder and FX-tail length independently
+ * of the theme/engine settings (independent DataStore keys). */
+ suspend fun saveRecording(dirUri: String?, tailBars: Int) {
+ context.settingsDataStore.edit { prefs ->
+ if (dirUri != null) prefs[RECORD_DIR] = dirUri
+ prefs[RECORD_TAIL] = tailBars
+ }
+ }
+
+ private companion object {
+ val PALETTE = stringPreferencesKey("palette")
+ val NATIVE_ENGINE = booleanPreferencesKey("nativeEngine")
+ val RECORD_DIR = stringPreferencesKey("recordDirUri")
+ val RECORD_TAIL = intPreferencesKey("recordTailBars")
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SongLibrary.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SongLibrary.kt
new file mode 100644
index 0000000..2f36f89
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SongLibrary.kt
@@ -0,0 +1,62 @@
+package com.reactorcoremeltdown.sizzletracker.io
+
+import java.io.File
+
+/**
+ * The on-device project library. Songs are stored as **full-fidelity** projects
+ * ([ProjectIo] text: patterns, arrangement, mixer, FX and instrument settings) so
+ * saving/loading round-trips the whole song — unlike the desktop-compatible `.sng`
+ * ([SngFormat]), which carries only the musical data and is used purely for
+ * export/import interchange (see [writeExport]).
+ *
+ * /My Tune.szg ← full project (what Save/Load use)
+ * /.export/….sng ← transient .sng written for sharing
+ *
+ * The library only moves text around; serialization lives in [ProjectIo] /
+ * [SngFormat].
+ */
+class SongLibrary(private val baseDir: File) {
+
+ /** Names (without extension) of the stored full projects, alphabetically. */
+ fun list(): List =
+ baseDir.listFiles { f -> f.isFile && f.name.endsWith(EXT) }
+ ?.map { it.name.removeSuffix(EXT) }
+ ?.sorted()
+ ?: emptyList()
+
+ fun file(name: String): File = File(baseDir.apply { mkdirs() }, sanitize(name) + EXT)
+
+ /** Write [text] as the named full project. Returns the written file. */
+ fun save(name: String, text: String): File {
+ val f = file(name)
+ f.writeText(text)
+ return f
+ }
+
+ /** Read the named full project's text, or null if it doesn't exist. */
+ fun load(name: String): String? = file(name).takeIf { it.exists() }?.readText()
+
+ /** Delete the named full project. Returns true if it existed. */
+ fun delete(name: String): Boolean = file(name).let { if (it.exists()) it.delete() else false }
+
+ /**
+ * Write a one-off `.sng` export (desktop interchange) to a shareable sub-dir
+ * and return the file. Kept separate from the [EXT] library files so it never
+ * shows up in the project list.
+ */
+ fun writeExport(name: String, sngText: String): File {
+ val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
+ val f = File(dir, sanitize(name) + ".sng")
+ f.writeText(sngText)
+ return f
+ }
+
+ /** Keep names safe as file names while staying human-readable. */
+ private fun sanitize(name: String): String =
+ name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" }
+
+ private companion object {
+ const val EXT = ".szg" // full project (ProjectIo)
+ const val EXPORT_DIR = ".export"
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/AmbiencePresets.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/AmbiencePresets.kt
new file mode 100644
index 0000000..5d7ba45
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/AmbiencePresets.kt
@@ -0,0 +1,79 @@
+package com.reactorcoremeltdown.sizzletracker.model
+
+/**
+ * Factory data for the [ToolboxType.AMBIENCE] reverb, ported from OTODESK's
+ * "Ambience 1.0.1" (a 16-channel FDN reverb). Only the tunables and the 21 factory
+ * presets are carried over — the plugin's graphic EQ, output EQ/cut filters and
+ * visualizers are intentionally left out. Stereo width and the per-band Pro-mode
+ * controls don't apply to this app's mono per-channel FX chain and are omitted too.
+ *
+ * A preset simply pins the reverb's parameters to a modelled acoustic space; the
+ * [AmbienceEditor] writes these values straight into the slot's value map, after
+ * which the user can freely tweak any control.
+ */
+object AmbiencePresets {
+
+ /** The 7 reverb algorithms (topologies), index-aligned with the DSP. */
+ val ALGORITHMS = listOf("Room 1", "Room 2", "Hall 1", "Hall 2", "Plate", "Spring", "Goldfoil")
+
+ data class Preset(
+ val name: String,
+ val algo: Int,
+ val preDelay: Float,
+ val roomSize: Float,
+ val decay: Float,
+ val hfDamp: Float,
+ val lfAbsorb: Float,
+ val diffusion: Float,
+ val modAmount: Float,
+ val modRate: Float,
+ val erLevel: Float,
+ val saturation: Float,
+ val wetDb: Float,
+ val dryDb: Float,
+ )
+
+ /** The 21 factory spaces, values transcribed from the original `.ambpreset` files. */
+ val FACTORY: List = listOf(
+ Preset("Abbey Road", 0, 10f, 0.75f, 0.45f, 0f, 0f, 0.55f, 0.15f, 0.30f, 0.70f, 0.08f, -6f, 0f),
+ Preset("Abbey Road 2", 1, 10f, 1.60f, 1.80f, 0f, 0f, 0.70f, 0.25f, 0.40f, 0.60f, 0.08f, -4f, 0f),
+ Preset("Capitol Studio A", 1, 10f, 1.35f, 1.40f, 0f, 0f, 0.65f, 0.20f, 0.35f, 0.65f, 0.10f, -4f, 0f),
+ Preset("Skywalker Sound", 1, 10f, 1.70f, 2.00f, 0f, 0f, 0.75f, 0.30f, 0.45f, 0.55f, 0.06f, -4f, 0f),
+ Preset("Carnegie Hall", 2, 10f, 1.50f, 1.70f, 0f, 0f, 0.72f, 0.20f, 0.30f, 0.55f, 0.08f, -4f, 0f),
+ Preset("Tokyo Opera City", 2, 10f, 1.65f, 2.00f, 0f, 0f, 0.75f, 0.25f, 0.35f, 0.50f, 0.06f, -4f, 0f),
+ Preset("Berlin Konzerthaus", 2, 10f, 1.60f, 2.00f, 0f, 0f, 0.73f, 0.22f, 0.32f, 0.52f, 0.07f, 0f, 0f),
+ Preset("Vienna Musikverein", 3, 10f, 1.80f, 2.05f, 0f, 0f, 0.80f, 0.25f, 0.30f, 0.50f, 0.07f, -4f, 0f),
+ Preset("Boston Symphony", 3, 10f, 1.70f, 1.85f, 0f, 0f, 0.78f, 0.22f, 0.28f, 0.52f, 0.06f, -4f, 0f),
+ Preset("Concertgebouw", 3, 10f, 1.85f, 2.05f, 0f, 0f, 0.82f, 0.28f, 0.32f, 0.48f, 0.06f, -4f, 0f),
+ Preset("EMT140 Vocal", 4, 10f, 1.00f, 1.80f, 0f, 0f, 0.85f, 0.30f, 0.50f, 0.40f, 0.12f, -4f, 0f),
+ Preset("EMT140 Snare", 4, 10f, 0.80f, 1.00f, 0f, 0f, 0.80f, 0.20f, 0.45f, 0.35f, 0.15f, -4f, 0f),
+ Preset("Dark Plate", 4, 10f, 1.20f, 2.50f, 0f, 0f, 0.88f, 0.35f, 0.55f, 0.10f, 0.10f, -4f, 0f),
+ Preset("Surf Guitar", 5, 10f, 0.70f, 1.50f, 0f, 0f, 0.45f, 0.50f, 0.70f, 0.45f, 0.20f, -4f, 0f),
+ Preset("Vintage Studio", 4, 10f, 0.85f, 2.20f, 0f, 0f, 0.50f, 0.55f, 0.65f, 0.40f, 0.35f, -4f, 0f),
+ Preset("Deep Tank", 5, 10f, 1.10f, 3.50f, 0f, 0f, 0.55f, 0.60f, 0.60f, 0.38f, 0.45f, -4f, 0f),
+ Preset("Gothic Cathedral", 6, 10f, 2.00f, 8.00f, 0f, 0f, 0.90f, 0.40f, 0.25f, 0.35f, 0.05f, -4f, 0f),
+ Preset("Stone Chamber", 6, 10f, 1.80f, 4.00f, 0f, 0f, 0.85f, 0.45f, 0.30f, 0.40f, 0.20f, -4f, 0f),
+ Preset("Infinite Space", 6, 10f, 2.00f, 12.00f, 0f, 0f, 0.95f, 0.65f, 0.28f, 0.25f, 0.10f, -4f, 0f),
+ Preset("Drums in a Box", 0, 10f, 0.55f, 0.30f, 0f, 0f, 0.40f, 0.10f, 0.20f, 0.80f, 0.05f, -4f, 0f),
+ Preset("Tracking Room", 0, 10f, 0.90f, 0.60f, 0f, 0f, 0.65f, 0.20f, 0.35f, 0.65f, 0.10f, -4f, 0f),
+ )
+
+ val NAMES: List get() = FACTORY.map { it.name }
+
+ /** Write [preset]'s values into [slot]'s parameter map. */
+ fun apply(slot: ToolboxSlot, preset: Preset) {
+ slot.set("algo", ALGORITHMS[preset.algo.coerceIn(0, ALGORITHMS.lastIndex)])
+ slot.set("predelay", preset.preDelay)
+ slot.set("roomsize", preset.roomSize)
+ slot.set("decay", preset.decay)
+ slot.set("hfdamp", preset.hfDamp)
+ slot.set("lfabsorb", preset.lfAbsorb)
+ slot.set("diffusion", preset.diffusion)
+ slot.set("modamt", preset.modAmount)
+ slot.set("modrate", preset.modRate)
+ slot.set("erlevel", preset.erLevel)
+ slot.set("saturation", preset.saturation)
+ slot.set("wet", preset.wetDb)
+ slot.set("dry", preset.dryDb)
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt
index 851fb61..088405e 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt
@@ -22,6 +22,20 @@ class Arrangement {
fun patternAt(lane: Int, beat: Int): Int =
if (beat in 0 until MAX_BEATS) slots[lane][beat] else EMPTY
+ /**
+ * The highest beat index holding any non-empty cell across all lanes, or -1 if
+ * the arrangement is empty. Playback uses this to stop after the last filled
+ * block instead of running out to [lengthBeats] through trailing silence.
+ */
+ fun lastFilledBeat(): Int {
+ for (beat in MAX_BEATS - 1 downTo 0) {
+ for (lane in 0 until LANE_COUNT) {
+ if (slots[lane][beat] != EMPTY) return beat
+ }
+ }
+ return -1
+ }
+
fun set(lane: Int, beat: Int, patternId: Int) {
if (lane in 0 until LANE_COUNT && beat in 0 until MAX_BEATS) {
slots[lane][beat] = patternId
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt
index f420d24..91b7126 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt
@@ -6,7 +6,10 @@ package com.reactorcoremeltdown.sizzletracker.model
* by their slot index in the [Toolbox] (tab 3). A value of -1 means "no device".
*/
class Mixer {
- val channels: List = List(CHANNEL_COUNT) { MixerChannel(it) }
+ // Buses start on the empty MIDI channel 0 (nothing routed) for a clean-slate
+ // new project; the user assigns each bus a MIDI channel (and instrument) as
+ // they build the song. Notes on channel 0 sound only through a channel-0 bus.
+ val channels: List = List(CHANNEL_COUNT) { MixerChannel(it, midiChannel = 0) }
/** True if ANY channel is soloed — used to mute the non-soloed ones. */
fun anySolo(): Boolean = channels.any { it.solo }
@@ -15,6 +18,8 @@ class Mixer {
const val CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep
const val FX_SLOTS = 4
const val NO_DEVICE = -1
+ /** Default linear fader level; the fader also snaps back to this. */
+ const val DEFAULT_VOLUME = 0.8f
}
}
@@ -27,7 +32,7 @@ class MixerChannel(
/** MIDI channel (1..16) this mixer channel listens/sends on. */
var midiChannel: Int = 1,
/** Linear 0..1 fader. */
- var volume: Float = 0.8f,
+ var volume: Float = Mixer.DEFAULT_VOLUME,
var mute: Boolean = false,
var solo: Boolean = false,
) {
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt
index 12fdd9a..d10f1e9 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt
@@ -23,7 +23,8 @@ class Cell(
fun clear() {
note = EMPTY
velocity = MAX_VELOCITY
- channel = 1
+ // channel is preserved: an empty cell never sounds, and keeping it means a
+ // note later entered here still routes to the track's default bus.
}
/** A detached copy (for the clipboard) — never aliases the source cell. */
@@ -61,9 +62,11 @@ class Pattern(
var name: String = "PTN",
var length: Int = 16,
) {
- /** tracks[t][line]. Always [TRACK_COUNT] tracks; each list has [length] cells. */
+ /** tracks[t][line]. Always [TRACK_COUNT] tracks; each list has [length] cells.
+ * New cells default to the empty channel 0 (routes to no bus until the user
+ * assigns one); routing is by channel, not track — see AudioEngine.triggerCell. */
val tracks: Array> =
- Array(TRACK_COUNT) { MutableList(length) { Cell() } }
+ Array(TRACK_COUNT) { MutableList(length) { Cell(channel = 0) } }
fun cell(track: Int, line: Int): Cell = tracks[track][line]
@@ -76,7 +79,7 @@ class Pattern(
for (t in 0 until TRACK_COUNT) {
val col = tracks[t]
when {
- newLength > col.size -> repeat(newLength - col.size) { col.add(Cell()) }
+ newLength > col.size -> repeat(newLength - col.size) { col.add(Cell(channel = 0)) }
newLength < col.size -> while (col.size > newLength) col.removeAt(col.size - 1)
}
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt
index 6a57b67..dff4cf8 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt
@@ -28,6 +28,8 @@ data class ParamSpec(
val min: Float = 0f,
val max: Float = 1f,
val choices: List = emptyList(),
+ /** When true the slider snaps to whole integers (e.g. bit depth, semitones). */
+ val isInt: Boolean = false,
) {
val isEnum: Boolean get() = choices.isNotEmpty()
}
@@ -51,18 +53,22 @@ enum class ToolboxType(
SAMPLER(
ToolboxKind.INSTRUMENT, "Sampler",
listOf(
- ParamSpec("startOctave", "Start Oct", 3f, 0f, 8f),
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
- ParamSpec("sliceStart", "Slice Start", 0f, 0f, 1f),
- ParamSpec("sliceEnd", "Slice End", 1f, 0f, 1f),
- // "samplePath" is stored as a string param at runtime (see ToolboxSlot).
+ // A 16-pad bank: each pad maps to a consecutive note from C2 (see
+ // [SamplerPads]) and stores its own sample id + slice as string params
+ // ("pad0", "pad0S", "pad0E", …). Edited by ui/toolbox/SamplerEditor.
),
),
SOUNDFONT(
ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
listOf(
- ParamSpec("program", "Program", 0f, 0f, 127f),
+ ParamSpec("program", "Program", 0f, 0f, 127f, isInt = true),
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
+ // Volume ADSR envelope (seconds, sustain 0..1).
+ ParamSpec("attack", "Attack", 0.005f, 0f, 2f),
+ ParamSpec("decay", "Decay", 0.10f, 0f, 2f),
+ ParamSpec("sustain", "Sustain", 1f, 0f, 1f),
+ ParamSpec("release", "Release", 0.20f, 0f, 3f),
),
),
@@ -71,13 +77,13 @@ enum class ToolboxType(
ToolboxKind.EFFECT, "MIDI Arpeggiator",
listOf(
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
- ParamSpec("octaves", "Octaves", 1f, 1f, 4f),
- ParamSpec("division", "Division", 3f, 0f, 6f), // index into TimeDivision
+ ParamSpec("octaves", "Octaves", 1f, 1f, 4f, isInt = true),
+ ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true), // index into TimeDivision
),
),
TRANSPOSER(
ToolboxKind.EFFECT, "MIDI Transposer",
- listOf(ParamSpec("semitones", "Semitones", 0f, -24f, 24f)),
+ listOf(ParamSpec("semitones", "Semitones", 0f, -24f, 24f, isInt = true)),
),
LFO(
ToolboxKind.EFFECT, "MIDI LFO",
@@ -85,25 +91,29 @@ enum class ToolboxType(
ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
ParamSpec("phase", "Phase", 0f, 0f, 1f),
ParamSpec("depth", "Depth", 0.5f, 0f, 1f),
- ParamSpec("division", "Division", 3f, 0f, 6f),
+ ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true),
// "target" (which parameter to modulate) is stored as a string param.
),
),
DELAY(
ToolboxKind.EFFECT, "Tape Delay",
- listOf(
- ParamSpec("division", "Division", 3f, 0f, 6f),
- ParamSpec("feedback", "Feedback", 0.4f, 0f, 0.95f),
- ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f),
- ParamSpec("heads", "Tape Heads", 4f, 1f, 4f),
- ),
- ),
- REVERB(
- ToolboxKind.EFFECT, "Digital Reverb",
- listOf(
- ParamSpec("amount", "Amount", 0.3f, 0f, 1f),
- ParamSpec("tone", "Tone", 0.5f, 0f, 1f),
- ),
+ // Four independent tape heads, each with its own tempo-synced division and an
+ // on/off flag, plus the common feedback / dry-wet / tape-strength controls.
+ // Feedback tops out below 1 (no runaway). Edited by ui/toolbox/DelayEditor.
+ buildList {
+ add(ParamSpec("feedback", "Feedback", 0.4f, 0f, DelayDivisions.MAX_FEEDBACK))
+ add(ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f))
+ add(ParamSpec("tape", "Tape Strength", 0.3f, 0f, 1f))
+ val headDefaults = intArrayOf(
+ DelayDivisions.indexOf("1/4"), DelayDivisions.indexOf("1/8"),
+ DelayDivisions.indexOf("1/4."), DelayDivisions.indexOf("1/2"),
+ )
+ for (h in 0 until DelayDivisions.HEADS) {
+ 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()))
+ }
+ },
),
FILTER(
ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
@@ -111,6 +121,16 @@ enum class ToolboxType(
ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
ParamSpec("resonance", "Resonance", 0.2f, 0f, 1f),
+ // Analog-style saturation added in the filter path.
+ ParamSpec("warmth", "Warmth", 0f, 0f, 1f),
+ // Cutoff envelope: how far the ADSR opens the filter above the base
+ // cutoff. 0 = envelope off (the filter behaves as a plain filter). The
+ // envelope is (re)triggered by signal activity arriving on the channel.
+ ParamSpec("envamt", "Env Amount", 0f, 0f, 1f),
+ ParamSpec("attack", "Attack", 0.005f, 0f, 2f),
+ ParamSpec("decay", "Decay", 0.10f, 0f, 2f),
+ ParamSpec("sustain", "Sustain", 1f, 0f, 1f),
+ ParamSpec("release", "Release", 0.20f, 0f, 3f),
),
),
EQ10(
@@ -121,10 +141,35 @@ enum class ToolboxType(
BITCRUSHER(
ToolboxKind.EFFECT, "Bitcrusher",
listOf(
- ParamSpec("bits", "Bits", 8f, 1f, 16f),
- ParamSpec("downsample", "Downsample", 1f, 1f, 32f),
+ ParamSpec("bits", "Bits", 8f, 1f, 16f, isInt = true),
+ ParamSpec("downsample", "Downsample", 1f, 1f, 32f, isInt = true),
ParamSpec("drive", "Drive", 0.2f, 0f, 1f),
),
+ ),
+ AMBIENCE(
+ ToolboxKind.EFFECT, "Ambience Reverb",
+ // A mono port of the Ambience 16-channel FDN reverb (OTODESK): 7 algorithms,
+ // tempo-independent tunables and 21 factory spaces (seeded into the preset
+ // library). Rendered by audio/AmbienceReverb.kt.
+ listOf(
+ ParamSpec("algo", "Algorithm", 0f, 0f, 6f, AmbiencePresets.ALGORITHMS),
+ ParamSpec("predelay", "Pre-Delay", 10f, 0f, 500f),
+ ParamSpec("roomsize", "Room Size", 1f, 0.3f, 2f),
+ ParamSpec("decay", "Decay", 1.5f, 0.1f, 20f),
+ ParamSpec("hfdamp", "HF Damp", 0f, 0f, 1f),
+ ParamSpec("lfabsorb", "LF Absorb", 0f, 0f, 1f),
+ ParamSpec("diffusion", "Diffusion", 0.7f, 0f, 1f),
+ ParamSpec("modamt", "Mod Amount", 0.25f, 0f, 1f),
+ ParamSpec("modrate", "Mod Rate", 0.5f, 0.05f, 2f),
+ ParamSpec("erlevel", "ER Level", 0.6f, 0f, 1f),
+ ParamSpec("saturation", "Saturation", 0f, 0f, 1f),
+ ParamSpec("wet", "Wet (dB)", -4f, -60f, 0f),
+ ParamSpec("dry", "Dry (dB)", 0f, -60f, 0f),
+ ParamSpec("duckamt", "Ducking (dB)", 0f, 0f, 20f),
+ ParamSpec("duckthresh", "Duck Thresh (dB)", -20f, -60f, 0f),
+ ParamSpec("duckattack", "Duck Attack (ms)", 10f, 0.5f, 100f),
+ ParamSpec("duckrelease", "Duck Release (ms)", 200f, 10f, 2000f),
+ ),
);
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
@@ -137,7 +182,71 @@ enum class ToolboxType(
}.toMutableMap()
}
-/** Tempo-synced note lengths used by arpeggiator / LFO / delay "division" params. */
+/**
+ * The Sampler's 16-pad bank. Pads map to consecutive MIDI notes starting at C2
+ * ([BASE_NOTE] = 36), so pad 0 = C2 … pad 15 = D#3. Each pad stores, in the slot's
+ * value map, a sample id under [key] and its trim under [sliceStartKey]/[sliceEndKey].
+ * Triggering a pad's note plays that sample at its natural pitch (one-shot).
+ */
+object SamplerPads {
+ const val COUNT = 16
+ const val BASE_NOTE = 36 // C2 (this app uses MIDI 60 == C4)
+ const val DEFAULT_VOLUME = 0.8f
+
+ fun key(pad: Int): String = "pad$pad"
+ fun sliceStartKey(pad: Int): String = "pad${pad}S"
+ fun sliceEndKey(pad: Int): String = "pad${pad}E"
+ fun volumeKey(pad: Int): String = "pad${pad}V"
+
+ /** The MIDI note a pad triggers, and the inverse. */
+ fun noteFor(pad: Int): Int = BASE_NOTE + pad
+ fun padFor(note: Int): Int = note - BASE_NOTE
+}
+
+/**
+ * The tempo-synced note values a Tape Delay head can be set to: straight, dotted
+ * (·, ×1.5) and triplet (T, ×2/3) subdivisions from 1/1 down to 1/16, ordered
+ * longest → shortest. Each head stores its choice as an index into [entries], which
+ * serializes as a plain integer. [beatFraction] is measured in quarter-note beats
+ * (1/4 == 1.0 beat), matching [TimeDivision].
+ */
+object DelayDivisions {
+ const val HEADS = 4
+ const val MAX_FEEDBACK = 0.9f // hard ceiling < 1 so feedback can never run away
+
+ /** (label, beat-fraction), longest first. */
+ val entries: List> = listOf(
+ "1/1" to 4.0,
+ "1/2." to 3.0,
+ "1/2" to 2.0,
+ "1/4." to 1.5,
+ "1/2T" to 4.0 / 3.0,
+ "1/4" to 1.0,
+ "1/8." to 0.75,
+ "1/4T" to 2.0 / 3.0,
+ "1/8" to 0.5,
+ "1/16." to 0.375,
+ "1/8T" to 1.0 / 3.0,
+ "1/16" to 0.25,
+ "1/16T" to 1.0 / 6.0,
+ )
+
+ val size: Int get() = entries.size
+ fun label(i: Int): String = entries[i.coerceIn(0, entries.lastIndex)].first
+ fun beatFraction(i: Int): Double = entries[i.coerceIn(0, entries.lastIndex)].second
+
+ /** Plain loop (no lambda/iterator) — this is read on the audio thread. */
+ fun indexOf(label: String): Int {
+ for (i in entries.indices) if (entries[i].first == label) return i
+ return 0
+ }
+
+ /** Default division index (a quarter note). A cached `val`, not a getter, so
+ * reading it on the audio thread (as a fallback arg) allocates nothing. */
+ val DEFAULT: Int = indexOf("1/4")
+}
+
+/** Tempo-synced note lengths used by arpeggiator / LFO "division" params. */
enum class TimeDivision(val label: String, val beatFraction: Double) {
WHOLE("1/1", 4.0), HALF("1/2", 2.0), QUARTER("1/4", 1.0),
EIGHTH("1/8", 0.5), SIXTEENTH("1/16", 0.25),
@@ -179,9 +288,24 @@ class ToolboxSlot(
values.clear()
}
+ /**
+ * Bumped on every parameter write. The audio engine reads it to skip re-parsing
+ * a slot's params (and re-deriving effect state) every block — string parsing on
+ * the audio thread allocates and, unchecked, drove GC-pause dropouts. Volatile so
+ * a UI-thread edit is seen by the audio thread. */
+ @Volatile
+ var version: Int = 0
+ private set
+
// Typed accessors used by the audio engine and UI.
- fun float(key: String, fallback: Float = 0f): Float = values[key]?.toFloatOrNull() ?: fallback
+ // NOTE: `float` runs on the audio thread; `toFloatOrNull()` boxes a Float, so parse
+ // to a primitive via try/catch instead (the happy path doesn't box; stored values
+ // are valid). Callers should still avoid calling it every block — see [version].
+ fun float(key: String, fallback: Float = 0f): Float {
+ val s = values[key] ?: return fallback
+ return try { s.toFloat() } catch (e: NumberFormatException) { fallback }
+ }
fun string(key: String, fallback: String = ""): String = values[key] ?: fallback
- fun set(key: String, value: Float) { values[key] = value.toString() }
- fun set(key: String, value: String) { values[key] = value }
+ fun set(key: String, value: Float) { values[key] = value.toString(); version++ }
+ fun set(key: String, value: String) { values[key] = value; version++ }
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt
index 1d8c196..8843329 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt
@@ -1,5 +1,6 @@
package com.reactorcoremeltdown.sizzletracker.ui
+import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -42,6 +43,11 @@ private val TABS = listOf(
@Composable
fun SizzleApp(vm: AppViewModel) {
val c = LocalRetro.current
+ // System back / edge-swipe steps to the previous tab. Deeper handlers (a
+ // Toolbox editor, a Settings subsection) are composed inside the active screen
+ // and intercept first. On the first tab this is disabled, so back falls through
+ // to the system default (leaving the app).
+ BackHandler(enabled = vm.selectedTab > 0) { vm.selectTab(vm.selectedTab - 1) }
Column(Modifier.fillMaxSize().background(c.background)) {
// ----- Active screen -----
Box(Modifier.weight(1f).fillMaxWidth()) {
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt
index c9e17be..a25de85 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt
@@ -2,6 +2,7 @@ package com.reactorcoremeltdown.sizzletracker.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
@@ -34,13 +35,21 @@ class AppViewModel(
private val router: InputRouter,
private val gamepadInput: com.reactorcoremeltdown.sizzletracker.input.GamepadInput,
private val midiInput: com.reactorcoremeltdown.sizzletracker.input.MidiInput,
+ private val keyboardInput: com.reactorcoremeltdown.sizzletracker.input.KeyboardInput,
+ private val bindingStore: com.reactorcoremeltdown.sizzletracker.input.BindingStore,
+ private val presetLibrary: com.reactorcoremeltdown.sizzletracker.io.PresetLibrary,
+ private val songLibrary: com.reactorcoremeltdown.sizzletracker.io.SongLibrary,
+ private val settingsStore: com.reactorcoremeltdown.sizzletracker.io.SettingsStore,
+ private val appContext: android.content.Context,
) : ViewModel() {
// ----- Observable UI state -----
var selectedTab by mutableIntStateOf(0); private set
var revision by mutableIntStateOf(0); private set // bump to force grid redraw
- /** Active colour theme; read by [MainActivity] to drive SizzleTheme. */
+ /** Active colour theme; read by [MainActivity] to drive SizzleTheme. Set via
+ * [selectPalette] so the choice is persisted. */
var palette by mutableStateOf(com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.AMBER)
+ private set
// Tracker cursor position.
var cursorTrack by mutableIntStateOf(0); private set
@@ -63,7 +72,27 @@ class AppViewModel(
* edits on an empty tick resume from these so entry continues where you left
* off instead of jumping to a fixed default. */
var lastNote by mutableIntStateOf(Pitch.MIDDLE_C); private set
- var lastChannel by mutableIntStateOf(1); private set
+ var lastChannel by mutableIntStateOf(0); private set
+
+ // ----- Shared keyboard / punch-in state -----
+ // These live on the ViewModel (not per-screen) so the tracker punch-in keyboard
+ // and the toolbox audition keyboard share them and they survive tab switches.
+ /** Rows the cursor jumps after a punch-in and per up/down nav step (1/2/4/8). */
+ var skipStep by mutableIntStateOf(1); private set
+ /** Whether the tracker's lower half shows the punch-in keyboard (vs the arranger). */
+ var punchInMode by mutableStateOf(false); private set
+ /** Target MIDI channel for keyboard notes; 0 == the empty channel. */
+ var kbdChannel by mutableIntStateOf(0); private set
+ /** Base octave (the lower of the two shown octaves). */
+ var kbdOctave by mutableIntStateOf(3); private set
+ /** Velocity for keyboard notes (0..127). */
+ var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set
+
+ fun updateSkipStep(n: Int) { skipStep = n.coerceIn(1, 8) }
+ fun togglePunchIn() { punchInMode = !punchInMode }
+ fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); touched() }
+ fun updateKbdOctave(o: Int) { kbdOctave = o.coerceIn(0, 8); touched() }
+ fun updateKbdVelocity(v: Int) { kbdVelocity = v.coerceIn(0, Cell.MAX_VELOCITY); touched() }
/** Live transport snapshot from the audio thread (drives the playhead). */
val transport get() = engine.transport
@@ -100,9 +129,70 @@ class AppViewModel(
cursorLine = (cursorLine + dLine).coerceIn(0, p.length - 1)
}
+ /**
+ * Move the cursor vertically by [dLine] rows, cycling past the top/bottom of the
+ * pattern (wrap-around) rather than clamping. Used by the external controllers'
+ * up/down so repeated presses keep scrolling — combined with the skip step this
+ * jumps by N rows and wraps at the pattern ends.
+ */
+ fun moveCursorVerticalWrap(dLine: Int) {
+ val len = project.activePattern().length
+ if (len <= 0) return
+ cursorLine = Math.floorMod(cursorLine + dLine, len)
+ }
+
fun nextColumn() { cursorColumn = nextOf(cursorColumn, +1) }
fun prevColumn() { cursorColumn = nextOf(cursorColumn, -1) }
+ /**
+ * Move the cursor by one column across the whole row — the three columns
+ * (note/velocity/channel) of every track laid end to end — so stepping right
+ * off a track's last column lands on the next track's first column. Used by the
+ * external controllers' left/right so they traverse columns, not just tracks.
+ */
+ fun moveColumnFlat(dir: Int) {
+ val cols = CellColumn.entries.size
+ val flat = (cursorTrack * cols + cursorColumn.ordinal + dir)
+ .coerceIn(0, Pattern.TRACK_COUNT * cols - 1)
+ cursorTrack = flat / cols
+ cursorColumn = CellColumn.entries[flat % cols]
+ }
+
+ /** Punch a note into the focused cell (velocity/channel from the keyboard) and
+ * advance the cursor by [skipStep] rows — the touch-keyboard entry path. */
+ fun punchNote(midi: Int) {
+ val cell = focusedCell()
+ cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
+ cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY)
+ cell.channel = kbdChannel
+ lastNote = cell.note; lastChannel = kbdChannel
+ moveCursor(0, skipStep)
+ touched()
+ }
+
+ // ------------------------------------------------------------ held notes
+ /**
+ * Notes currently held down, from ANY source (on-screen keyboards, external
+ * keyboard/gamepad, MIDI in). Keyed by MIDI note → active-press count so the
+ * same note held by two sources at once (e.g. touch + MIDI) doesn't clear the
+ * highlight until both release. Backed by a snapshot map, so every on-screen
+ * keyboard widget that reads [isNoteHeld] lights its keys up while held.
+ */
+ private val heldNotes = mutableStateMapOf()
+
+ /** True while [midi] is held down by at least one input source. */
+ fun isNoteHeld(midi: Int): Boolean = (heldNotes[midi] ?: 0) > 0
+
+ private fun markNoteOn(midi: Int) { heldNotes[midi] = (heldNotes[midi] ?: 0) + 1 }
+ private fun markNoteOff(midi: Int) {
+ val n = (heldNotes[midi] ?: 0) - 1
+ if (n <= 0) heldNotes.remove(midi) else heldNotes[midi] = n
+ }
+
+ /** Audition a note on the keyboard's channel/velocity (mix-bus playback). */
+ fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
+ fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
+
/**
* The core value-editing operation, shared by drag gestures and +/- input.
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
@@ -217,6 +307,84 @@ class AppViewModel(
fun stop() = engine.stop()
+ /** MIDI panic: silence all sounding/stuck notes immediately. */
+ fun midiPanic() { heldNotes.clear(); engine.panic() }
+
+ // ------------------------------------------------ master-bus recording
+ /** Whether recording is armed (starts capturing when playback next starts). */
+ var recordArmed by mutableStateOf(false); private set
+ /** FX-tail length captured after the sequencer stops, in bars (0..4). */
+ var recordTailBars by mutableIntStateOf(2); private set
+ /** Chosen output folder as a persisted SAF tree URI string, or null. */
+ var recordDirUri by mutableStateOf(null); private set
+ /** Human-readable name of the chosen output folder (for the toolbar). */
+ var recordDirName by mutableStateOf(null); private set
+ /** True while a take is actively capturing (arm has fired on play). */
+ var masterRecording by mutableStateOf(false); private set
+ /** Filename of the most recently written take (a small "saved …" status). */
+ var lastRecordingName by mutableStateOf(null); private set
+
+ /** Toggle record-arm. Requires a folder first — see [MixerScreen] which opens the
+ * picker when none is set, so this is only reached once a destination exists. */
+ fun toggleRecordArm() {
+ recordArmed = !recordArmed
+ engine.setRecordingArm(recordArmed, recordTailBars)
+ }
+
+ fun updateRecordTailBars(n: Int) {
+ recordTailBars = n.coerceIn(0, 4)
+ engine.setRecordTailBars(recordTailBars)
+ persistRecordingSettings()
+ }
+
+ /** Adopt a folder picked via the Storage Access Framework as the WAV destination,
+ * taking a persistable permission so it survives an app restart. */
+ fun setRecordDir(uri: android.net.Uri) {
+ runCatching {
+ appContext.contentResolver.takePersistableUriPermission(
+ uri,
+ android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
+ )
+ }
+ recordDirUri = uri.toString()
+ recordDirName = displayNameForTree(uri)
+ persistRecordingSettings()
+ }
+
+ private fun persistRecordingSettings() {
+ viewModelScope.launch { settingsStore.saveRecording(recordDirUri, recordTailBars) }
+ }
+
+ /** Copy a finalized temp WAV into the chosen SAF folder with a date-time name.
+ * Runs on the recorder's writer thread; returns the written file name or null. */
+ private fun saveRecordingToDir(temp: java.io.File, startMillis: Long): String? {
+ val dirUriStr = recordDirUri
+ if (dirUriStr == null) { temp.delete(); return null }
+ return runCatching {
+ val treeUri = android.net.Uri.parse(dirUriStr)
+ val dirDocUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
+ treeUri, android.provider.DocumentsContract.getTreeDocumentId(treeUri),
+ )
+ val name = "sizzle-" + java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
+ .format(java.util.Date(startMillis)) + ".wav"
+ val fileUri = android.provider.DocumentsContract.createDocument(
+ appContext.contentResolver, dirDocUri, "audio/wav", name,
+ ) ?: return@runCatching null
+ appContext.contentResolver.openOutputStream(fileUri)?.use { out ->
+ temp.inputStream().use { it.copyTo(out) }
+ }
+ name
+ }.getOrNull().also { temp.delete() }
+ }
+
+ /** Best-effort readable folder name from a SAF tree URI's document id. */
+ private fun displayNameForTree(uri: android.net.Uri): String {
+ val id = runCatching { android.provider.DocumentsContract.getTreeDocumentId(uri) }
+ .getOrNull() ?: return "folder"
+ return id.substringAfterLast('/').substringAfterLast(':').ifBlank { id }
+ }
+
// --------------------------------------------- project-level setting edits
fun setTempo(bpm: Float) { project.tempoBpm = bpm.coerceIn(20f, 300f); touched() }
@@ -239,10 +407,11 @@ class AppViewModel(
/** The single dispatch point for keyboard / gamepad / MIDI actions. */
private fun onAction(action: InputAction) {
when (action) {
- InputAction.NavUp -> moveCursor(0, -1)
- InputAction.NavDown -> moveCursor(0, +1)
- InputAction.NavLeft -> moveCursor(-1, 0)
- InputAction.NavRight -> moveCursor(+1, 0)
+ // External up/down jump by the skip step; left/right traverse columns.
+ InputAction.NavUp -> moveCursorVerticalWrap(-skipStep)
+ InputAction.NavDown -> moveCursorVerticalWrap(+skipStep)
+ InputAction.NavLeft -> moveColumnFlat(-1)
+ InputAction.NavRight -> moveColumnFlat(+1)
InputAction.NextColumn -> nextColumn()
InputAction.PrevColumn -> prevColumn()
is InputAction.Increment -> editFocused(action.amount)
@@ -250,13 +419,14 @@ class AppViewModel(
InputAction.ClearCell -> clearFocused()
InputAction.NoteOffCell -> noteOffFocused()
is InputAction.NoteOn -> {
+ markNoteOn(action.pitch)
engine.liveNoteOn(action.pitch, action.velocity)
// Live entry also writes the note into the focused NOTE cell.
if (cursorColumn == CellColumn.NOTE) {
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
}
}
- is InputAction.NoteOff -> engine.liveNoteOff(action.pitch)
+ is InputAction.NoteOff -> { markNoteOff(action.pitch); engine.liveNoteOff(action.pitch) }
InputAction.PlayPause -> playPause()
InputAction.Stop -> stop()
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
@@ -273,48 +443,73 @@ class AppViewModel(
fun beginLearn(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
learnTarget = actionId
learnSource = source
+ router.learnSource = source
router.learnMode = true
}
fun cancelLearn() {
learnTarget = null
learnSource = null
+ router.learnSource = null
router.learnMode = false
}
- /** An unbound control arrived. If we're learning and it matches the armed
- * source, store the binding into the right handler's map. */
+ /** A control arrived during learn. If it matches the armed source, store it as
+ * the binding for [learnTarget]. Each action keeps at most one binding, so any
+ * previous one for this action (on the same device) is replaced. */
private fun onRawControl(action: InputAction.RawControl) {
val target = learnTarget ?: return
if (action.source != learnSource) return
when (action.source) {
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
midiInput.ccBindings[action.code] = target
- com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
- gamepadInput.bindings[action.code] = target
+ com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD -> {
+ gamepadInput.bindings.entries.removeAll { it.value == target }
+ gamepadInput.bindings[
+ com.reactorcoremeltdown.sizzletracker.input.GamepadChord(action.modifier, action.code),
+ ] = target
+ }
+ com.reactorcoremeltdown.sizzletracker.input.InputSource.KEYBOARD -> {
+ keyboardInput.bindings.entries.removeAll { it.value == target }
+ keyboardInput.bindings[action.code] = target
+ }
else -> {}
}
cancelLearn()
touched()
+ persistBindings()
}
/** Current MIDI CC number bound to [actionId], or null. */
fun midiBindingFor(actionId: String): Int? =
midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key
- /** Current gamepad key code bound to [actionId], or null. */
- fun gamepadBindingFor(actionId: String): Int? =
+ /** Current gamepad chord (modifier + button, or plain button) bound to
+ * [actionId], or null. */
+ fun gamepadBindingFor(actionId: String): com.reactorcoremeltdown.sizzletracker.input.GamepadChord? =
gamepadInput.bindings.entries.firstOrNull { it.value == actionId }?.key
+ /** Current keyboard key code bound to [actionId], or null. */
+ fun keyboardBindingFor(actionId: String): Int? =
+ keyboardInput.bindings.entries.firstOrNull { it.value == actionId }?.key
+
fun clearBinding(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
when (source) {
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
midiInput.ccBindings.entries.removeAll { it.value == actionId }
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
gamepadInput.bindings.entries.removeAll { it.value == actionId }
+ com.reactorcoremeltdown.sizzletracker.input.InputSource.KEYBOARD ->
+ keyboardInput.bindings.entries.removeAll { it.value == actionId }
else -> {}
}
touched()
+ persistBindings()
+ }
+
+ /** Save all input binding maps so edits survive an app restart. */
+ private fun persistBindings() {
+ viewModelScope.launch { bindingStore.save(keyboardInput, gamepadInput, midiInput) }
}
private fun touched() { revision++ }
@@ -330,20 +525,86 @@ class AppViewModel(
touched()
}
+ /** Nudge an A/B loop region's repeat count and apply it to live playback
+ * immediately (without restarting the transport). */
+ fun changeRegionRepeats(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, delta: Int) {
+ region.repeats = (region.repeats + delta).coerceAtLeast(1)
+ engine.refreshLoopPlaylist()
+ touched()
+ }
+
// -------------------------------------------------------------- sampler
private val recorder = com.reactorcoremeltdown.sizzletracker.audio.SampleRecorder()
var isRecording by mutableStateOf(false); private set
- /** Store already-decoded sample bytes into a slot (from a WAV file import). */
- fun loadSampleInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, id: String, bytes: ByteArray): Boolean {
+ /** Decode a WAV and assign it to Sampler [slot]'s [pad] (from a file import). */
+ fun loadSampleInto(
+ slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot,
+ pad: Int,
+ id: String,
+ bytes: ByteArray,
+ ): Boolean {
val sample = com.reactorcoremeltdown.sizzletracker.audio.SampleStore.decodeWav(bytes) ?: return false
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
- slot.set("samplePath", id)
- slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
+ val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
+ slot.set(pads.key(pad), id)
+ slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
touched()
return true
}
+ /** Load an SF2 / XI (or WAV) file into a SoundFont [slot]: parse it, cache the
+ * extracted sample, and store its id + root note on the slot. */
+ fun loadSoundFont(
+ slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot,
+ id: String,
+ bytes: ByteArray,
+ ): Boolean {
+ val loaded = com.reactorcoremeltdown.sizzletracker.audio.SoundFontLoader.load(bytes) ?: return false
+ com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, loaded.sample)
+ slot.set("sfSample", id)
+ slot.set("sfRoot", loaded.rootNote.toFloat())
+ touched()
+ return true
+ }
+
+ /**
+ * Destructively trim a Sampler [slot]'s [pad] to its current slice region: the
+ * pad's sample is replaced by just the portion between the two slice markers,
+ * and the markers reset to the full (new) sample. There is no undo — re-import
+ * or re-record to start over. Returns false if there's nothing to trim.
+ */
+ fun trimSamplerPad(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, pad: Int): Boolean {
+ val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
+ val store = com.reactorcoremeltdown.sizzletracker.audio.SampleStore
+ val sample = store.get(slot.string(pads.key(pad))) ?: return false
+ val len = sample.data.size
+ if (len <= 1) return false
+ val a = slot.float(pads.sliceStartKey(pad), 0f).coerceIn(0f, 1f)
+ val b = slot.float(pads.sliceEndKey(pad), 1f).coerceIn(0f, 1f)
+ val start = (minOf(a, b) * len).toInt().coerceIn(0, len - 1)
+ val end = (maxOf(a, b) * len).toInt().coerceIn(start + 1, len)
+ if (start == 0 && end == len) return false // slice already spans the whole sample
+ // A fresh id so other pads sharing the original sample are left untouched.
+ val id = "trim:" + System.nanoTime()
+ store.put(id, com.reactorcoremeltdown.sizzletracker.audio.SampleStore.Sample(
+ sample.data.copyOfRange(start, end), sample.sampleRate))
+ slot.set(pads.key(pad), id)
+ slot.set(pads.sliceStartKey(pad), 0f)
+ slot.set(pads.sliceEndKey(pad), 1f)
+ touched()
+ return true
+ }
+
+ /** Clear the sample assigned to a Sampler [slot]'s [pad]. */
+ fun clearSamplerPad(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, pad: Int) {
+ val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
+ slot.values.remove(pads.key(pad))
+ slot.values.remove(pads.sliceStartKey(pad))
+ slot.values.remove(pads.sliceEndKey(pad))
+ touched()
+ }
+
fun startRecording() { if (recorder.start()) isRecording = true }
// ------------------------------------------------------- audio device routing
@@ -369,17 +630,65 @@ class AppViewModel(
fun applyNativeEngine(enabled: Boolean) {
engine.setNativeOutput(enabled)
nativeEngine = engine.useNativeOutput
+ persistSettings()
}
- /** Stop recording and load the captured audio into [slot]. */
- fun stopRecordingInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot) {
+ /** Choose the colour theme and persist the choice. */
+ fun selectPalette(p: com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette) {
+ palette = p
+ persistSettings()
+ }
+
+ /** Save app-level settings (theme + audio backend) so they survive a restart. */
+ private fun persistSettings() {
+ viewModelScope.launch { settingsStore.save(palette.name, nativeEngine) }
+ }
+
+ // Restore persisted app settings. This init block sits AFTER the `palette` and
+ // `nativeEngine` state properties are declared, so their backing MutableStates
+ // exist before we touch them (init blocks run in textual order). A tiny blocking
+ // read at construction (before the first compose) so the theme applies without
+ // a flash of the default palette.
+ init {
+ kotlinx.coroutines.runBlocking {
+ val s = settingsStore.load()
+ com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.ALL
+ .firstOrNull { it.name == s.paletteName }?.let { palette = it }
+ if (s.nativeEngine && nativeEngineAvailable) applyNativeEngine(true)
+
+ // Restore the recording destination + tail, and wire the engine's
+ // capture staging/completion into the SAF copy.
+ recordDirUri = s.recordDirUri
+ recordDirName = recordDirUri
+ ?.let { runCatching { displayNameForTree(android.net.Uri.parse(it)) }.getOrNull() }
+ recordTailBars = s.recordTailBars.coerceIn(0, 4)
+ engine.recTempDir = appContext.cacheDir
+ engine.setRecordTailBars(recordTailBars)
+ engine.onRecordingStarted = {
+ viewModelScope.launch { masterRecording = true }
+ }
+ engine.onRecordingComplete = { file, startMillis ->
+ // On the recorder's writer thread: copy off the main thread, then
+ // publish the UI state change on it.
+ val saved = saveRecordingToDir(file, startMillis)
+ viewModelScope.launch {
+ masterRecording = false
+ if (saved != null) lastRecordingName = saved
+ }
+ }
+ }
+ }
+
+ /** Stop recording and assign the captured audio to Sampler [slot]'s [pad]. */
+ fun stopRecordingInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, pad: Int) {
val sample = recorder.stop()
isRecording = false
if (sample != null) {
val id = "rec-" + System.currentTimeMillis()
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
- slot.set("samplePath", id)
- slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
+ val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
+ slot.set(pads.key(pad), id)
+ slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
touched()
}
}
@@ -408,8 +717,9 @@ class AppViewModel(
}
/** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
- fun auditionOn(slotIndex: Int, midi: Int) = engine.auditionSlotOn(slotIndex, midi)
- fun auditionOff(midi: Int) = engine.auditionSlotOff(midi)
+ fun auditionOn(slotIndex: Int, midi: Int) { markNoteOn(midi); engine.auditionSlotOn(slotIndex, midi) }
+ fun auditionOff(midi: Int) { markNoteOff(midi); engine.auditionSlotOff(midi) }
+
// ------------------------------------------------------------ project I/O
/** Serialize the current song to `.sng` text. */
@@ -434,6 +744,96 @@ class AppViewModel(
touched()
}
+ // -------------------------------------------- on-device project library (full)
+ /** Names of the full projects stored on device (for the Settings browser). */
+ fun songNames(): List = songLibrary.list()
+
+ /** The current project name (shown as the save default). */
+ val songName: String get() = project.name
+
+ /** Save the current project — full fidelity (mixer/FX/instruments) — to storage. */
+ fun saveSong(name: String) {
+ project.name = name
+ songLibrary.save(name, saveProjectText())
+ touched()
+ }
+
+ /**
+ * Discard the current song and start from a clean, empty project — the same
+ * state as a fresh launch. Implemented by loading a freshly-serialized default
+ * [Project] into the shared instance (reusing the loader's full reset), so every
+ * field — patterns, arrangement, mixer routing, toolbox, tempo/scale — returns to
+ * its default. There is no undo. Playback is stopped first so the audio thread
+ * isn't reading the model as it's cleared.
+ */
+ fun newProject() {
+ engine.stop()
+ loadProjectText(com.reactorcoremeltdown.sizzletracker.io.ProjectIo.save(Project()))
+ project.name = "untitled"
+ engine.rebuildFxChains() // all FX routing was cleared
+ }
+
+ /** Load a full project from storage (in place), restoring its sample audio. */
+ fun loadSong(name: String): Boolean {
+ val text = songLibrary.load(name) ?: return false
+ loadProjectText(text) // full restore + cursor reset + revision bump
+ project.name = name
+ presetLibrary.rehydrate(project) // reload the referenced sample audio
+ engine.rebuildFxChains() // mixer FX routing may have changed
+ return true
+ }
+
+ /** Delete a stored full project. */
+ fun deleteSong(name: String): Boolean {
+ val ok = songLibrary.delete(name)
+ if (ok) touched()
+ return ok
+ }
+
+ /** Write the current project as a desktop-compatible `.sng` to a shareable file. */
+ fun writeSngExport(): java.io.File =
+ songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
+
+ // ------------------------------------------------------ instrument/effect presets
+ /** Preset names available for a device type (for the editor's browser dropdown). */
+ fun presetNames(type: com.reactorcoremeltdown.sizzletracker.model.ToolboxType): List =
+ presetLibrary.list(type)
+
+ /** Save [slot] as a named preset in the library. Returns the preset file. */
+ fun savePreset(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, name: String): java.io.File {
+ val file = presetLibrary.save(slot, name)
+ touched()
+ return file
+ }
+
+ /** Load a named preset for [slot]'s current type into it. */
+ fun loadPreset(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, name: String): Boolean {
+ val type = slot.type ?: return false
+ val ok = presetLibrary.load(slot, type, name)
+ if (ok) { engine.rebuildFxChains(); touched() }
+ return ok
+ }
+
+ /** Import a preset from picked bytes: a `.zip` bundle (Sampler) or plain text. */
+ fun importPreset(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, bytes: ByteArray): Boolean {
+ val isZip = bytes.size >= 2 && bytes[0] == 'P'.code.toByte() && bytes[1] == 'K'.code.toByte()
+ val ok = if (isZip) presetLibrary.importZip(slot, java.io.ByteArrayInputStream(bytes))
+ else presetLibrary.importText(slot, String(bytes, Charsets.UTF_8))
+ if (ok) { engine.rebuildFxChains(); touched() }
+ return ok
+ }
+
+ /** A shareable file for a stored preset (a zip bundle for the Sampler). */
+ fun presetShareFile(type: com.reactorcoremeltdown.sizzletracker.model.ToolboxType, name: String): java.io.File? =
+ presetLibrary.bundleForShare(type, name)
+
+ /** Delete a stored preset (and, for the Sampler, its bundled sample). */
+ fun deletePreset(type: com.reactorcoremeltdown.sizzletracker.model.ToolboxType, name: String): Boolean {
+ val ok = presetLibrary.delete(type, name)
+ if (ok) touched()
+ return ok
+ }
+
private fun nextOf(c: CellColumn, dir: Int): CellColumn {
val values = CellColumn.entries
return values[(c.ordinal + dir + values.size) % values.size]
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/StackedKeyboard.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/StackedKeyboard.kt
new file mode 100644
index 0000000..72afe8a
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/StackedKeyboard.kt
@@ -0,0 +1,121 @@
+package com.reactorcoremeltdown.sizzletracker.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.waitForUpOrCancellation
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.reactorcoremeltdown.sizzletracker.model.Pitch
+import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
+import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
+import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
+import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
+
+private const val VEL_STEP = 8
+
+/**
+ * A stacked two-octave keyboard shared by the Toolbox (audition) and Tracker
+ * (punch-in) tabs. Its channel, octave range and velocity live on the
+ * [AppViewModel] (kbdChannel / kbdOctave / kbdVelocity) so both keyboards share
+ * them and they persist across tab switches. Holding a key auditions the note
+ * through the selected mix bus; when [punchIn] is true it also writes the note into
+ * the tracker's focused cell and advances the cursor by the skip step.
+ */
+@Composable
+fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) {
+ val c = LocalRetro.current
+ @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
+ // In punch-in mode only in-scale keys are playable (Chromatic yields all twelve);
+ // the toolbox audition keyboard stays fully chromatic (null = no filter).
+ val inKey: Set? = if (punchIn) {
+ val root = vm.project.rootNote
+ vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
+ } else {
+ null
+ }
+ Column(
+ modifier.fillMaxWidth().background(c.background).padding(6.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Selector("CH", if (vm.kbdChannel == 0) "--" else "${vm.kbdChannel}",
+ { vm.updateKbdChannel(vm.kbdChannel - 1) }, { vm.updateKbdChannel(vm.kbdChannel + 1) })
+ Selector("OCT", "${vm.kbdOctave}–${vm.kbdOctave + 1}",
+ { vm.updateKbdOctave(vm.kbdOctave - 1) }, { vm.updateKbdOctave(vm.kbdOctave + 1) })
+ Selector("VEL", "%02X".format(vm.kbdVelocity),
+ { vm.updateKbdVelocity(vm.kbdVelocity - VEL_STEP) }, { vm.updateKbdVelocity(vm.kbdVelocity + VEL_STEP) })
+ }
+ KeyboardOctave(vm, punchIn, inKey, (vm.kbdOctave + 2) * 12, Modifier.weight(1f)) // upper octave on top
+ KeyboardOctave(vm, punchIn, inKey, (vm.kbdOctave + 1) * 12, Modifier.weight(1f)) // lower octave below
+ }
+}
+
+/** A "LABEL - value +" stepper emitted inline into the control row. */
+@Composable
+private fun Selector(label: String, value: String, onDec: () -> Unit, onInc: () -> Unit) {
+ val c = LocalRetro.current
+ Text(label, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
+ RetroButton("-", onClick = onDec)
+ Text(
+ value, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
+ textAlign = TextAlign.Center, modifier = Modifier.width(40.dp),
+ )
+ RetroButton("+", onClick = onInc)
+}
+
+/** One octave; hold a key to audition (and, in punch-in mode, record) the note.
+ * When [inKey] is non-null, out-of-scale keys are dimmed and inert. */
+@Composable
+private fun KeyboardOctave(
+ vm: AppViewModel,
+ punchIn: Boolean,
+ inKey: Set?,
+ startMidi: Int,
+ modifier: Modifier,
+) {
+ PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
+ val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
+ val enabled = inKey == null || pc in inKey
+ val liveMidi = rememberUpdatedState(midi)
+ val livePunch = rememberUpdatedState(punchIn)
+ PianoKey(
+ label = Pitch.name(midi).replace("-", ""),
+ isBlack = isBlack,
+ enabled = enabled,
+ selected = false,
+ pressed = vm.isNoteHeld(midi),
+ modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) {
+ awaitEachGesture {
+ awaitFirstDown()
+ val n = liveMidi.value
+ if (livePunch.value) vm.punchNote(n)
+ vm.kbdNoteOn(n)
+ waitForUpOrCancellation()
+ vm.kbdNoteOff(n)
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Piano.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Piano.kt
index 36c260a..33cb8ff 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Piano.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Piano.kt
@@ -62,8 +62,10 @@ fun PianoOctave(
/**
* A single piano key face: light for white keys, dark for black, dimmed when
- * [enabled] is false and accent-filled when [selected]. The gesture (if any) is
- * supplied by the caller through [modifier].
+ * [enabled] is false and accent-filled when [selected]. [pressed] lights the key
+ * in the accent colour while it is held down (from any input source) — the visual
+ * feedback shared by every keyboard widget. The gesture (if any) is supplied by the
+ * caller through [modifier].
*/
@Composable
fun PianoKey(
@@ -72,26 +74,29 @@ fun PianoKey(
enabled: Boolean,
selected: Boolean,
modifier: Modifier,
+ pressed: Boolean = false,
) {
val c = LocalRetro.current
+ val lit = selected || pressed
val bg = when {
- selected -> c.accent
+ lit -> c.accent
isBlack -> if (enabled) BLACK_KEY else BLACK_KEY_OFF
else -> if (enabled) WHITE_KEY else WHITE_KEY_OFF
}
val fg = when {
- selected -> c.background
+ lit -> c.background
isBlack -> if (enabled) Color(0xFFDDDDDD) else Color(0xFF555555)
else -> if (enabled) Color(0xFF141414) else Color(0xFF6A6A6A)
}
Box(
modifier
.background(bg)
- .border(1.dp, if (selected) c.accent else KEY_BORDER, RectangleShape),
+ .border(1.dp, if (lit) c.accent else KEY_BORDER, RectangleShape),
contentAlignment = Alignment.BottomCenter,
) {
Text(
- label, color = fg, fontFamily = FontFamily.Monospace, fontSize = 8.sp,
+ label, color = fg, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
+ maxLines = 1, softWrap = false,
modifier = Modifier.padding(bottom = 3.dp),
)
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt
index 86bc9e4..bc4511d 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt
@@ -48,21 +48,24 @@ fun RetroButton(
modifier: Modifier = Modifier,
active: Boolean = false,
width: Dp? = null,
+ danger: Boolean = false,
onClick: () -> Unit,
) {
val c = LocalRetro.current
+ // danger (red) wins over active (accent); otherwise the neutral surface look.
+ val hue = when { danger -> c.danger; active -> c.accent; else -> null }
Box(
modifier
.then(if (width != null) Modifier.width(width) else Modifier)
- .border(1.dp, if (active) c.accent else c.grid, RectangleShape)
- .background(if (active) c.accent.copy(alpha = 0.18f) else c.surface)
+ .border(1.dp, hue ?: c.grid, RectangleShape)
+ .background(hue?.copy(alpha = 0.18f) ?: c.surface)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
- color = if (active) c.accent else c.text,
+ color = hue ?: c.text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
maxLines = 1,
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt
index c45b8fa..0b278db 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt
@@ -1,9 +1,12 @@
package com.reactorcoremeltdown.sizzletracker.ui.mixer
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -44,14 +48,74 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
fun MixerScreen(vm: AppViewModel) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
- Row(Modifier.fillMaxSize().background(c.background).padding(6.dp),
- horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- vm.project.mixer.channels.forEach { channel ->
- ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
+ Column(Modifier.fillMaxSize().background(c.background)) {
+ RecordToolbar(vm)
+ Row(
+ Modifier.weight(1f).fillMaxWidth().padding(6.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ vm.project.mixer.channels.forEach { channel ->
+ ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
+ }
}
}
}
+/**
+ * Master-bus recorder controls at the top of the mixer: arm, FX-tail length, and
+ * the output folder. Recording begins when playback starts and stops automatically
+ * a tail (in bars) after the sequencer stops — a pause/stop, or the arrangement end
+ * with looping off. WAV files are written to the chosen folder with a date-time name.
+ */
+@Composable
+private fun RecordToolbar(vm: AppViewModel) {
+ val c = LocalRetro.current
+ val dirPicker = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocumentTree(),
+ ) { uri -> uri?.let { vm.setRecordDir(it) } }
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .background(c.surface)
+ .padding(6.dp)
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val recording = vm.masterRecording
+ // Arm/record. With no folder yet, the first tap opens the picker instead.
+ RetroButton(
+ if (recording) "● REC" else "● ARM",
+ active = vm.recordArmed && !recording,
+ danger = recording,
+ onClick = { if (vm.recordDirUri == null) dirPicker.launch(null) else vm.toggleRecordArm() },
+ )
+ RetroDropdown(
+ label = "TAIL",
+ options = listOf(0, 1, 2, 3, 4),
+ selected = vm.recordTailBars,
+ optionLabel = { "$it bar" + if (it == 1) "" else "s" },
+ onSelect = { vm.updateRecordTailBars(it) },
+ )
+ RetroButton(
+ "DIR:" + (vm.recordDirName ?: "—"),
+ onClick = { dirPicker.launch(null) },
+ )
+ val status = when {
+ recording -> "recording…"
+ vm.recordDirUri == null -> "pick a folder to record"
+ vm.recordArmed -> "armed — starts on play"
+ vm.lastRecordingName != null -> "saved ${vm.lastRecordingName}"
+ else -> "master → WAV"
+ }
+ Text(
+ status, color = c.textDim, fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp, maxLines = 1,
+ )
+ }
+}
+
@Composable
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
val c = LocalRetro.current
@@ -93,7 +157,7 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
SectionLabel("MIDI Ch")
Row(verticalAlignment = Alignment.CenterVertically) {
- RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(1); vm.bumpForToolbar() }
+ RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(0); vm.bumpForToolbar() }
Text(
"${channel.midiChannel}", color = c.text,
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
@@ -107,6 +171,7 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
// Vertical fader fills the remaining strip height.
VerticalFader(
value = channel.volume,
+ default = Mixer.DEFAULT_VOLUME,
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
modifier = Modifier.weight(1f).fillMaxWidth(),
)
@@ -123,21 +188,22 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
}
/** A vertical volume fader: fill grows from the bottom; tap or drag to set.
- * Snaps to exactly zero (silence) near the bottom so full-off is easy to hit. */
+ * Snaps to the [default] level within a small band so returning to the standard
+ * volume is easy to hit. */
@Composable
-private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
+private fun VerticalFader(value: Float, default: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
val c = LocalRetro.current
Box(
modifier
.border(1.dp, c.grid, RectangleShape)
.background(c.background)
- .pointerInput(Unit) {
- detectTapGestures { off -> onValueChange(snapZero(1f - off.y / size.height)) }
+ .pointerInput(default) {
+ detectTapGestures { off -> onValueChange(snapDefault(1f - off.y / size.height, default)) }
}
- .pointerInput(Unit) {
+ .pointerInput(default) {
detectDragGestures { change, _ ->
change.consume()
- onValueChange(snapZero(1f - change.position.y / size.height))
+ onValueChange(snapDefault(1f - change.position.y / size.height, default))
}
},
contentAlignment = Alignment.BottomCenter,
@@ -154,10 +220,11 @@ private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier
}
}
-/** Snap the fader to exactly 0 (silence) within a small band of the bottom. */
-private fun snapZero(raw: Float): Float {
+/** Snap the fader to the [default] level when the raw value lands within a small
+ * band around it, so returning to the standard volume is easy. */
+private fun snapDefault(raw: Float, default: Float): Float {
val v = raw.coerceIn(0f, 1f)
- return if (v < ZERO_SNAP_BAND) 0f else v
+ return if (kotlin.math.abs(v - default) < SNAP_BAND) default else v
}
-private const val ZERO_SNAP_BAND = 0.03f
+private const val SNAP_BAND = 0.03f
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt
index 1b6a6bf..3713f2e 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt
@@ -1,8 +1,13 @@
package com.reactorcoremeltdown.sizzletracker.ui.settings
import android.content.Context
+import android.content.Intent
import android.media.AudioDeviceInfo
import android.media.AudioManager
+import android.view.KeyEvent
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -21,10 +26,13 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SportsEsports
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -53,6 +61,9 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
+import com.reactorcoremeltdown.sizzletracker.input.GamepadChord
+import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
import com.reactorcoremeltdown.sizzletracker.input.InputSource
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
@@ -64,13 +75,17 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
* project save/load, theme selection + export, gamepad bindings, and a
* searchable / collapsible MIDI binding list.
*/
-private enum class SettingsSection { MAIN, GAMEPAD, MIDI }
+private enum class SettingsSection { MAIN, GAMEPAD, KEYBOARD, MIDI }
@Composable
fun SettingsScreen(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
var section by remember { mutableStateOf(SettingsSection.MAIN) }
+ // Back returns from a binding subsection to the Settings home before the
+ // app-level handler moves to the previous tab.
+ BackHandler(enabled = section != SettingsSection.MAIN) { section = SettingsSection.MAIN }
+
when (section) {
// Top level stays light: the heavy binding lists live in their own
// subsections, so they only compose when the user actually opens them.
@@ -78,14 +93,20 @@ fun SettingsScreen(vm: AppViewModel) {
Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
+ item { PanicButton(vm) }
item { ProjectCard(vm) }
item { AudioDevicesCard(vm) }
item { ThemeCard(vm) }
item {
- NavCard("Gamepad Bindings", "Rebind buttons", Icons.Filled.SportsEsports) {
+ NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
section = SettingsSection.GAMEPAD
}
}
+ item {
+ NavCard("Keyboard Hotkeys", "Rebind keyboard keys", Icons.Filled.Keyboard) {
+ section = SettingsSection.KEYBOARD
+ }
+ }
item {
NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
section = SettingsSection.MIDI
@@ -99,8 +120,13 @@ fun SettingsScreen(vm: AppViewModel) {
) {
item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
item {
- Text("Tap Learn, then press a button to bind it.",
- style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(
+ "Tap Learn, then press a control — or hold one and press another to " +
+ "bind a combo (e.g. A + Up). Buttons, the D-pad and the analog " +
+ "sticks can all be used, alone or as the modifier. A control used " +
+ "only in combos can have its own binding cleared to make it a pure modifier.",
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
items(BIND_MODULES.values.flatten()) { action ->
BindingRow(vm, action, InputSource.GAMEPAD)
@@ -108,6 +134,24 @@ fun SettingsScreen(vm: AppViewModel) {
}
}
+ SettingsSection.KEYBOARD -> LazyColumn(
+ Modifier.fillMaxSize().padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ item { SubHeader("Keyboard Hotkeys") { section = SettingsSection.MAIN } }
+ item {
+ Text(
+ "Tap Learn, then press a key to bind it. The two-octave typing " +
+ "piano (Z–M, Q–U) is fixed and unaffected.",
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ items(KEYBOARD_MODULES.values.flatten()) { action ->
+ BindingRow(vm, action, InputSource.KEYBOARD)
+ HorizontalDivider()
+ }
+ }
+
SettingsSection.MIDI -> LazyColumn(
Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
@@ -142,29 +186,154 @@ private fun NavCard(title: String, subtitle: String, icon: androidx.compose.ui.g
// ---------------------------------------------------------------- top-level cards
+/** Prominent, always-at-the-top MIDI panic: immediately silences every sounding
+ * or stuck note (sequencer, live, audition). */
+@Composable
+private fun PanicButton(vm: AppViewModel) {
+ Button(
+ onClick = { vm.midiPanic() },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) {
+ Text("MIDI PANIC — ALL NOTES OFF")
+ }
+}
+
@Composable
private fun ProjectCard(vm: AppViewModel) {
- val clipboard = LocalClipboardManager.current
- SettingsCard("Project", subtitle = "Interchange with desktop sizzletracker (.sng), or full save") {
+ val context = LocalContext.current
+ @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after save/delete
+ val songs = vm.songNames()
+
+ var selectedName by remember { mutableStateOf(null) }
+ val current = selectedName?.takeIf { it in songs } ?: songs.firstOrNull()
+ var menuOpen by remember { mutableStateOf(false) }
+ var showSave by remember { mutableStateOf(false) }
+ var confirmDelete by remember { mutableStateOf(null) }
+ var confirmNew by remember { mutableStateOf(false) }
+
+ // Import a .sng (desktop interchange) into the current project.
+ val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ uri ?: return@rememberLauncherForActivityResult
+ runCatching {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ }.getOrNull()?.let { vm.importSng(String(it, Charsets.UTF_8)) }
+ }
+
+ SettingsCard("Project", subtitle = "Full projects saved on this device; .sng for desktop interchange") {
+ // On-device full-project browser: load (dropdown) + save + delete.
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box {
+ OutlinedButton(onClick = { menuOpen = true }) { Text(current ?: "No projects", maxLines = 1) }
+ DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
+ if (songs.isEmpty()) {
+ DropdownMenuItem(text = { Text("No saved projects") }, onClick = { menuOpen = false }, enabled = false)
+ } else {
+ songs.forEach { s ->
+ DropdownMenuItem(text = { Text(s) }, onClick = {
+ vm.loadSong(s); selectedName = s; menuOpen = false
+ })
+ }
+ }
+ }
+ }
+ Button(onClick = { showSave = true }) { Text("Save") }
+ OutlinedButton(onClick = { confirmNew = true }) { Text("New") }
+ if (current != null) {
+ Button(
+ onClick = { confirmDelete = current },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) { Text("Delete") }
+ }
+ }
+ Text(
+ "Full projects keep mixer, FX, instruments and sample assignments.",
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ HorizontalDivider()
+ // Desktop interchange: .sng is only exported / imported, never the library.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(onClick = { clipboard.setText(AnnotatedString(vm.exportSng())) }) {
+ OutlinedButton(onClick = { shareSongFile(context, vm.writeSngExport()) }) {
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
Text("Export .sng")
}
- OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::importSng) }) {
+ OutlinedButton(onClick = { importer.launch(arrayOf("*/*")) }) {
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
Text("Import .sng")
}
}
- Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedButton(onClick = { clipboard.setText(AnnotatedString(vm.saveProjectText())) }) { Text("Save full") }
- OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::loadProjectText) }) { Text("Load full") }
- }
Text(
- ".sng carries notes/blocks/arrangement (desktop-compatible); full save also keeps mixer, FX and instruments.",
+ ".sng carries notes/blocks/arrangement only (desktop-compatible).",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
+
+ if (showSave) {
+ var name by remember { mutableStateOf(vm.songName.ifBlank { "untitled" }) }
+ AlertDialog(
+ onDismissRequest = { showSave = false },
+ title = { Text("Save project") },
+ text = {
+ OutlinedTextField(
+ value = name, onValueChange = { name = it }, singleLine = true,
+ label = { Text("Name") },
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ if (name.isNotBlank()) { vm.saveSong(name.trim()); selectedName = name.trim() }
+ showSave = false
+ }) { Text("Save") }
+ },
+ dismissButton = { TextButton(onClick = { showSave = false }) { Text("Cancel") } },
+ )
+ }
+
+ if (confirmNew) {
+ AlertDialog(
+ onDismissRequest = { confirmNew = false },
+ title = { Text("New project") },
+ text = { Text("Start a clean, empty project? Any unsaved changes to the current song will be lost.") },
+ confirmButton = {
+ TextButton(onClick = {
+ vm.newProject(); selectedName = null; confirmNew = false
+ }) { Text("New project") }
+ },
+ dismissButton = { TextButton(onClick = { confirmNew = false }) { Text("Cancel") } },
+ )
+ }
+
+ confirmDelete?.let { name ->
+ AlertDialog(
+ onDismissRequest = { confirmDelete = null },
+ title = { Text("Delete project") },
+ text = { Text("Delete \"$name\"? This cannot be undone.") },
+ confirmButton = {
+ TextButton(onClick = {
+ vm.deleteSong(name); selectedName = null; confirmDelete = null
+ }) { Text("Delete") }
+ },
+ dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
+ )
+ }
+}
+
+/** Launch the system share sheet for an exported .sng file. */
+private fun shareSongFile(context: Context, file: java.io.File) {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val send = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(send, "Share song"))
}
@Composable
@@ -209,7 +378,7 @@ private fun ThemeCard(vm: AppViewModel) {
RetroPalette.ALL.forEach { p ->
FilterChip(
selected = vm.palette.name == p.name,
- onClick = { vm.palette = p },
+ onClick = { vm.selectPalette(p) },
label = { Text(p.name) },
leadingIcon = { Icon(Icons.Filled.Palette, null) },
)
@@ -239,13 +408,50 @@ private val BIND_MODULES: Map> = linkedMapOf(
"Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
)
+/** The keyboard supports the shared actions plus a few keyboard-only ones
+ * (column movement, note-off, and octave shifting of the typing piano). */
+private val KEYBOARD_MODULES: Map> = linkedMapOf(
+ "Transport" to listOf(
+ BindAction("Play / Pause", "playPause"), BindAction("Stop", "stop"), BindAction("Toggle Loop", "loop"),
+ ),
+ "Navigation" to listOf(
+ BindAction("Cursor Up", "up"), BindAction("Cursor Down", "down"),
+ BindAction("Cursor Left", "left"), BindAction("Cursor Right", "right"),
+ ),
+ "Editing" to listOf(
+ BindAction("Value +", "increment"), BindAction("Value -", "decrement"),
+ BindAction("Clear Cell", "clear"), BindAction("Note Off", "noteOff"),
+ ),
+ "Columns" to listOf(
+ BindAction("Next Column", "nextColumn"), BindAction("Prev Column", "prevColumn"),
+ ),
+ "Octave" to listOf(
+ BindAction("Octave Down", "octaveDown"), BindAction("Octave Up", "octaveUp"),
+ ),
+ "Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
+)
+
/** One action row with its current binding, a Learn toggle, and a clear button. */
@Composable
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
val listening = vm.learnTarget == action.id
- val code = if (source == InputSource.MIDI) vm.midiBindingFor(action.id) else vm.gamepadBindingFor(action.id)
- val label = code?.let { (if (source == InputSource.MIDI) "CC $it" else "key $it") } ?: "unbound"
+ val label: String
+ val bound: Boolean
+ when (source) {
+ InputSource.MIDI -> {
+ val code = vm.midiBindingFor(action.id)
+ label = code?.let { "CC $it" } ?: "unbound"; bound = code != null
+ }
+ InputSource.GAMEPAD -> {
+ val chord = vm.gamepadBindingFor(action.id)
+ label = chord?.let { gamepadChordLabel(it) } ?: "unbound"; bound = chord != null
+ }
+ else -> {
+ val code = vm.keyboardBindingFor(action.id)
+ label = code?.let { keyLabel(it) } ?: "unbound"; bound = code != null
+ }
+ }
ListItem(
headlineContent = { Text(action.label) },
@@ -255,7 +461,7 @@ private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource
TextButton(onClick = { if (listening) vm.cancelLearn() else vm.beginLearn(action.id, source) }) {
Text(if (listening) "Listening…" else "Learn")
}
- if (code != null) {
+ if (bound) {
TextButton(onClick = { vm.clearBinding(action.id, source) }) { Text("✕") }
}
}
@@ -264,6 +470,18 @@ private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource
)
}
+/** Human-readable name for a physical keyboard key ("PAGE UP", "TAB", "L"). */
+private fun keyLabel(code: Int): String =
+ KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_").replace('_', ' ')
+
+/** Short gamepad control name ("A", "L1", "UP", "LS↑") for a button/D-pad/stick code. */
+private fun gamepadButtonLabel(code: Int): String = GamepadInput.controlLabel(code)
+
+/** "A + UP" for a chord, or just "A" for a plain button binding. */
+private fun gamepadChordLabel(chord: GamepadChord): String =
+ if (chord.modifier == GamepadChord.NONE) gamepadButtonLabel(chord.code)
+ else "${gamepadButtonLabel(chord.modifier)} + ${gamepadButtonLabel(chord.code)}"
+
/** A label + dropdown that routes audio to a chosen device (or the default). */
@Composable
private fun DeviceRow(
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/DelayEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/DelayEditor.kt
new file mode 100644
index 0000000..b5c4cf2
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/DelayEditor.kt
@@ -0,0 +1,139 @@
+package com.reactorcoremeltdown.sizzletracker.ui.toolbox
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.reactorcoremeltdown.sizzletracker.model.DelayDivisions
+import com.reactorcoremeltdown.sizzletracker.model.ParamSpec
+import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
+import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
+import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
+
+/**
+ * The Tape Delay's custom editor: four tape heads side by side, each a vertical
+ * tempo-synced division fader (hard-snapping to the [DelayDivisions] steps —
+ * 1/1 … 1/16 plus dotted and triplet values) with an on/off toggle above it, then
+ * the shared Feedback / Dry-Wet / Tape-Strength sliders below. Feedback is capped
+ * below 1 in both the UI and the DSP, so it can never build a runaway loop.
+ */
+@Composable
+fun DelayEditor(vm: AppViewModel, slot: ToolboxSlot) {
+ val c = LocalRetro.current
+ @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so heads/params refresh
+
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text(
+ "Tape heads — drag a fader to set its time; tap the number to switch it off/on:",
+ color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
+ )
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ for (h in 0 until DelayDivisions.HEADS) {
+ DelayHead(vm, slot, h, rev, Modifier.weight(1f))
+ }
+ }
+ // Common controls (left as plain sliders with sane defaults). Feedback's max
+ // is the DSP's hard ceiling, so a positive-feedback runaway is impossible.
+ ParamControl(vm, slot, ParamSpec("feedback", "Feedback", 0.4f, 0f, DelayDivisions.MAX_FEEDBACK))
+ ParamControl(vm, slot, ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f))
+ ParamControl(vm, slot, ParamSpec("tape", "Tape Strength", 0.3f, 0f, 1f))
+ }
+}
+
+/** One tape head: an on/off toggle, a vertical hard-snapping division fader, and
+ * the current division label. Longer values sit at the top (taller fill). */
+@Composable
+private fun DelayHead(vm: AppViewModel, slot: ToolboxSlot, head: Int, rev: Int, modifier: Modifier) {
+ @Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
+ val c = LocalRetro.current
+ val on = slot.float("head${head}On", if (head == 0) 1f else 0f) >= 0.5f
+ val steps = DelayDivisions.size
+ val idx = slot.float("head${head}Div", DelayDivisions.DEFAULT.toFloat()).toInt().coerceIn(0, steps - 1)
+ val border = if (on) c.accent else c.grid
+
+ Column(modifier, horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ // On/off toggle (the head number).
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(26.dp)
+ .border(1.dp, border, RectangleShape)
+ .background(if (on) c.accent.copy(alpha = 0.22f) else c.surface)
+ .pointerInput(head, slot.index) {
+ detectTapGestures { slot.set("head${head}On", if (on) 0f else 1f); vm.bumpForToolbar() }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("${head + 1}", color = if (on) c.accent else c.textDim,
+ fontFamily = FontFamily.Monospace, fontSize = 13.sp)
+ }
+
+ // Vertical fader: top = longest (1/1), bottom = shortest (1/16T). Tap or drag
+ // to snap to a step.
+ Canvas(
+ Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .background(c.surface)
+ .border(1.dp, border, RectangleShape)
+ .pointerInput(head, slot.index) {
+ detectTapGestures { off ->
+ val i = (off.y / size.height * steps).toInt().coerceIn(0, steps - 1)
+ slot.set("head${head}Div", i.toFloat()); vm.bumpForToolbar()
+ }
+ }
+ .pointerInput(head, slot.index) {
+ detectDragGestures { change, _ ->
+ change.consume()
+ val i = (change.position.y / size.height * steps).toInt().coerceIn(0, steps - 1)
+ slot.set("head${head}Div", i.toFloat()); vm.bumpForToolbar()
+ }
+ },
+ ) {
+ val w = size.width
+ val hgt = size.height
+ val stepH = hgt / steps
+ // Fill from the bottom up to the selected step: a longer division (higher
+ // up) shows a taller fader.
+ val fillTop = idx * stepH
+ drawRect(
+ (if (on) c.accent else c.grid).copy(alpha = 0.45f),
+ Offset(0f, fillTop), Size(w, hgt - fillTop),
+ )
+ // Step dividers.
+ for (s in 1 until steps) {
+ drawLine(c.grid.copy(alpha = 0.4f), Offset(0f, s * stepH), Offset(w, s * stepH), strokeWidth = 1f)
+ }
+ // Highlight the selected step band.
+ drawRect(
+ (if (on) c.playhead else c.textDim).copy(alpha = 0.5f),
+ Offset(0f, fillTop), Size(w, stepH),
+ )
+ }
+
+ Text(
+ DelayDivisions.label(idx),
+ color = if (on) c.text else c.textDim,
+ fontFamily = FontFamily.Monospace, fontSize = 10.sp,
+ )
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt
index bfc1054..43f239e 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt
@@ -15,6 +15,7 @@ import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
+import kotlin.math.roundToInt
/**
* One editable parameter row. Enumerated params ([ParamSpec.isEnum]) render as a
@@ -35,17 +36,24 @@ fun ParamControl(vm: AppViewModel, slot: ToolboxSlot, spec: ParamSpec) {
)
} else {
val value = slot.float(spec.key, spec.default)
+ // Integer params (e.g. bit depth, semitones, octaves) snap to whole values:
+ // the slider gets one discrete stop per integer, and writes rounded values.
+ val intSteps = if (spec.isInt) ((spec.max - spec.min).toInt() - 1).coerceAtLeast(0) else 0
Column(Modifier.fillMaxWidth()) {
Row {
Text("${spec.label}: ", color = c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
- Text(formatValue(value), color = c.text,
+ Text(if (spec.isInt) value.roundToInt().toString() else formatValue(value), color = c.text,
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
}
Slider(
value = value.coerceIn(spec.min, spec.max),
valueRange = spec.min..spec.max,
- onValueChange = { slot.set(spec.key, it); vm.bumpForToolbar() },
+ steps = intSteps,
+ onValueChange = {
+ val v = if (spec.isInt) it.roundToInt().toFloat() else it
+ slot.set(spec.key, v); vm.bumpForToolbar()
+ },
colors = SliderDefaults.colors(
thumbColor = c.accent, activeTrackColor = c.accent, inactiveTrackColor = c.grid,
),
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt
index b3259bb..9a72a44 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt
@@ -4,14 +4,16 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -19,9 +21,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
@@ -29,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.audio.SampleStore
import com.reactorcoremeltdown.sizzletracker.model.Pitch
+import com.reactorcoremeltdown.sizzletracker.model.SamplerPads
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
@@ -36,56 +41,68 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
import kotlin.math.abs
/**
- * The Sampler's custom editor (replaces the generic parameter list for the
- * Sampler device). It lets you import a WAV or record from the mic/USB input,
- * trim the sample with two draggable slice markers on a waveform, set the start
- * octave + volume, and audition across a two-octave keyboard.
+ * The Sampler's custom editor: a 16-pad drum-style bank plus a per-pad detail area.
+ *
+ * The bottom half is a 4x4 grid of pads mapped to consecutive notes from C2
+ * ([SamplerPads]). Tapping a pad selects it AND auditions its sample (so pads are
+ * for both testing and picking which one to edit); long-pressing a pad clears it.
+ * The top half acts on the selected pad: Import a WAV or Record to assign a sample,
+ * position the two waveform markers to set the play slice, and CLIP to destructively
+ * trim the sample down to that slice (no undo). Per-pad volume lives in the fader
+ * bank. When the sequencer plays a pad's note the sample fires at its natural pitch.
*/
@Composable
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
val context = LocalContext.current
- @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: waveform/markers refresh
+ @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: pads/waveform refresh
- val sampleId = slot.string("samplePath")
- val sliceStart = slot.float("sliceStart", 0f)
- val sliceEnd = slot.float("sliceEnd", 1f)
- val startOctave = slot.float("startOctave", 3f).toInt()
- val volume = slot.float("volume", 0.8f)
+ var pad by remember(slot.index) { mutableIntStateOf(0) }
+ val sampleId = slot.string(SamplerPads.key(pad))
+ val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f)
+ val sliceEnd = slot.float(SamplerPads.sliceEndKey(pad), 1f)
- // System file picker for importing a WAV.
+ // System file picker: assigns the chosen WAV to the currently-selected pad.
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
- }.getOrNull()?.let { bytes ->
- val ok = vm.loadSampleInto(slot, uri.toString(), bytes)
- if (!ok) { /* unsupported file; a toast could go here */ }
- }
+ }.getOrNull()?.let { bytes -> vm.loadSampleInto(slot, pad, uri.toString(), bytes) }
}
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- // ----- Source buttons -----
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // ----- Selected pad + source buttons (act on the selected pad) -----
+ Text(
+ "Pad ${pad + 1} · ${Pitch.name(SamplerPads.noteFor(pad))}",
+ color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
+ )
+ Row(
+ Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
RetroButton("IMPORT WAV") { picker.launch(arrayOf("audio/*")) }
if (vm.isRecording) {
- RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot) }
+ RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot, pad) }
} else {
RetroButton("● RECORD") { vm.startRecording() }
}
+ if (sampleId.isNotBlank()) {
+ // Destructively trim the sample to the current slice markers (no undo).
+ RetroButton("✂ CLIP") { vm.trimSamplerPad(slot, pad) }
+ RetroButton("CLEAR", danger = true) { vm.clearSamplerPad(slot, pad) }
+ }
}
- // ----- Waveform + draggable slice markers -----
+ // ----- Waveform + draggable slice markers for the selected pad -----
val sample = SampleStore.get(sampleId)
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
var dragging by remember { mutableIntStateOf(-1) }
-
Canvas(
Modifier
.fillMaxWidth()
- .height(130.dp)
+ .height(110.dp)
.background(c.surface)
- .pointerInput(sampleId) {
+ .pointerInput(sampleId, pad) {
detectDragGestures(
onDragStart = { off ->
val frac = (off.x / size.width).coerceIn(0f, 1f)
@@ -94,7 +111,8 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
onDrag = { change, _ ->
change.consume()
val frac = (change.position.x / size.width).coerceIn(0f, 1f)
- if (dragging == 0) slot.set("sliceStart", frac) else slot.set("sliceEnd", frac)
+ if (dragging == 0) slot.set(SamplerPads.sliceStartKey(pad), frac)
+ else slot.set(SamplerPads.sliceEndKey(pad), frac)
vm.bumpForToolbar()
},
)
@@ -108,53 +126,170 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
val bw = w / mins.size
for (i in mins.indices) {
val x = i * bw
- drawLine(
- color = c.text,
- start = Offset(x, mid - maxs[i] * mid),
- end = Offset(x, mid - mins[i] * mid),
- strokeWidth = 1f,
- )
+ drawLine(c.text, Offset(x, mid - maxs[i] * mid), Offset(x, mid - mins[i] * mid), strokeWidth = 1f)
}
}
- // Shade the active slice region and draw the two markers.
val sx = sliceStart * w
val ex = sliceEnd * w
- drawRect(color = c.accent.copy(alpha = 0.14f), topLeft = Offset(sx, 0f), size = Size((ex - sx), h))
+ drawRect(c.accent.copy(alpha = 0.14f), Offset(sx, 0f), Size(ex - sx, h))
drawLine(c.accent, Offset(sx, 0f), Offset(sx, h), strokeWidth = 3f)
drawLine(c.playhead, Offset(ex, 0f), Offset(ex, h), strokeWidth = 3f)
}
Text(
- if (sample == null) "No sample loaded — import a WAV or record one."
- else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers to trim",
+ if (sample == null) "Pad empty — Import a WAV or Record to assign it."
+ else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers, then CLIP to trim",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
)
- // ----- Start octave + volume -----
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Text("Start oct:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
- modifier = Modifier.padding(top = 6.dp))
- RetroButton("-") { slot.set("startOctave", (startOctave - 1).coerceAtLeast(0).toFloat()); vm.bumpForToolbar() }
- Text(" $startOctave ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
- modifier = Modifier.padding(top = 6.dp))
- RetroButton("+") { slot.set("startOctave", (startOctave + 1).coerceAtMost(8).toFloat()); vm.bumpForToolbar() }
- }
- ParamControl(vm, slot, com.reactorcoremeltdown.sizzletracker.model.ParamSpec("volume", "Volume", volume, 0f, 1f))
+ // ----- Per-pad output volume: 16 vertical sliders laid out 8 x 2 -----
+ Text(
+ "Pad volumes (drag a slider; tap selects that pad):",
+ color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
+ )
+ PadVolumeBank(vm, slot, pad, rev, onSelect = { pad = it })
- // ----- Two-octave audition keyboard -----
- Text("Audition (2 octaves):", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
- val root = ((startOctave + 1) * 12).coerceIn(0, 103)
- Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
- horizontalArrangement = Arrangement.spacedBy(2.dp)) {
- for (n in 0 until 24) {
- val midi = root + n
- RetroButton(Pitch.name(midi)) { vm.auditionSample(slot, midi) }
+ // ----- 16-pad bank (bottom section) -----
+ Text(
+ "Pads (from C2 — tap to test & select, long-press to clear):",
+ color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
+ )
+ // Pass rev so assign/clear (which only bump revision) redraw the pads: a
+ // changed Int param defeats Compose strong-skipping on these leaves.
+ PadBank(vm, slot, pad, rev, onSelect = { pad = it })
+ }
+}
+
+/** The 4x4 pad grid. Pads read ascending left-to-right, top-to-bottom (pad 0 = C2). */
+@Composable
+private fun PadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ for (row in 0 until 4) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ for (col in 0 until 4) {
+ PadCell(vm, slot, row * 4 + col, selected, rev, Modifier.weight(1f), onSelect)
+ }
}
}
}
}
-/** Down-sample the waveform to [buckets] min/max pairs for cheap drawing. */
-private fun computePeaks(data: FloatArray, buckets: Int): Pair {
+@Composable
+private fun PadCell(
+ vm: AppViewModel,
+ slot: ToolboxSlot,
+ pad: Int,
+ selected: Int,
+ rev: Int,
+ modifier: Modifier,
+ onSelect: (Int) -> Unit,
+) {
+ @Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
+ val c = LocalRetro.current
+ val isSelected = selected == pad
+ val assigned = slot.string(SamplerPads.key(pad)).isNotBlank()
+ val note = SamplerPads.noteFor(pad)
+ val border = when { isSelected -> c.playhead; assigned -> c.accent; else -> c.grid }
+ val fill = when {
+ isSelected -> c.accent.copy(alpha = 0.28f)
+ assigned -> c.accent.copy(alpha = 0.12f)
+ else -> c.surface
+ }
+ Box(
+ modifier
+ .height(46.dp)
+ .border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
+ .background(fill)
+ .pointerInput(pad, slot.index) {
+ detectTapGestures(
+ onTap = { onSelect(pad); vm.auditionSample(slot, note) },
+ onLongPress = { onSelect(pad); vm.clearSamplerPad(slot, pad) },
+ )
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(Pitch.name(note), color = if (assigned) c.text else c.textDim,
+ fontFamily = FontFamily.Monospace, fontSize = 10.sp)
+ Text(if (assigned) "●" else "+", color = if (assigned) c.accent else c.textDim,
+ fontFamily = FontFamily.Monospace, fontSize = 10.sp)
+ }
+ }
+}
+
+/** The per-pad volume bank: 16 vertical sliders in an 8-wide, 2-tall grid, so it
+ * reads left-to-right, top-to-bottom just like the [PadBank] pad grid above. */
+@Composable
+private fun PadVolumeBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ for (row in 0 until 2) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ for (col in 0 until 8) {
+ val p = row * 8 + col
+ VerticalPadSlider(vm, slot, p, selected == p, rev, Modifier.weight(1f), onSelect)
+ }
+ }
+ }
+ }
+}
+
+/** One vertical fader for a pad's output volume (0..1). Drag anywhere on the track
+ * to set the level; a tap also selects the pad so the detail area follows it. The
+ * fill height is the level; assigned pads read in the accent colour, empty ones dim. */
+@Composable
+private fun VerticalPadSlider(
+ vm: AppViewModel,
+ slot: ToolboxSlot,
+ pad: Int,
+ isSelected: Boolean,
+ rev: Int,
+ modifier: Modifier,
+ onSelect: (Int) -> Unit,
+) {
+ @Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
+ val c = LocalRetro.current
+ val vol = slot.float(SamplerPads.volumeKey(pad), SamplerPads.DEFAULT_VOLUME)
+ val assigned = slot.string(SamplerPads.key(pad)).isNotBlank()
+ val border = when { isSelected -> c.playhead; assigned -> c.accent; else -> c.grid }
+ val fillColor = if (assigned) c.accent.copy(alpha = 0.55f) else c.grid.copy(alpha = 0.55f)
+ Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Canvas(
+ Modifier
+ .fillMaxWidth()
+ .height(96.dp)
+ .background(c.surface)
+ .border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
+ .pointerInput(pad, slot.index) {
+ detectTapGestures { off ->
+ onSelect(pad)
+ slot.set(SamplerPads.volumeKey(pad), (1f - off.y / size.height).coerceIn(0f, 1f))
+ vm.bumpForToolbar()
+ }
+ }
+ .pointerInput(pad, slot.index) {
+ detectDragGestures(
+ onDragStart = { onSelect(pad) },
+ onDrag = { change, _ ->
+ change.consume()
+ slot.set(SamplerPads.volumeKey(pad), (1f - change.position.y / size.height).coerceIn(0f, 1f))
+ vm.bumpForToolbar()
+ },
+ )
+ },
+ ) {
+ val fillH = vol.coerceIn(0f, 1f) * size.height
+ drawRect(fillColor, Offset(0f, size.height - fillH), Size(size.width, fillH))
+ }
+ Text(
+ Pitch.name(SamplerPads.noteFor(pad)),
+ color = if (assigned) c.text else c.textDim,
+ fontFamily = FontFamily.Monospace, fontSize = 8.sp,
+ )
+ }
+}
+
+/** Down-sample the waveform to [buckets] min/max pairs for cheap drawing.
+ * Shared with the SoundFont editor's preview. */
+internal fun computePeaks(data: FloatArray, buckets: Int): Pair {
val mins = FloatArray(buckets)
val maxs = FloatArray(buckets)
if (data.isEmpty()) return mins to maxs
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SoundFontEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SoundFontEditor.kt
new file mode 100644
index 0000000..4709d01
--- /dev/null
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SoundFontEditor.kt
@@ -0,0 +1,204 @@
+package com.reactorcoremeltdown.sizzletracker.ui.toolbox
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.waitForUpOrCancellation
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.reactorcoremeltdown.sizzletracker.audio.SampleStore
+import com.reactorcoremeltdown.sizzletracker.model.Pitch
+import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
+import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
+import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
+import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
+import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
+import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
+
+/**
+ * The SoundFont device editor: load an `.sf2` / `.xi` (or `.wav`) file from local
+ * storage, preview its waveform, set output volume, and audition it pitched across
+ * a two-octave keyboard. The extracted sample plays across the whole keyboard from
+ * the file's root note (see [com.reactorcoremeltdown.sizzletracker.audio.SoundFontLoader]).
+ */
+@Composable
+fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) {
+ val c = LocalRetro.current
+ val context = LocalContext.current
+ @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: waveform/info refresh
+
+ val sfId = slot.string("sfSample")
+ val sample = SampleStore.get(sfId)
+ val root = slot.float("sfRoot", 60f).toInt()
+
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ uri ?: return@rememberLauncherForActivityResult
+ runCatching {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ }.getOrNull()?.let { bytes -> vm.loadSoundFont(slot, uri.toString(), bytes) }
+ }
+
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ RetroButton("LOAD SF2 / XI") {
+ // .sf2/.xi have no reliable MIME; accept any file and detect by content.
+ picker.launch(arrayOf("*/*"))
+ }
+ Text(
+ if (sample == null) {
+ "No instrument loaded — load a .sf2 or .xi file."
+ } else {
+ "Loaded: ${sample.data.size} frames @ ${sample.sampleRate} Hz · root ${Pitch.name(root)}"
+ },
+ color = if (sample == null) c.textDim else c.text,
+ fontFamily = FontFamily.Monospace, fontSize = 10.sp,
+ )
+
+ if (sample != null) {
+ val peaks = remember(sfId, sample.data.size) { computePeaks(sample.data, 400) }
+ Canvas(Modifier.fillMaxWidth().height(96.dp).background(c.surface)) {
+ val (mins, maxs) = peaks
+ val bw = size.width / mins.size
+ val mid = size.height / 2f
+ for (i in mins.indices) {
+ val x = i * bw
+ drawLine(c.text, Offset(x, mid - maxs[i] * mid), Offset(x, mid - mins[i] * mid), strokeWidth = 1f)
+ }
+ }
+ }
+
+ // Volume + ADSR as compact vertical faders (one row) so the audition
+ // keyboard below gets room for full-size keys.
+ Text("Volume / envelope:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ VerticalSlider(vm, slot, rev, "volume", "VOL", 0f, 1f, 0.8f, Modifier.weight(1f))
+ VerticalSlider(vm, slot, rev, "attack", "ATK", 0f, 2f, 0.005f, Modifier.weight(1f))
+ VerticalSlider(vm, slot, rev, "decay", "DEC", 0f, 2f, 0.10f, Modifier.weight(1f))
+ VerticalSlider(vm, slot, rev, "sustain", "SUS", 0f, 1f, 1f, Modifier.weight(1f))
+ VerticalSlider(vm, slot, rev, "release", "REL", 0f, 3f, 0.20f, Modifier.weight(1f))
+ }
+
+ // Audition keyboard — same look and behaviour as the toolbox MIDI piano:
+ // an octave selector over two stacked octaves, hold a key to sound the
+ // loaded instrument.
+ var octave by remember { mutableIntStateOf(3) }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text("OCT", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
+ RetroButton("-") { octave = (octave - 1).coerceAtLeast(0) }
+ Text("$octave–${octave + 1}", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp)
+ RetroButton("+") { octave = (octave + 1).coerceAtMost(8) }
+ }
+ AuditionOctave(vm, slot.index, (octave + 2) * 12) // upper octave on top
+ AuditionOctave(vm, slot.index, (octave + 1) * 12) // lower octave below
+ }
+}
+
+/** A compact vertical fader bound to a numeric slot param ([key], range [min]..[max]).
+ * Drag or tap the track to set the value; the current value shows above and the
+ * short label below. [rev] is passed only to defeat Compose strong-skipping so the
+ * fader refreshes when a preset load changes the value. */
+@Composable
+private fun VerticalSlider(
+ vm: AppViewModel,
+ slot: ToolboxSlot,
+ rev: Int,
+ key: String,
+ label: String,
+ min: Float,
+ max: Float,
+ default: Float,
+ modifier: Modifier,
+) {
+ @Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
+ val c = LocalRetro.current
+ val value = slot.float(key, default).coerceIn(min, max)
+ val frac = if (max > min) (value - min) / (max - min) else 0f
+
+ fun setFromY(y: Float, h: Float) {
+ val f = (1f - y / h).coerceIn(0f, 1f)
+ slot.set(key, min + f * (max - min))
+ vm.bumpForToolbar()
+ }
+
+ Column(modifier, horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(formatSliderValue(value), color = c.text,
+ fontFamily = FontFamily.Monospace, fontSize = 9.sp, maxLines = 1)
+ Canvas(
+ Modifier
+ .fillMaxWidth()
+ .height(120.dp)
+ .background(c.surface)
+ .border(1.dp, c.grid, RectangleShape)
+ .pointerInput(key, slot.index) {
+ detectTapGestures { off -> setFromY(off.y, size.height.toFloat()) }
+ }
+ .pointerInput(key, slot.index) {
+ detectDragGestures { change, _ -> change.consume(); setFromY(change.position.y, size.height.toFloat()) }
+ },
+ ) {
+ val fillH = frac.coerceIn(0f, 1f) * size.height
+ drawRect(c.accent.copy(alpha = 0.5f), Offset(0f, size.height - fillH), Size(size.width, fillH))
+ }
+ Text(label, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 9.sp, maxLines = 1)
+ }
+}
+
+private fun formatSliderValue(v: Float): String =
+ if (v == v.toInt().toFloat()) v.toInt().toString() else String.format("%.2f", v)
+
+/** One octave of the SoundFont audition keyboard — a real piano octave (like the
+ * toolbox piano and the cell-editor keyboard); hold a key to sound the slot's
+ * instrument. A fixed, full-size height is used because the editor scrolls, and
+ * PianoOctave needs a bounded height to lay its keys out. */
+@Composable
+private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, startMidi: Int) {
+ PianoOctave(Modifier.fillMaxWidth().height(150.dp)) { pc, isBlack, keyMod ->
+ val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
+ val liveMidi = rememberUpdatedState(midi)
+ PianoKey(
+ label = Pitch.name(midi).replace("-", ""),
+ isBlack = isBlack,
+ enabled = true,
+ selected = false,
+ pressed = vm.isNoteHeld(midi),
+ modifier = keyMod.pointerInput(Unit) {
+ awaitEachGesture {
+ awaitFirstDown()
+ val n = liveMidi.value
+ vm.auditionOn(slotIndex, n)
+ waitForUpOrCancellation()
+ vm.auditionOff(n)
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt
index aa3bcf5..5221d65 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt
@@ -1,25 +1,37 @@
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
+import android.content.Intent
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
+import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -29,19 +41,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import com.reactorcoremeltdown.sizzletracker.io.PresetIo
+import androidx.core.content.FileProvider
import com.reactorcoremeltdown.sizzletracker.model.Pitch
-import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
+import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
+import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
/**
@@ -65,6 +78,12 @@ fun ToolboxScreen(vm: AppViewModel) {
var editing by remember { mutableStateOf(null) }
var picking by remember { mutableStateOf(null) }
+ // Back closes an open device editor / picker before the app-level handler
+ // switches tabs.
+ BackHandler(enabled = editing != null || picking != null) {
+ if (editing != null) editing = null else picking = null
+ }
+
Column(Modifier.fillMaxSize().background(c.background)) {
LazyVerticalGrid(
columns = GridCells.Fixed(4),
@@ -87,7 +106,7 @@ fun ToolboxScreen(vm: AppViewModel) {
}
}
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
- AuditionKeyboard(vm, vm.project.toolbox.getOrNull(selected), Modifier.weight(1f))
+ com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = false, Modifier.weight(1f))
}
picking?.let { index ->
@@ -148,70 +167,7 @@ private fun SlotTile(
}
}
-// ---------------------------------------------------------- test keyboard
-
-/**
- * Two stacked octaves (one per row), each drawn as a real piano keyboard, that
- * audition the selected instrument tile. Disabled (dimmed) when nothing is selected
- * or the selection is an effect.
- */
-@Composable
-private fun AuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot?, modifier: Modifier) {
- val c = LocalRetro.current
- val isInstrument = slot?.type?.kind == ToolboxKind.INSTRUMENT
- Column(
- modifier.fillMaxWidth().background(c.background).padding(6.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- when {
- slot == null -> "Tap a tile to select an instrument to test"
- !isInstrument -> "${slot.name}: not an instrument"
- else -> "Test: ${slot.name}"
- },
- color = if (isInstrument) c.accent else c.textDim,
- fontFamily = FontFamily.Monospace, fontSize = 11.sp,
- )
- val slotIndex = slot?.index ?: -1
- val base = 48 // C3
- PianoRow(vm, slotIndex, isInstrument, base + 12, Modifier.weight(1f)) // upper octave (C4..B4) on top
- PianoRow(vm, slotIndex, isInstrument, base, Modifier.weight(1f)) // lower octave (C3..B3)
- }
-}
-
-/** One octave of the audition keyboard: every key is playable (hold to sound it)
- * when an instrument is selected, since this is for testing, not scale-locked. */
-@Composable
-private fun PianoRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) {
- PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
- val midi = startMidi + pc
- // Keep each key's gesture stable across selection changes: keyed on `midi`
- // only (constant), reading the live slot/enabled via rememberUpdatedState so
- // switching instruments doesn't relaunch every key's coroutine.
- val liveSlot = rememberUpdatedState(slotIndex)
- val liveEnabled = rememberUpdatedState(enabled)
- PianoKey(
- label = Pitch.name(midi).take(2),
- isBlack = isBlack,
- enabled = enabled,
- selected = false,
- modifier = keyMod.pointerInput(midi) {
- // Press → note on; release/cancel → note off (proper key behaviour).
- awaitEachGesture {
- awaitFirstDown()
- val idx = liveSlot.value
- if (liveEnabled.value && idx >= 0) {
- vm.auditionOn(idx, midi)
- waitForUpOrCancellation()
- vm.auditionOff(midi)
- } else {
- waitForUpOrCancellation()
- }
- }
- },
- )
- }
-}
+// ---------------------------------------------------------- MIDI piano
/** A simple overlay list of all available instruments and effects to load. */
@Composable
@@ -238,16 +194,15 @@ private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
}
}
-/** Auto-generated parameter editor plus preset copy/paste to the clipboard. */
+/** Auto-generated parameter editor. Presets are managed by the [PresetBar]. */
@Composable
private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
val c = LocalRetro.current
- val clipboard = LocalClipboardManager.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
val type = slot.type ?: return
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
- Column(Modifier.fillMaxSize().padding(12.dp),
+ Column(Modifier.fillMaxSize().padding(12.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
@@ -255,41 +210,148 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
}
+ // Preset browser: load / save / import / share for this device.
+ PresetBar(vm, slot)
+
// The Sampler gets a bespoke editor (waveform + slicing + record);
// the LFO adds a modulation-target picker; everything else uses the
// auto-generated parameter list.
when (type) {
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
+ ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
+ ToolboxType.DELAY -> DelayEditor(vm, slot)
ToolboxType.LFO -> {
type.params.forEach { spec -> ParamControl(vm, slot, spec) }
LfoTargetPicker(vm, slot)
}
else -> type.params.forEach { spec -> ParamControl(vm, slot, spec) }
}
-
- // ----- Preset import / export (human-readable text via clipboard) -----
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- PresetButton("COPY PRESET") {
- clipboard.setText(AnnotatedString(PresetIo.export(slot)))
- }
- PresetButton("PASTE PRESET") {
- clipboard.getText()?.text?.let { PresetIo.import(slot, it); vm.bumpForToolbar() }
- }
- }
- Text(
- "Presets are plain text (KEY=VALUE). Copy to share, paste to load.",
- color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
- )
}
}
}
+/**
+ * The preset browser bar shown at the top of every device editor: a dropdown of
+ * the presets saved for this device type, plus Save (to the app's on-disk preset
+ * library), Import (from a picked text preset or — for the Sampler — a `.zip`
+ * bundle), and Share (via the system share sheet). Presets are the plain-text
+ * [com.reactorcoremeltdown.sizzletracker.io.PresetIo] format, stored one folder
+ * per instrument/effect.
+ */
@Composable
-private fun PresetButton(label: String, onClick: () -> Unit) {
+private fun PresetBar(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
- Text(
- "[$label]",
- color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
- modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClick() } }.padding(4.dp),
- )
+ val context = LocalContext.current
+ @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after save/import
+ val type = slot.type ?: return
+
+ val names = vm.presetNames(type)
+ var showSave by remember { mutableStateOf(false) }
+ var confirmDelete by remember { mutableStateOf(null) }
+ // The preset currently chosen in the dropdown (what Delete acts on). Falls back
+ // to the slot's name, then the first available preset.
+ var selected by remember(slot.index) { mutableStateOf(null) }
+ val current = selected?.takeIf { it in names } ?: slot.name.takeIf { it in names } ?: names.firstOrNull()
+
+ // System file picker for importing a preset (text preset or sampler .zip bundle).
+ val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ uri ?: return@rememberLauncherForActivityResult
+ runCatching {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ }.getOrNull()?.let { bytes -> vm.importPreset(slot, bytes) }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(
+ Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // DELETE kept at the far LEFT, isolated by a gap, so reaching for the
+ // preset dropdown (now on the right) can't accidentally hit it.
+ if (current != null) {
+ RetroButton("DELETE", danger = true) { confirmDelete = current }
+ Spacer(Modifier.width(20.dp))
+ }
+ RetroButton("SAVE") { showSave = true }
+ RetroButton("IMPORT") {
+ // Accept text presets and (for the Sampler) zip bundles.
+ importer.launch(arrayOf("application/zip", "text/*", "application/octet-stream", "*/*"))
+ }
+ RetroButton("SHARE") {
+ val name = slot.name.ifBlank { type.displayName }
+ vm.savePreset(slot, name) // ensure a file exists to share
+ vm.presetShareFile(type, name)?.let { sharePresetFile(context, it) }
+ }
+ Spacer(Modifier.width(20.dp))
+ // Rightmost: the preset browser dropdown.
+ if (names.isNotEmpty() && current != null) {
+ RetroDropdown(
+ label = "PRESET",
+ options = names,
+ selected = current,
+ onSelect = { selected = it; vm.loadPreset(slot, it) },
+ )
+ } else {
+ Text("no presets", color = c.textDim,
+ fontFamily = FontFamily.Monospace, fontSize = 11.sp)
+ }
+ }
+ Text(
+ "Presets are plain text, saved per device under the app's storage. " +
+ "Sampler presets carry the sample (shared as a .zip bundle).",
+ color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
+ )
+ }
+
+ if (showSave) {
+ var name by remember { mutableStateOf(slot.name.ifBlank { type.displayName }) }
+ AlertDialog(
+ onDismissRequest = { showSave = false },
+ title = { Text("Save preset") },
+ text = {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ singleLine = true,
+ label = { Text("Name") },
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ if (name.isNotBlank()) vm.savePreset(slot, name.trim())
+ showSave = false
+ }) { Text("Save") }
+ },
+ dismissButton = { TextButton(onClick = { showSave = false }) { Text("Cancel") } },
+ )
+ }
+
+ confirmDelete?.let { name ->
+ AlertDialog(
+ onDismissRequest = { confirmDelete = null },
+ title = { Text("Delete preset") },
+ text = { Text("Delete \"$name\"? This cannot be undone.") },
+ confirmButton = {
+ TextButton(onClick = {
+ vm.deletePreset(type, name)
+ selected = null
+ confirmDelete = null
+ }) { Text("Delete") }
+ },
+ dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
+ )
+ }
+}
+
+/** Launch the system share sheet for a saved preset file (text) or bundle (zip). */
+private fun sharePresetFile(context: android.content.Context, file: java.io.File) {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val mime = if (file.name.endsWith(".zip")) "application/zip" else "text/plain"
+ val send = Intent(Intent.ACTION_SEND).apply {
+ type = mime
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(send, "Share preset"))
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt
index 987542d..06e00cd 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt
@@ -3,6 +3,7 @@ package com.reactorcoremeltdown.sizzletracker.ui.tracker
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -28,7 +29,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
@@ -50,10 +51,16 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
* scrollable roll body. The ruler and body share one horizontal scroll so they
* stay aligned; the roll body is drawn on a single [Canvas] for performance.
*
- * Each lane is tied to its own tracker block (lane i ⟷ block i). Tapping a beat
- * cell toggles that lane's block on/off at that beat; when the playhead crosses
- * an enabled cell, the sequencer plays that block. Tap a lane number to edit that
- * block in the tracker above.
+ * Each lane is tied to its own tracker block (lane i ⟷ block i). Cells are NOT
+ * toggled by tapping; instead you SELECT cells (tap a single cell, or long-press
+ * and drag to sweep a rectangle) and then use the FILL / CLEAR toggle in the loop
+ * toolbar to enable or disable the whole selection at once. When the playhead
+ * crosses an enabled cell, the sequencer plays that block. Tap a lane number to
+ * edit that block in the tracker above.
+ *
+ * The ruler also shows the A and B loop regions as coloured bands; the A/B
+ * buttons in the toolbar assign the current selection to a region, and tapping a
+ * button that is already highlighted removes that region (and its band).
*/
@Composable
fun ArrangementRoll(vm: AppViewModel) {
@@ -80,10 +87,25 @@ fun ArrangementRoll(vm: AppViewModel) {
val beatWidthPx = with(density) { beatWidth.toPx() }
val rulerHeightPx = with(density) { rulerHeight.toPx() }
- var selStart by remember { mutableIntStateOf(-1) }
- var selEnd by remember { mutableIntStateOf(-1) }
+ // Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none.
+ // Unlike before, selecting does NOT change any cell; the toolbar toggle acts
+ // on the selection. Read in composition (for the toolbar's FILL/CLEAR label)
+ // and captured by the Canvas draw below.
+ var selLane0 by remember { mutableIntStateOf(-1) }
+ var selBeat0 by remember { mutableIntStateOf(-1) }
+ var selLane1 by remember { mutableIntStateOf(-1) }
+ var selBeat1 by remember { mutableIntStateOf(-1) }
+
+ val hasSelection = selBeat0 >= 0
+ val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
+ val beatRange = if (hasSelection) minOf(selBeat0, selBeat1)..maxOf(selBeat0, selBeat1) else IntRange.EMPTY
+ // Whether every cell in the selection is already filled (drives FILL vs CLEAR).
+ val selectionAllFilled = hasSelection &&
+ laneRange.all { lane -> beatRange.all { beat -> arr.patternAt(lane, beat) != ArrModel.EMPTY } }
val rulerStyle = remember(c) { TextStyle(color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
+ val regionAStyle = remember(c) { TextStyle(color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
+ val regionBStyle = remember(c) { TextStyle(color = c.playhead, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
// Follow the playhead by scrolling horizontally. Collects the transport flow
// directly rather than keying a LaunchedEffect on a composition read of the beat,
@@ -100,6 +122,32 @@ fun ArrangementRoll(vm: AppViewModel) {
}
}
+ // Enable or disable every selected cell. A mixed/empty selection fills; a
+ // fully-filled selection clears — so the toolbar button is a true toggle.
+ fun toggleSelectedCells() {
+ if (!hasSelection) return
+ val clear = selectionAllFilled
+ for (lane in laneRange) for (beat in beatRange) {
+ arr.set(lane, beat, if (clear) ArrModel.EMPTY else lane)
+ }
+ vm.bumpForToolbar()
+ }
+
+ fun assignRegion(region: LoopRegion) {
+ if (!hasSelection) return
+ region.start = beatRange.first
+ region.end = beatRange.last + 1
+ region.enabled = true
+ vm.bumpForToolbar()
+ }
+
+ fun clearRegion(region: LoopRegion) {
+ region.enabled = false
+ region.start = 0
+ region.end = 0
+ vm.bumpForToolbar()
+ }
+
Column(Modifier.fillMaxSize().background(c.background)) {
Box(Modifier.weight(1f).fillMaxWidth()) {
Row(Modifier.fillMaxSize()) {
@@ -131,6 +179,8 @@ fun ArrangementRoll(vm: AppViewModel) {
Modifier
.width(beatWidth * arr.lengthBeats)
.fillMaxHeight()
+ // Tap selects a single cell (no toggle). The ruler is
+ // display-only, so ruler taps are ignored.
.pointerInput(arr, sig) {
detectTapGestures { off ->
if (off.y < rulerHeightPx) return@detectTapGestures
@@ -138,12 +188,29 @@ fun ArrangementRoll(vm: AppViewModel) {
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
.coerceIn(0, ArrModel.LANE_COUNT - 1)
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 1)
- // Lane i is tied to block i: toggle block i at this beat.
- if (arr.patternAt(lane, beat) != ArrModel.EMPTY) arr.set(lane, beat, ArrModel.EMPTY)
- else arr.set(lane, beat, lane)
- if (selStart < 0) selStart = beat else selEnd = beat
- vm.bumpForToolbar()
+ selLane0 = lane; selLane1 = lane
+ selBeat0 = beat; selBeat1 = beat
}
+ }
+ // Long-press then drag sweeps out a rectangular selection.
+ .pointerInput(arr, sig) {
+ fun laneAt(off: Offset): Int {
+ val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT
+ return ((off.y - rulerHeightPx) / laneH).toInt()
+ .coerceIn(0, ArrModel.LANE_COUNT - 1)
+ }
+ fun beatAt(off: Offset): Int =
+ (off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 1)
+ detectDragGesturesAfterLongPress(
+ onDragStart = { off ->
+ selLane0 = laneAt(off); selLane1 = selLane0
+ selBeat0 = beatAt(off); selBeat1 = selBeat0
+ },
+ onDrag = { change, _ ->
+ selLane1 = laneAt(change.position)
+ selBeat1 = beatAt(change.position)
+ },
+ )
},
) {
// Subscribe the draw phase to edit revisions (live state read,
@@ -170,6 +237,28 @@ fun ArrangementRoll(vm: AppViewModel) {
}
}
+ // A / B loop-region bands on the ruler (drawn over the ticks).
+ fun drawRegionBand(region: LoopRegion, color: androidx.compose.ui.graphics.Color, letter: String, style: TextStyle) {
+ if (!region.isValid) return
+ val x0 = region.start * beatWidthPx
+ val x1 = region.end * beatWidthPx
+ drawRect(color.copy(alpha = 0.25f), Offset(x0, 0f), Size(x1 - x0, rulerHeightPx))
+ drawLine(color, Offset(x0, 0f), Offset(x1, 0f), strokeWidth = 2f)
+ val layout = glyphs.measure(letter, style)
+ // If the region starts on a bar boundary a bar number sits
+ // at x0; nudge the A/B letter past its (1- or 2-digit) width
+ // plus a small gap so they never overlap.
+ val letterX = if (region.start % beatsPerBar == 0) {
+ val barNum = region.start / beatsPerBar + 1
+ x0 + 2f + glyphs.measure("$barNum", rulerStyle).size.width + 3f
+ } else {
+ x0 + 2f
+ }
+ drawText(layout, topLeft = Offset(letterX, (rulerHeightPx - layout.size.height) / 2f))
+ }
+ drawRegionBand(arr.regionA, c.accent, "A", regionAStyle)
+ drawRegionBand(arr.regionB, c.playhead, "B", regionBStyle)
+
// Lane cells.
for (lane in 0 until ArrModel.LANE_COUNT) {
val laneY = rulerHeightPx + lane * laneH
@@ -177,7 +266,7 @@ fun ArrangementRoll(vm: AppViewModel) {
val x = beat * beatWidthPx
val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY
val onPlayhead = transport.isPlaying && beat == transport.currentBeat
- val inSel = selStart in 0..beat && beat < maxOf(selStart, selEnd + 1)
+ val inSel = selBeat0 >= 0 && lane in laneRange && beat in beatRange
val fill = when {
filled -> c.accent.copy(alpha = 0.85f)
onPlayhead -> c.playhead.copy(alpha = 0.3f)
@@ -191,6 +280,20 @@ fun ArrangementRoll(vm: AppViewModel) {
drawLine(c.background, Offset(0f, laneY), Offset(w, laneY), strokeWidth = 1f)
}
+ // Selection outline (rectangle across the selected lanes/beats).
+ if (selBeat0 >= 0) {
+ val bx0 = beatRange.first * beatWidthPx
+ val bx1 = (beatRange.last + 1) * beatWidthPx
+ val ly0 = rulerHeightPx + laneRange.first * laneH
+ val ly1 = rulerHeightPx + (laneRange.last + 1) * laneH
+ drawRect(
+ c.accent,
+ topLeft = Offset(bx0, ly0),
+ size = Size(bx1 - bx0, ly1 - ly0),
+ style = Stroke(width = 2f),
+ )
+ }
+
// Playhead marker across the whole roll.
if (transport.isPlaying) {
val px = transport.currentBeat * beatWidthPx
@@ -204,8 +307,11 @@ fun ArrangementRoll(vm: AppViewModel) {
LoopToolbar(
vm = vm,
arr = arr,
- onAssignA = { assign(arr.regionA, selStart, selEnd); vm.bumpForToolbar() },
- onAssignB = { assign(arr.regionB, selStart, selEnd); vm.bumpForToolbar() },
+ hasSelection = hasSelection,
+ selectionAllFilled = selectionAllFilled,
+ onToggleCells = { toggleSelectedCells() },
+ onTapA = { if (arr.regionA.isValid) clearRegion(arr.regionA) else assignRegion(arr.regionA) },
+ onTapB = { if (arr.regionB.isValid) clearRegion(arr.regionB) else assignRegion(arr.regionB) },
)
}
}
@@ -214,42 +320,44 @@ fun ArrangementRoll(vm: AppViewModel) {
private fun LoopToolbar(
vm: AppViewModel,
arr: ArrModel,
- onAssignA: () -> Unit,
- onAssignB: () -> Unit,
+ hasSelection: Boolean,
+ selectionAllFilled: Boolean,
+ onToggleCells: () -> Unit,
+ onTapA: () -> Unit,
+ onTapB: () -> Unit,
) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
Row(
- Modifier.fillMaxWidth().background(c.surface).padding(6.dp),
+ Modifier.fillMaxWidth().background(c.surface).horizontalScroll(rememberScrollState()).padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RetroButton("LOOP", active = arr.loopEnabled) {
arr.loopEnabled = !arr.loopEnabled; vm.bumpForToolbar()
}
- RegionControl("A", arr.regionA, onAssign = onAssignA, vm = vm)
- RegionControl("B", arr.regionB, onAssign = onAssignB, vm = vm)
+ // Fill/clear the current cell selection (replaces tap-to-toggle on cells).
+ // Fixed width so the button doesn't resize as the label toggles FILL/CLEAR.
+ RetroButton(
+ if (selectionAllFilled) "CLEAR" else "FILL",
+ active = hasSelection, width = 72.dp, onClick = onToggleCells,
+ )
+ RegionControl("A", arr.regionA, onTap = onTapA, vm = vm)
+ RegionControl("B", arr.regionB, onTap = onTapB, vm = vm)
}
}
@Composable
-private fun RegionControl(name: String, region: LoopRegion, onAssign: () -> Unit, vm: AppViewModel) {
+private fun RegionControl(name: String, region: LoopRegion, onTap: () -> Unit, vm: AppViewModel) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
Row(verticalAlignment = Alignment.CenterVertically) {
- RetroButton(name, active = region.enabled) {
- region.enabled = !region.enabled; onAssign()
- }
- RetroButton("-") { region.repeats = (region.repeats - 1).coerceAtLeast(1); vm.bumpForToolbar() }
+ // Highlighted when the region is set; tapping a highlighted button removes
+ // the region, otherwise it assigns the current selection to it.
+ RetroButton(name, active = region.isValid, onClick = onTap)
+ // Repeat count updates live during playback (no transport reset).
+ RetroButton("-") { vm.changeRegionRepeats(region, -1) }
Text(" x${region.repeats} ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
- RetroButton("+") { region.repeats++; vm.bumpForToolbar() }
+ RetroButton("+") { vm.changeRegionRepeats(region, +1) }
}
}
-
-/** Copy the tap-selection into a loop region. */
-private fun assign(region: LoopRegion, selStart: Int, selEnd: Int) {
- if (selStart < 0) return
- region.start = minOf(selStart, selEnd.coerceAtLeast(selStart))
- region.end = maxOf(selStart, selEnd) + 1
- region.enabled = true
-}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/CellEditorPopup.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/CellEditorPopup.kt
deleted file mode 100644
index e4bdf4e..0000000
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/CellEditorPopup.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-package com.reactorcoremeltdown.sizzletracker.ui.tracker
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.reactorcoremeltdown.sizzletracker.model.Cell
-import com.reactorcoremeltdown.sizzletracker.model.CellColumn
-import com.reactorcoremeltdown.sizzletracker.model.Pitch
-import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
-import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
-import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
-import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
-import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
-
-/**
- * The touch note-entry popup, opened by long-pressing a cell in the pattern grid.
- * Which editor it shows depends on the column that was long-pressed:
- * - NOTE : one octave of piano keys plus an octave switcher (- OCTAVE n +).
- * - VELOCITY : a value readout with - / + steppers.
- * - CHANNEL : a value readout with - / + steppers.
- *
- * It replaces the old vertical drag-to-edit gesture — touch entry is now explicit
- * (long-press → pick), matching the keyboard/gamepad path (which nudges values and
- * resumes from the last entered note/channel).
- */
-@Composable
-fun CellEditorPopup(vm: AppViewModel, column: CellColumn, onDismiss: () -> Unit) {
- val c = LocalRetro.current
- @Suppress("UNUSED_EXPRESSION") vm.revision // refresh readouts after each edit
- val cell = vm.cellUnderCursor()
-
- // Full-screen scrim; a tap outside the card dismisses.
- Box(
- Modifier
- .fillMaxSize()
- .background(c.background.copy(alpha = 0.88f))
- .pointerInput(Unit) { detectTapGestures { onDismiss() } },
- contentAlignment = Alignment.Center,
- ) {
- // The card. Its own (empty) tap handler swallows taps so they don't dismiss.
- Column(
- Modifier
- .then(if (column == CellColumn.NOTE) Modifier.fillMaxWidth(0.96f) else Modifier)
- .border(1.dp, c.accent, RectangleShape)
- .background(c.surface)
- .pointerInput(Unit) { detectTapGestures { } }
- .padding(14.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Text(
- "T${vm.cursorTrack + 1} LINE ${lineLabel(vm.cursorLine)}",
- color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
- )
- when (column) {
- CellColumn.NOTE -> NoteEditor(vm, cell)
- CellColumn.VELOCITY -> Stepper("VELOCITY", velLabel(cell)) { vm.editFocused(it) }
- CellColumn.CHANNEL -> Stepper("CHANNEL", chanLabel(cell)) { vm.editFocused(it) }
- }
- RetroButton("DONE", onClick = onDismiss)
- }
- }
-}
-
-/** A real one-octave piano keyboard + an octave switcher, writing the note on tap.
- * Only in-scale keys are enabled (all keys when the scale is Chromatic). */
-@Composable
-private fun NoteEditor(vm: AppViewModel, cell: Cell) {
- val c = LocalRetro.current
- @Suppress("UNUSED_VARIABLE") val rev = vm.revision // re-highlight the key after entry
- // Start on the octave of the cell's note, or of the last note entered if empty.
- val seedMidi = if (cell.isPlayable) cell.note else vm.lastNote
- var octave by remember { mutableIntStateOf((seedMidi / 12 - 1).coerceIn(0, 9)) }
- // Pitch-classes the current scale/root permits (Chromatic yields all twelve).
- val root = vm.project.rootNote
- val inKey = remember(vm.project.scale, root) {
- vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
- }
-
- Column(
- Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- RetroButton("-") { octave = (octave - 1).coerceAtLeast(0) }
- Text("OCTAVE $octave", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
- RetroButton("+") { octave = (octave + 1).coerceAtMost(9) }
- }
- PianoOctave(Modifier.fillMaxWidth().height(104.dp)) { pc, isBlack, keyMod ->
- val midi = ((octave + 1) * 12 + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
- val enabled = pc in inKey
- PianoKey(
- label = Pitch.name(midi).take(2),
- isBlack = isBlack,
- enabled = enabled,
- selected = cell.isPlayable && cell.note == midi,
- modifier = if (enabled) keyMod.clickable { vm.setFocusedNote(midi) } else keyMod,
- )
- }
- Text(
- "NOTE: " + when {
- cell.isPlayable -> Pitch.name(cell.note)
- cell.isNoteOff -> "==="
- else -> "---"
- },
- color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
- )
- }
-}
-
-/** A titled value readout with - / + steppers; [onStep] receives -1 or +1. */
-@Composable
-private fun Stepper(title: String, value: String, onStep: (Int) -> Unit) {
- val c = LocalRetro.current
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Text(title, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp)
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(20.dp),
- ) {
- RetroButton("-") { onStep(-1) }
- Text(value, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 22.sp)
- RetroButton("+") { onStep(+1) }
- }
- }
-}
-
-private fun velLabel(cell: Cell) = cell.velocity.toString(16).uppercase().padStart(2, '0')
-private fun chanLabel(cell: Cell) = cell.channel.toString().padStart(2, '0')
-private fun lineLabel(line: Int) = line.toString(16).uppercase().padStart(2, '0')
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt
index ad13907..0f68cc5 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt
@@ -7,18 +7,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
@@ -42,7 +37,8 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
*
* Visuals: beat and bar rows are tinted distinctly; the four vertical tracks get
* an alternating tint + separator lines; the cursor cell and playhead row are
- * highlighted. Gestures are unchanged (tap to focus, vertical drag to edit).
+ * highlighted. The only touch gesture is tap-to-focus; notes are entered with the
+ * punch-in keyboard or external controllers.
*/
@Composable
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
@@ -58,11 +54,8 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
val transportState = vm.transport.collectAsState()
val pattern = vm.project.activePattern()
val sig = vm.project.timeSignature
- val haptic = LocalHapticFeedback.current
val measurer = rememberTextMeasurer()
val glyphs = remember(measurer) { GlyphCache(measurer) }
- // Which column's long-press editor popup is open (null = none).
- var editorColumn by remember { mutableStateOf(null) }
// Cache the text styles per palette (fontSize is resolved with the ambient density).
val mono = FontFamily.Monospace
@@ -113,20 +106,12 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
Modifier
.fillMaxSize()
.pointerInput(pattern, widthPx, heightPx) {
- detectTapGestures(
- // Tap: just move the cursor to the cell/column.
- onTap = { off ->
- hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
- },
- // Long-press: focus the cell and open its value editor popup.
- onLongPress = { off ->
- hitTest(off.x, off.y)?.let { (t, col, line) ->
- vm.focusCell(t, line, col)
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- editorColumn = col
- }
- },
- )
+ // Tap moves the cursor to the cell/column. Note entry is done with
+ // the punch-in keyboard or external controllers — there is no
+ // long-press cell editor.
+ detectTapGestures { off ->
+ hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
+ }
},
) {
// Subscribe the DRAW PHASE to edit revisions. Model mutations (edit,
@@ -205,11 +190,6 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
}
}
}
-
- // Long-press value editor, overlaid on the grid area.
- editorColumn?.let { col ->
- CellEditorPopup(vm, col) { editorColumn = null }
- }
}
}
diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt
index b4ad240..0c3a886 100644
--- a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt
+++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt
@@ -54,9 +54,14 @@ fun TrackerScreen(vm: AppViewModel) {
.height(2.dp)
.background(c.accent),
)
- // Lower section: the arrangement roll, squashed to give the grid its rows.
+ // Lower section: the arrangement roll, or — in punch-in mode — the stacked
+ // touch keyboard, so the bottom real estate serves note entry too.
Box(Modifier.weight(1f).fillMaxWidth()) {
- ArrangementRoll(vm)
+ if (vm.punchInMode) {
+ com.reactorcoremeltdown.sizzletracker.ui.StackedKeyboard(vm, punchIn = true, Modifier.fillMaxSize())
+ } else {
+ ArrangementRoll(vm)
+ }
}
}
}
@@ -98,18 +103,12 @@ private fun TransportToolbar(vm: AppViewModel) {
onSelect = vm::setTimeSignature,
)
}
- // Row 2: block + length + scale + root.
+ // Row 2: length + scale + root, then the punch-in/nav skip step at the end.
+ // (The active block is chosen from the arrangement, so no BLK selector here.)
Row(
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
- RetroDropdown(
- label = "BLK",
- options = project.patterns.map { it.id },
- selected = vm.activeBlock,
- optionLabel = { "${it + 1}" },
- onSelect = vm::setActiveBlock,
- )
RetroDropdown(
label = "LEN",
options = project.timeSignature.lengthOptions,
@@ -130,6 +129,13 @@ private fun TransportToolbar(vm: AppViewModel) {
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
)
+ // Rows the cursor jumps after a punch-in and per external up/down step.
+ RetroDropdown(
+ label = "SKIP",
+ options = listOf(1, 2, 4, 8),
+ selected = vm.skipStep,
+ onSelect = vm::updateSkipStep,
+ )
}
// Row 3: selection + clipboard edit operations. SEL toggles rectangular
// select mode (the cursor then drags out a region); the rest act on that
@@ -145,6 +151,8 @@ private fun TransportToolbar(vm: AppViewModel) {
RetroButton("DEL", onClick = vm::deleteSelection)
// Insert a note-off ("===") at the cursor.
RetroButton("OFF ===", onClick = vm::noteOffFocused)
+ // Swap the bottom half between the arranger and the punch-in keyboard.
+ RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
}
}
}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..eadaa7a
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+