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 @@ + + + + + +