Big batch update: the tracker is mostly usable

This commit is contained in:
Reactorcoremeltdown
2026-07-15 16:22:04 +02:00
parent d380f8643b
commit a4c8ba2080
46 changed files with 4571 additions and 834 deletions

View File

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

View File

@@ -51,6 +51,18 @@
android:resource="@xml/usb_device_filter" />
</activity>
<!-- Exposes saved preset files (and zip bundles) as content:// URIs so they
can be shared to other apps via the system share sheet. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Foreground service that owns the audio engine so playback survives
when the UI is not visible, and drives the media notification. -->
<service

View File

@@ -9,7 +9,14 @@
#include <jni.h>
#include <android/log.h>
#include <oboe/Oboe.h>
#include <atomic>
#include <memory>
#include <time.h>
// 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<bool> 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<float *>(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<oboe::AudioStream> 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<jfloatArray>(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<jfloatArray>(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();

View File

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

View File

@@ -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)
}
}
}

View File

@@ -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<FloatArray> = 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
}
}
}

View File

@@ -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<FxUnit>() }
* 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<FxUnit>> =
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<TransportState> = _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<FxUnit>() }
for (ch in 0 until Pattern.TRACK_COUNT) {
val chain = channelChains[ch]
chain.clear()
val units = ArrayList<FxUnit>(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<Int>()
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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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<Preferences> 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<Int, String>): String =
m.entries.joinToString("\n") { "${it.key}=${it.value}" }
private fun decodeIntMap(s: String): MutableMap<Int, String> {
val out = mutableMapOf<Int, String>()
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<GamepadChord, String>): String =
m.entries.joinToString("\n") { "${it.key.modifier}:${it.key.code}=${it.value}" }
private fun decodeChordMap(s: String): MutableMap<GamepadChord, String> {
val out = mutableMapOf<GamepadChord, String>()
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")
}
}

View File

@@ -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<Int, String> = defaultBindings(),
var bindings: MutableMap<GamepadChord, String> = 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<Int>()
/** 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<Int>()
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<Int, String> = 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<GamepadChord, String> = 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",
)
}
}

View File

@@ -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. */

View File

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

View File

@@ -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).

View File

@@ -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<Int, String> = 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<Int, String> = 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",
)
}
}

View File

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

View File

@@ -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:
*
* <baseDir>/NES_SYNTH/Lead.szp
* <baseDir>/SAMPLER/Kick.szp + <baseDir>/SAMPLER/Kick.wav
* <baseDir>/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<String> =
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<Int, String>()
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<Int, String>): 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<String> =
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"
}
}

View File

@@ -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 = ""

View File

@@ -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)
}

View File

@@ -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<Preferences> 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")
}
}

View File

@@ -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]).
*
* <baseDir>/My Tune.szg ← full project (what Save/Load use)
* <baseDir>/.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<String> =
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"
}
}

View File

@@ -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<Preset> = 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<String> 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)
}
}

View File

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

View File

@@ -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<MixerChannel> = 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<MixerChannel> = 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,
) {

View File

@@ -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<MutableList<Cell>> =
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)
}
}

View File

@@ -28,6 +28,8 @@ data class ParamSpec(
val min: Float = 0f,
val max: Float = 1f,
val choices: List<String> = 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<Pair<String, Double>> = 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++ }
}

View File

@@ -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()) {

View File

@@ -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<Int, Int>()
/** 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<String?>(null); private set
/** Human-readable name of the chosen output folder (for the toolbar). */
var recordDirName by mutableStateOf<String?>(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<String?>(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<String> = 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<String> =
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]

View File

@@ -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<Int>? = 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<Int>?,
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)
}
},
)
}
}

View File

@@ -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),
)
}

View File

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

View File

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

View File

@@ -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 (ZM, QU) 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<String?>(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<String?>(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<String, List<BindAction>> = 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<String, List<BindAction>> = 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(

View File

@@ -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,
)
}
}

View File

@@ -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,
),

View File

@@ -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 loadedimport a WAV or record one."
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers to trim",
if (sample == null) "Pad emptyImport 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<FloatArray, FloatArray> {
@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<FloatArray, FloatArray> {
val mins = FloatArray(buckets)
val maxs = FloatArray(buckets)
if (data.isEmpty()) return mins to maxs

View File

@@ -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)
}
},
)
}
}

View File

@@ -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<Int?>(null) }
var picking by remember { mutableStateOf<Int?>(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<String?>(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<String?>(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"))
}

View File

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

View File

@@ -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')

View File

@@ -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<CellColumn?>(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 }
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider roots. Preset files and share bundles live under
filesDir/presets, so exposing that subtree is enough to share them. -->
<paths>
<files-path name="presets" path="presets/" />
<files-path name="songs" path="songs/" />
</paths>