Big batch update: the tracker is mostly usable
This commit is contained in:
@@ -51,14 +51,14 @@ android {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A realistic-performance variant for testing UI responsiveness on-device.
|
// A realistic-performance "staging" variant for testing UI responsiveness
|
||||||
// The `debug` build is `debuggable = true`, which makes ART run the app
|
// on-device. The `debug` build is `debuggable = true`, which makes ART run
|
||||||
// un-optimised and makes Compose several times slower — so measuring UI
|
// the app un-optimised and makes Compose several times slower — so measuring
|
||||||
// "snappiness" on a debug build is misleading. This variant flips
|
// UI "snappiness" on a debug build is misleading. This variant flips
|
||||||
// debuggability off (the single biggest perf lever) while reusing the debug
|
// debuggability off (the single biggest perf lever) while reusing the debug
|
||||||
// signing key, so it still installs without a release keystore. R8/shrinking
|
// signing key, so it still installs without a release keystore. R8/shrinking
|
||||||
// stays off to avoid needing JNI/Compose keep-rules for now.
|
// stays off to avoid needing JNI/Compose keep-rules for now.
|
||||||
create("profile") {
|
create("staging") {
|
||||||
initWith(getByName("debug"))
|
initWith(getByName("debug"))
|
||||||
isDebuggable = false
|
isDebuggable = false
|
||||||
isJniDebuggable = false
|
isJniDebuggable = false
|
||||||
|
|||||||
@@ -51,6 +51,18 @@
|
|||||||
android:resource="@xml/usb_device_filter" />
|
android:resource="@xml/usb_device_filter" />
|
||||||
</activity>
|
</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
|
<!-- Foreground service that owns the audio engine so playback survives
|
||||||
when the UI is not visible, and drives the media notification. -->
|
when the UI is not visible, and drives the media notification. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -9,7 +9,14 @@
|
|||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <android/log.h>
|
#include <android/log.h>
|
||||||
#include <oboe/Oboe.h>
|
#include <oboe/Oboe.h>
|
||||||
|
#include <atomic>
|
||||||
#include <memory>
|
#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 LOG_TAG "SizzleNative"
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
#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;
|
jmethodID g_renderMethod = nullptr;
|
||||||
jfloatArray g_buffer = nullptr; // reused Java float[] of g_frames length
|
jfloatArray g_buffer = nullptr; // reused Java float[] of g_frames length
|
||||||
int g_frames = 0;
|
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.
|
// Fetch a JNIEnv for the (persistent) audio callback thread, attaching once.
|
||||||
JNIEnv *callbackEnv() {
|
JNIEnv *callbackEnv() {
|
||||||
@@ -37,6 +57,22 @@ public:
|
|||||||
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData,
|
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData,
|
||||||
int32_t numFrames) override {
|
int32_t numFrames) override {
|
||||||
auto *out = static_cast<float *>(audioData);
|
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();
|
JNIEnv *env = callbackEnv();
|
||||||
if (!env || !g_callback || !g_renderMethod || !g_buffer) {
|
if (!env || !g_callback || !g_renderMethod || !g_buffer) {
|
||||||
for (int i = 0; i < numFrames; ++i) out[i] = 0.0f;
|
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;
|
const int n = numFrames < g_frames ? numFrames : g_frames;
|
||||||
// Ask Kotlin to render n frames into the reused Java buffer ...
|
// Ask Kotlin to render n frames into the reused Java buffer ...
|
||||||
env->CallVoidMethod(g_callback, g_renderMethod, g_buffer, n);
|
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);
|
env->GetFloatArrayRegion(g_buffer, 0, n, out);
|
||||||
for (int i = n; i < numFrames; ++i) out[i] = 0.0f;
|
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;
|
return oboe::DataCallbackResult::Continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -55,6 +141,67 @@ public:
|
|||||||
SizzleCallback g_dataCallback;
|
SizzleCallback g_dataCallback;
|
||||||
std::shared_ptr<oboe::AudioStream> g_stream;
|
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
|
} // namespace
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
|
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
|
extern "C" JNIEXPORT jboolean JNICALL
|
||||||
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStart(
|
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStart(
|
||||||
JNIEnv *env, jobject /*thiz*/, jobject callback, jint sampleRate, jint framesPerCallback) {
|
JNIEnv *env, jobject /*thiz*/, jobject callback, jint sampleRate, jint framesPerCallback) {
|
||||||
g_frames = framesPerCallback;
|
|
||||||
g_callback = env->NewGlobalRef(callback);
|
g_callback = env->NewGlobalRef(callback);
|
||||||
jclass cls = env->GetObjectClass(callback);
|
jclass cls = env->GetObjectClass(callback);
|
||||||
g_renderMethod = env->GetMethodID(cls, "renderAudio", "([FI)V");
|
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;
|
// Allocate the reused Java render buffer up front, sized to our maximum callback,
|
||||||
builder.setDirection(oboe::Direction::Output)
|
// so the stream can be transparently reopened on a disconnect without touching
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
// JNI from Oboe's (non-Java) error thread. The data callback only ever renders
|
||||||
->setSharingMode(oboe::SharingMode::Shared)
|
// min(numFrames, g_frames) frames, and low-latency bursts are far smaller.
|
||||||
->setFormat(oboe::AudioFormat::Float)
|
g_frames = kMaxBufferFrames;
|
||||||
->setChannelCount(oboe::ChannelCount::Mono)
|
if (g_frames < framesPerCallback) g_frames = framesPerCallback;
|
||||||
->setSampleRate(sampleRate)
|
g_buffer = static_cast<jfloatArray>(env->NewGlobalRef(env->NewFloatArray(g_frames)));
|
||||||
->setFramesPerDataCallback(framesPerCallback)
|
|
||||||
->setDataCallback(&g_dataCallback);
|
|
||||||
|
|
||||||
oboe::Result result = builder.openStream(g_stream);
|
if (!openAndStartStream()) {
|
||||||
if (result != oboe::Result::OK) {
|
env->DeleteGlobalRef(g_buffer);
|
||||||
LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
|
g_buffer = nullptr;
|
||||||
return JNI_FALSE;
|
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;
|
return JNI_TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT void JNICALL
|
extern "C" JNIEXPORT void JNICALL
|
||||||
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStop(
|
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStop(
|
||||||
JNIEnv *env, jobject /*thiz*/) {
|
JNIEnv *env, jobject /*thiz*/) {
|
||||||
|
g_wantRunning = false; // stop the disconnect-recovery from reopening
|
||||||
if (g_stream) {
|
if (g_stream) {
|
||||||
g_stream->stop();
|
g_stream->stop();
|
||||||
g_stream->close();
|
g_stream->close();
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
this,
|
this,
|
||||||
viewModelFactory {
|
viewModelFactory {
|
||||||
initializer {
|
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]
|
)[AppViewModel::class.java]
|
||||||
@@ -83,25 +88,40 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
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()
|
midi.stop()
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Hardware input forwarding ----
|
// ---- Hardware input forwarding ----
|
||||||
// Physical keyboards and most gamepad buttons arrive as key events; we give
|
// Gamepad events are intercepted at DISPATCH time — before Compose's focus
|
||||||
// the gamepad handler first refusal, then the keyboard handler.
|
// 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 =
|
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 =
|
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
|
||||||
keyboard.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
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) ----
|
// ---- (helper removed; viewModel.palette is read directly in setContent) ----
|
||||||
|
|
||||||
// ---- Permissions ----
|
// ---- Permissions ----
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ package com.reactorcoremeltdown.sizzletracker
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.BindingStore
|
||||||
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
|
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
|
||||||
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
|
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
|
||||||
import com.reactorcoremeltdown.sizzletracker.input.KeyboardInput
|
import com.reactorcoremeltdown.sizzletracker.input.KeyboardInput
|
||||||
import com.reactorcoremeltdown.sizzletracker.input.MidiInput
|
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 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
|
* 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 gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
|
||||||
val midiInput: MidiInput by lazy { MidiInput(this, 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() {
|
override fun onCreate() {
|
||||||
super.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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.media.AudioTrack
|
|||||||
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.SamplerPads
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
|
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
@@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.tanh
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The real-time sound engine. It owns one [AudioTrack] and a dedicated render
|
* 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-LOOP mode (when the arrangement is empty): the currently-edited
|
||||||
* pattern loops, so the Tracker tab is always audible while you edit.
|
* 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
|
* Voices are pooled PER MIXER BUS: each of the [TRACK_COUNT] buses owns a pool of
|
||||||
* while each (lane, track) stays monophonic (classic tracker channel behaviour).
|
* [POLYPHONY] synth + [POLYPHONY] sample voices, so a single instrument can sound
|
||||||
* A voice's mixer channel is its track index, so mute/solo/volume apply per track.
|
* 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
|
* 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
|
* still falls back to the synth. Each bus pool has both [SynthVoice]s and
|
||||||
* [SampleVoice]; the channel's instrument type decides which one a note triggers.
|
* [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 /
|
* Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
|
||||||
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
|
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
|
||||||
* Kotlin/AudioTrack loop for an Oboe (C++/AAudio) engine is a later milestone.
|
* Kotlin/AudioTrack loop for an Oboe (C++/AAudio) engine is a later milestone.
|
||||||
@@ -49,25 +53,73 @@ import kotlin.math.sin
|
|||||||
class AudioEngine(
|
class AudioEngine(
|
||||||
private val sampleRate: Int = 48_000,
|
private val sampleRate: Int = 48_000,
|
||||||
) {
|
) {
|
||||||
// Sequencer voices: one synth + one sample voice per (lane, track).
|
// Sequencer voices: a per-mixer-bus pool of POLYPHONY synth + POLYPHONY sample
|
||||||
private val seqVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SynthVoice(sampleRate) }
|
// voices, laid out as bus*POLYPHONY + i. A note plays whatever instrument its
|
||||||
private val sampleVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SampleVoice(sampleRate) }
|
// 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.
|
// ... plus a small pool for live auditioning from keyboard / MIDI / pads.
|
||||||
private val liveVoices = Array(LIVE_POLYPHONY) { SynthVoice(sampleRate) }
|
private val liveVoices = Array(LIVE_POLYPHONY) { SynthVoice(sampleRate) }
|
||||||
// Sample voices for previewing sampler instruments (toolbox keyboard / editor).
|
// Sample voices for previewing sampler instruments (toolbox keyboard / editor).
|
||||||
private val auditionSampleVoices = Array(AUDITION_POLYPHONY) { SampleVoice(sampleRate) }
|
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.
|
// Per-(lane,track) arpeggiator state, driven by advanceArps.
|
||||||
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
|
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
|
||||||
|
|
||||||
private fun seqVoice(lane: Int, track: Int) = seqVoices[lane * Pattern.TRACK_COUNT + track]
|
/** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. */
|
||||||
private fun sampleVoice(lane: Int, track: Int) = sampleVoices[lane * Pattern.TRACK_COUNT + track]
|
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. */
|
/** 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).
|
/** Per-channel effect chains (mixer FX slots resolved to real processors).
|
||||||
* Rebuilt when routing changes; parameters refresh live every block. */
|
* Rebuilt when routing changes; parameters refresh live every block. Stored as
|
||||||
private val channelChains = Array(Pattern.TRACK_COUNT) { mutableListOf<FxUnit>() }
|
* 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). */
|
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
||||||
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
||||||
@@ -87,12 +139,16 @@ class AudioEngine(
|
|||||||
|
|
||||||
// ---- Arrangement mode ----
|
// ---- Arrangement mode ----
|
||||||
private var arrangementMode = false
|
private var arrangementMode = false
|
||||||
/** Beat indices to play, in order, with A/B repeats already expanded. */
|
/** Beat indices to play, in order, with A/B repeats already expanded. Volatile
|
||||||
private var playlist: IntArray = IntArray(0)
|
* 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 playlistIndex = 0
|
||||||
private var lineInBeat = 0
|
private var lineInBeat = 0
|
||||||
private val laneCursor = IntArray(Arrangement.LANE_COUNT) // which beat of its pattern each lane is on
|
/** Local tick of the *edited* block at the current beat, for the tracker
|
||||||
private val lanePrevPattern = IntArray(Arrangement.LANE_COUNT) // pattern played on the previous beat (-2 = none)
|
* playhead. Updated as that block sounds; retained when it isn't. */
|
||||||
|
private var arrPlayLine = 0
|
||||||
|
|
||||||
private val _transport = MutableStateFlow(TransportState())
|
private val _transport = MutableStateFlow(TransportState())
|
||||||
val transport: StateFlow<TransportState> = _transport
|
val transport: StateFlow<TransportState> = _transport
|
||||||
@@ -100,6 +156,87 @@ class AudioEngine(
|
|||||||
private var renderThread: Thread? = null
|
private var renderThread: Thread? = null
|
||||||
private var track: AudioTrack? = 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) {
|
fun setProject(p: Project) {
|
||||||
project = p
|
project = p
|
||||||
rebuildArrangement()
|
rebuildArrangement()
|
||||||
@@ -114,16 +251,18 @@ class AudioEngine(
|
|||||||
*/
|
*/
|
||||||
fun rebuildFxChains() {
|
fun rebuildFxChains() {
|
||||||
val proj = project ?: return
|
val proj = project ?: return
|
||||||
|
val next = Array(Pattern.TRACK_COUNT) { emptyArray<FxUnit>() }
|
||||||
for (ch in 0 until Pattern.TRACK_COUNT) {
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
val chain = channelChains[ch]
|
val units = ArrayList<FxUnit>(proj.mixer.channels[ch].fxSlots.size)
|
||||||
chain.clear()
|
|
||||||
for (slotIndex in proj.mixer.channels[ch].fxSlots) {
|
for (slotIndex in proj.mixer.channels[ch].fxSlots) {
|
||||||
if (slotIndex < 0) continue
|
if (slotIndex < 0) continue
|
||||||
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
||||||
val type = slot.type ?: continue
|
val type = slot.type ?: continue
|
||||||
AudioEffect.create(type, sampleRate)?.let { 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)
|
playlist = buildPlaylist(arr)
|
||||||
playlistIndex = 0
|
playlistIndex = 0
|
||||||
lineInBeat = 0
|
lineInBeat = 0
|
||||||
laneCursor.fill(0)
|
arrPlayLine = 0
|
||||||
lanePrevPattern.fill(-2)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Expand the arrangement (respecting loop + A/B regions) into a flat beat list. */
|
||||||
private fun buildPlaylist(arr: Arrangement): IntArray {
|
private fun buildPlaylist(arr: Arrangement): IntArray {
|
||||||
if (!arr.loopEnabled) return IntArray(arr.lengthBeats) { it } // play once, front to back
|
|
||||||
val a = arr.regionA
|
val a = arr.regionA
|
||||||
val b = arr.regionB
|
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>()
|
val out = ArrayList<Int>()
|
||||||
if (a.isValid) repeat(a.repeats) { for (beat in a.start until a.end) out.add(beat) }
|
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) }
|
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). */
|
/** Stop the engine entirely (called from the PlaybackService on shutdown). */
|
||||||
fun release() {
|
fun release() {
|
||||||
running = false
|
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?.stop()
|
||||||
nativeBridge = null
|
nativeBridge = null
|
||||||
renderThread?.join(500)
|
renderThread?.join(500)
|
||||||
@@ -245,11 +410,29 @@ class AudioEngine(
|
|||||||
rebuildFxChains()
|
rebuildFxChains()
|
||||||
pendingTrigger = true
|
pendingTrigger = true
|
||||||
playing = true
|
playing = true
|
||||||
|
maybeStartRecording()
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() { playing = false; allSeqVoicesOff(); 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. */
|
/** Stop playback. If already stopped, rewind to the very beginning. */
|
||||||
fun stop() {
|
fun stop() {
|
||||||
if (!playing && currentLine == 0 && playlistIndex == 0 && lineInBeat == 0) return
|
if (!playing && currentLine == 0 && playlistIndex == 0 && lineInBeat == 0) return
|
||||||
@@ -259,7 +442,7 @@ class AudioEngine(
|
|||||||
if (wasStopped) {
|
if (wasStopped) {
|
||||||
currentLine = 0; samplesIntoLine = 0.0
|
currentLine = 0; samplesIntoLine = 0.0
|
||||||
playlistIndex = 0; lineInBeat = 0
|
playlistIndex = 0; lineInBeat = 0
|
||||||
laneCursor.fill(0); lanePrevPattern.fill(-2)
|
arrPlayLine = 0
|
||||||
}
|
}
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
@@ -300,6 +483,9 @@ class AudioEngine(
|
|||||||
updateFxParams(proj)
|
updateFxParams(proj)
|
||||||
}
|
}
|
||||||
val anySolo = proj?.mixer?.anySolo() ?: false
|
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) {
|
for (i in 0 until frames) {
|
||||||
if (playing && proj != null) advanceSequencer(proj)
|
if (playing && proj != null) advanceSequencer(proj)
|
||||||
@@ -307,13 +493,19 @@ class AudioEngine(
|
|||||||
var mix = 0f
|
var mix = 0f
|
||||||
if (proj != null) {
|
if (proj != null) {
|
||||||
channelSum.fill(0f)
|
channelSum.fill(0f)
|
||||||
|
// Pools are laid out bus*POLYPHONY + i, so the bus is vi / POLYPHONY.
|
||||||
for (vi in seqVoices.indices) {
|
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) {
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
val mc = proj.mixer.channels[ch]
|
val mc = proj.mixer.channels[ch]
|
||||||
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
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)
|
for (u in chain) s = u.effect.process(s)
|
||||||
mix += s
|
mix += s
|
||||||
}
|
}
|
||||||
@@ -321,15 +513,26 @@ class AudioEngine(
|
|||||||
for (lv in liveVoices) mix += lv.render() * LIVE_GAIN
|
for (lv in liveVoices) mix += lv.render() * LIVE_GAIN
|
||||||
for (av in auditionSampleVoices) mix += av.render() * LIVE_GAIN
|
for (av in auditionSampleVoices) mix += av.render() * LIVE_GAIN
|
||||||
out[i] = softClip(mix * MASTER_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) {
|
private fun updateFxParams(proj: Project) {
|
||||||
val tempo = proj.tempoBpm
|
val tempo = proj.tempoBpm
|
||||||
|
val chains = channelChains
|
||||||
for (ch in 0 until Pattern.TRACK_COUNT) {
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
val chain = channelChains[ch]
|
val chain = chains[ch]
|
||||||
for (u in chain) u.effect.update(u.slot, tempo)
|
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) {
|
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) {
|
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:
|
* Trigger one tracker [cell], routing it to the mixer bus(es) whose MIDI channel
|
||||||
* Transposer shifts the pitch, Arpeggiator (if present) captures the note and
|
* matches the cell's channel — NOT the source track. A note therefore plays
|
||||||
* hands ongoing retriggering to [advanceArps] instead of sounding it once.
|
* 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) {
|
private fun triggerCell(proj: Project, lane: Int, cell: com.reactorcoremeltdown.sizzletracker.model.Cell) {
|
||||||
val arp = arps[lane * Pattern.TRACK_COUNT + track]
|
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||||
when {
|
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
|
||||||
cell.isPlayable -> {
|
val arp = arps[lane * Pattern.TRACK_COUNT + bus]
|
||||||
val velocity = cell.velocity.coerceIn(0, 127)
|
when {
|
||||||
val note = (cell.note + channelTranspose(proj, track)).coerceIn(0, 127)
|
cell.isPlayable -> {
|
||||||
val arpSlot = channelArpSlot(proj, track)
|
val velocity = cell.velocity.coerceIn(0, 127)
|
||||||
if (arpSlot != null) {
|
val note = (cell.note + channelTranspose(proj, bus)).coerceIn(0, 127)
|
||||||
val octaves = arpSlot.float("octaves", 1f).toInt()
|
val arpSlot = channelArpSlot(proj, bus)
|
||||||
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
|
if (arpSlot != null) {
|
||||||
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
val octaves = arpSlot.float("octaves", 1f).toInt()
|
||||||
arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples)
|
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
|
||||||
playNote(proj, lane, track, arp.currentNote(), velocity)
|
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||||
} else {
|
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()
|
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
|
* Sound a note on a mixer [bus]: allocate a [SampleVoice] from the bus pool when
|
||||||
* instrument is a Sampler with a loaded sample, otherwise the NES synth. The
|
* the channel's instrument is a Sampler/SoundFont with a loaded sample, otherwise
|
||||||
* unused voice of the pair is killed so they never double up. Shared by the
|
* a [SynthVoice] for the NES synth. Pooled allocation (up to [POLYPHONY] per bus,
|
||||||
* sequencer and the arpeggiator.
|
* 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) {
|
private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int) {
|
||||||
val synth = seqVoice(lane, track)
|
val slot = proj.toolbox.getOrNull(proj.mixer.channels[bus].instrumentSlot)
|
||||||
val samp = sampleVoice(lane, track)
|
val vs = voiceSample(slot, note)
|
||||||
val slot = proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot)
|
when {
|
||||||
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
|
vs != null ->
|
||||||
if (sample != null && slot != null) {
|
allocSampleVoice(bus).noteOn(vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
|
||||||
synth.kill()
|
vs.attack, vs.decay, vs.sustain, vs.release)
|
||||||
val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
|
// Empty bus (no instrument — e.g. the default channel 0) or a Sampler
|
||||||
samp.noteOn(
|
// with an empty pad → silent, matching the live audition path.
|
||||||
sample, note, velocity, root,
|
slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
|
||||||
slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f),
|
// NES synth, or a SoundFont with nothing loaded → the tone generator.
|
||||||
)
|
else -> {
|
||||||
} else {
|
val (wave, a, d, s, r) = synthParamsForTrack(proj, bus)
|
||||||
samp.kill()
|
allocSynthVoice(bus).noteOn(note, velocity, wave, a, d, s, r)
|
||||||
val (wave, a, d, s, r) = synthParamsForTrack(proj, track)
|
}
|
||||||
synth.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. */
|
/** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
|
||||||
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
|
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
|
||||||
for (idx in proj.mixer.channels[track].fxSlots) {
|
for (idx in proj.mixer.channels[track].fxSlots) {
|
||||||
@@ -485,7 +747,7 @@ class AudioEngine(
|
|||||||
a.samplesLeft -= 1.0
|
a.samplesLeft -= 1.0
|
||||||
if (a.samplesLeft <= 0.0) {
|
if (a.samplesLeft <= 0.0) {
|
||||||
a.advance()
|
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
|
a.samplesLeft += a.stepSamples
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,16 +799,20 @@ class AudioEngine(
|
|||||||
fun auditionSlotOn(slotIndex: Int, midi: Int) {
|
fun auditionSlotOn(slotIndex: Int, midi: Int) {
|
||||||
val proj = project ?: return
|
val proj = project ?: return
|
||||||
val slot = proj.toolbox.getOrNull(slotIndex)
|
val slot = proj.toolbox.getOrNull(slotIndex)
|
||||||
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
|
val vs = voiceSample(slot, midi)
|
||||||
if (sample != null && slot != null) {
|
if (vs != null) {
|
||||||
val v = auditionSampleVoices.firstOrNull { !it.isActive } ?: auditionSampleVoices[0].also { it.kill() }
|
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(vs.sample, midi, 110, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
|
||||||
v.noteOn(sample, midi, 110, root, slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f))
|
vs.attack, vs.decay, vs.sustain, vs.release)
|
||||||
} else {
|
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)
|
|
||||||
}
|
}
|
||||||
|
// 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). */
|
/** 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()
|
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 -----
|
// ----- Arrangement mode -----
|
||||||
private fun advanceArrangement(proj: Project) {
|
private fun advanceArrangement(proj: Project) {
|
||||||
val arr = proj.arrangement
|
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 linesPerBeat = proj.timeSignature.linesPerBeat
|
||||||
val samplesPerLine = samplesPerLine(proj)
|
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 (pendingTrigger) {
|
||||||
if (lineInBeat == 0) advanceLaneCursors(proj, arr, beat, linesPerBeat)
|
|
||||||
triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
|
triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
|
||||||
pendingTrigger = false
|
pendingTrigger = false
|
||||||
}
|
}
|
||||||
@@ -576,10 +892,9 @@ class AudioEngine(
|
|||||||
if (lineInBeat >= linesPerBeat) {
|
if (lineInBeat >= linesPerBeat) {
|
||||||
lineInBeat = 0
|
lineInBeat = 0
|
||||||
playlistIndex++
|
playlistIndex++
|
||||||
if (playlistIndex >= playlist.size) {
|
if (playlistIndex >= list.size) {
|
||||||
if (arr.loopEnabled) {
|
if (arr.loopEnabled) {
|
||||||
playlistIndex = 0
|
playlistIndex = 0
|
||||||
lanePrevPattern.fill(-2) // fresh cycle: blocks restart from beat 0
|
|
||||||
} else {
|
} else {
|
||||||
playing = false; allSeqVoicesOff(); publish(); return
|
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) {
|
* Play one tick of every block marked at [beat]. Mirrors the reference
|
||||||
for (lane in 0 until Arrangement.LANE_COUNT) {
|
* sizzletracker `playAt`: a marker means the block sounds *that beat*, and a
|
||||||
val p = arr.patternAt(lane, beat)
|
* contiguous run of markers plays the block from its start beat-by-beat (and
|
||||||
if (p == Arrangement.EMPTY) {
|
* restarts after any erased gap). The beat-within-block is derived STATELESSLY
|
||||||
lanePrevPattern[lane] = Arrangement.EMPTY
|
* from the run's start each tick — no mutable cursor — so it stays correct
|
||||||
continue
|
* 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).
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun triggerArrangementLine(
|
private fun triggerArrangementLine(
|
||||||
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
|
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
|
||||||
) {
|
) {
|
||||||
for (lane in 0 until Arrangement.LANE_COUNT) {
|
// Two passes across all lanes: note-offs first, then note-ons, so a note-off
|
||||||
val p = arr.patternAt(lane, beat)
|
// never cancels a note that begins on the same line/bus (a note-off releases
|
||||||
if (p == Arrangement.EMPTY) continue
|
// the whole bus). See [triggerPatternLine].
|
||||||
val pat = proj.pattern(p) ?: continue
|
for (pass in 0..1) {
|
||||||
val patLine = laneCursor[lane] * linesPerBeat + lineInBeat
|
for (lane in 0 until Arrangement.LANE_COUNT) {
|
||||||
if (patLine >= pat.length) continue
|
val p = arr.patternAt(lane, beat)
|
||||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
if (p == Arrangement.EMPTY) continue
|
||||||
triggerVoice(proj, lane, t, pat.cell(t, patLine))
|
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 proj = project
|
||||||
val linesPerBeat = proj?.timeSignature?.linesPerBeat ?: 4
|
val linesPerBeat = proj?.timeSignature?.linesPerBeat ?: 4
|
||||||
if (arrangementMode) {
|
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(
|
_transport.value = TransportState(
|
||||||
isPlaying = playing,
|
isPlaying = playing,
|
||||||
currentLine = laneCursor[0] * linesPerBeat + lineInBeat,
|
currentLine = arrPlayLine,
|
||||||
currentBeat = beat,
|
currentBeat = beat,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -681,17 +1002,27 @@ class AudioEngine(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz
|
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 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 MASTER_GAIN = 0.35f
|
||||||
private const val LIVE_GAIN = 0.5f
|
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)
|
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 {
|
* Continuous soft-knee limiter: linear (transparent) up to [SOFT_KNEE], then
|
||||||
x > 1f -> 1f - 1f / (x + 1f)
|
* smoothly saturating to +-1 above it. The previous clipper was discontinuous
|
||||||
x < -1f -> -1f + 1f / (-x + 1f)
|
* at |x|=1 (it snapped the output 1.0 -> 0.5 as the signal crossed full scale),
|
||||||
else -> x
|
* 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.reactorcoremeltdown.sizzletracker.audio
|
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.ToolboxSlot
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
@@ -29,7 +32,7 @@ interface AudioEffect {
|
|||||||
companion object {
|
companion object {
|
||||||
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
|
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
|
||||||
ToolboxType.DELAY -> TapeDelay(sampleRate)
|
ToolboxType.DELAY -> TapeDelay(sampleRate)
|
||||||
ToolboxType.REVERB -> Reverb(sampleRate)
|
ToolboxType.AMBIENCE -> AmbienceReverb(sampleRate)
|
||||||
ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
|
ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
|
||||||
ToolboxType.BITCRUSHER -> Bitcrusher()
|
ToolboxType.BITCRUSHER -> Bitcrusher()
|
||||||
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
||||||
@@ -39,48 +42,132 @@ interface AudioEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tape delay: a single delay line with feedback, tempo-synced time and dry/wet.
|
// Multi-tap tape delay: one shared delay line read by up to four independent
|
||||||
// (The "tape heads" count is modelled but rendered as one tap here.)
|
// "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 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 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 feedback = 0.4f
|
||||||
private var dryWet = 0.35f
|
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) {
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
val div = TimeDivision.fromIndex(slot.float("division", 3f).toInt())
|
val samplesPerBeat = (60.0 / tempoBpm) * sampleRate
|
||||||
val seconds = div.beatFraction * (60.0 / tempoBpm)
|
for (h in 0 until DelayDivisions.HEADS) {
|
||||||
delaySamples = (seconds * sampleRate).toInt().coerceIn(1, buffer.size - 1)
|
// Precomputed key strings — building "head${h}On" here would allocate on
|
||||||
feedback = slot.float("feedback", 0.4f).coerceIn(0f, 0.95f)
|
// 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)
|
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 {
|
override fun process(x: Float): Float {
|
||||||
val readIndex = (writeIndex - delaySamples + buffer.size) % buffer.size
|
flutterPhase += flutterInc
|
||||||
val delayed = buffer[readIndex]
|
if (flutterPhase > 2 * PI) flutterPhase -= 2 * PI
|
||||||
buffer[writeIndex] = x + delayed * feedback
|
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
|
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 class BiquadFilterEffect(private val sampleRate: Int) : AudioEffect {
|
||||||
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
|
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 a1 = 0.0; private var a2 = 0.0
|
||||||
private var z1 = 0.0; private var z2 = 0.0 // transposed direct form II state
|
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) {
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
val type = slot.string("type", "LPF")
|
type = slot.string("type", "LPF")
|
||||||
// Map 0..1 to ~20 Hz .. ~18 kHz logarithmically.
|
baseCutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
|
||||||
val cutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
|
q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
|
||||||
val freq = 20.0 * 900.0.pow(cutoff.toDouble())
|
warmth = slot.float("warmth", 0f).coerceIn(0f, 1f)
|
||||||
val q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
|
envAmount = slot.float("envamt", 0f).coerceIn(0f, 1f)
|
||||||
computeCoefficients(type, freq.coerceIn(20.0, sampleRate * 0.45), q)
|
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) {
|
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 {
|
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 input = x.toDouble()
|
||||||
val out = b0 * input + z1
|
val out = b0 * input + z1
|
||||||
z1 = b1 * input - a1 * out + z2
|
z1 = b1 * input - a1 * out + z2
|
||||||
z2 = b2 * input - a2 * out
|
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.
|
// 10-band graphic EQ: ten peaking biquads at octave-spaced centre frequencies.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
private class GraphicEq(private val sampleRate: Int) : AudioEffect {
|
private class GraphicEq(private val sampleRate: Int) : AudioEffect {
|
||||||
private val centers = floatArrayOf(31f, 63f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
|
private val centers = floatArrayOf(31f, 63f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
|
||||||
private val bands = Array(10) { PeakBiquad() }
|
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) {
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
for (i in bands.indices) {
|
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)
|
bands[i].set(sampleRate, centers[i], gainDb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,36 @@ object SampleStore {
|
|||||||
return Sample(out, sampleRate)
|
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 {
|
private fun readTag(bb: ByteBuffer): String {
|
||||||
val b = ByteArray(4)
|
val b = ByteArray(4)
|
||||||
bb.get(b)
|
bb.get(b)
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package com.reactorcoremeltdown.sizzletracker.audio
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
import kotlin.math.exp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays back a decoded [SampleStore.Sample] as a pitched, one-shot voice for the
|
* 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
|
* playback rate (so a single sample is pitch-stretched across the keyboard), and
|
||||||
* the slice start/end markers bound the region that plays.
|
* the slice start/end markers bound the region that plays.
|
||||||
*
|
*
|
||||||
* Runs on the audio thread: [render] must not allocate. Linear interpolation
|
* Amplitude is shaped by a linear **ADSR envelope** (attack → decay → sustain →
|
||||||
* keeps pitched playback smooth; short fade-in/out ramps avoid clicks.
|
* 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) {
|
class SampleVoice(private val engineSampleRate: Int) {
|
||||||
private var sample: SampleStore.Sample? = null
|
private var sample: SampleStore.Sample? = null
|
||||||
@@ -16,21 +24,46 @@ class SampleVoice(private val engineSampleRate: Int) {
|
|||||||
private var startIndex = 0
|
private var startIndex = 0
|
||||||
private var endIndex = 0
|
private var endIndex = 0
|
||||||
private var amp = 0f
|
private var amp = 0f
|
||||||
private var releasing = false
|
|
||||||
private var gain = 0f // current fade gain 0..1
|
|
||||||
var pitch = -1; private set
|
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
|
val isActive: Boolean get() = sample != null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param root MIDI note at which the sample plays at its natural pitch.
|
* @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 sliceStart/[sliceEnd] 0..1 fractions of the sample to play.
|
||||||
* @param volume 0..1 instrument volume.
|
* @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(
|
fun noteOn(
|
||||||
s: SampleStore.Sample, midi: Int, velocity: Int, root: Int,
|
s: SampleStore.Sample, midi: Int, velocity: Int, root: Int,
|
||||||
sliceStart: Float, sliceEnd: Float, volume: Float,
|
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
|
sample = s
|
||||||
pitch = midi
|
pitch = midi
|
||||||
val len = s.data.size
|
val len = s.data.size
|
||||||
@@ -43,34 +76,74 @@ class SampleVoice(private val engineSampleRate: Int) {
|
|||||||
val semis = (midi - root) / 12.0
|
val semis = (midi - root) / 12.0
|
||||||
rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate)
|
rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate)
|
||||||
amp = (velocity / 127f).coerceIn(0f, 1f) * volume
|
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 {
|
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 data = s.data
|
||||||
val i = position.toInt()
|
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.
|
// Linear interpolation between neighbouring samples.
|
||||||
val frac = (position - i).toFloat()
|
val frac = (position - i).toFloat()
|
||||||
val out = data[i] + (data[i + 1] - data[i]) * frac
|
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
|
position += rate
|
||||||
return out * amp * gain
|
val y = out * amp * env * endGain + declick
|
||||||
|
declick *= declickCoeff
|
||||||
|
lastY = y
|
||||||
|
return y
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,98 +5,203 @@ import android.view.KeyEvent
|
|||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates USB / Bluetooth gamepad input into [InputAction]s. Two kinds of
|
* A gamepad binding trigger: a main control [code], optionally combined with a
|
||||||
* events arrive:
|
* held [modifier] control. [modifier] == [NONE] means "no modifier" (a plain,
|
||||||
* - button presses -> [KeyEvent] (handled in [onKeyDown])
|
* single-control binding). This lets one control act as a "shift key": e.g. bind
|
||||||
* - stick / trigger / D-pad-as-hat movement -> [MotionEvent] (handled in [onMotion])
|
* 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
|
* A "control" is a button key-code, a D-pad direction (the DPAD_* key-codes, used
|
||||||
* action id string, editable from the Settings tab. A sensible default layout is
|
* whether the pad reports as keys or as a hat), OR an analog-stick direction (the
|
||||||
* provided so the pad works out of the box.
|
* 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(
|
class GamepadInput(
|
||||||
private val router: InputRouter,
|
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.
|
// Current sign (-1/0/+1) of each directional axis, for edge detection. The
|
||||||
private var lastHatX = 0
|
// D-pad hat reuses the DPAD_* key-codes so hat- and key-reported D-pads behave
|
||||||
private var lastHatY = 0
|
// 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 {
|
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD) &&
|
if (!isGamepad(keyCode, event)) return false
|
||||||
!event.isFromSource(InputDevice.SOURCE_JOYSTICK)
|
return controlDown(keyCode)
|
||||||
) 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
fun onMotion(event: MotionEvent): Boolean {
|
||||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||||
|
hatX = axis(event.getAxisValue(MotionEvent.AXIS_HAT_X), hatX, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||||
// D-pad reported as HAT axes on many pads.
|
hatY = axis(event.getAxisValue(MotionEvent.AXIS_HAT_Y), hatY, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN)
|
||||||
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X).toSign()
|
lStickX = axis(event.getAxisValue(MotionEvent.AXIS_X), lStickX, CODE_LSTICK_LEFT, CODE_LSTICK_RIGHT)
|
||||||
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y).toSign()
|
lStickY = axis(event.getAxisValue(MotionEvent.AXIS_Y), lStickY, CODE_LSTICK_UP, CODE_LSTICK_DOWN)
|
||||||
if (hatX != lastHatX) {
|
rStickX = axis(event.getAxisValue(MotionEvent.AXIS_Z), rStickX, CODE_RSTICK_LEFT, CODE_RSTICK_RIGHT)
|
||||||
lastHatX = hatX
|
rStickY = axis(event.getAxisValue(MotionEvent.AXIS_RZ), rStickY, CODE_RSTICK_UP, CODE_RSTICK_DOWN)
|
||||||
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))
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun actionFromId(id: String): InputAction? = when (id) {
|
// ---- core press/release pipeline (shared by buttons, D-pad and sticks) ----
|
||||||
"playPause" -> InputAction.PlayPause
|
|
||||||
"stop" -> InputAction.Stop
|
/** @return true if the press was consumed (learning, or a bound control). */
|
||||||
"loop" -> InputAction.ToggleLoop
|
private fun controlDown(code: Int): Boolean {
|
||||||
"up" -> InputAction.NavUp
|
// Learn mode: record the press sequence and consume it (no action fires).
|
||||||
"down" -> InputAction.NavDown
|
if (learningGamepad()) {
|
||||||
"left" -> InputAction.NavLeft
|
if (code !in learnSeq) learnSeq.add(code)
|
||||||
"right" -> InputAction.NavRight
|
heldButtons.add(code)
|
||||||
"increment" -> InputAction.Increment(1)
|
return true
|
||||||
"decrement" -> InputAction.Decrement(1)
|
}
|
||||||
"clear" -> InputAction.ClearCell
|
// Chord first: any already-held control may be the modifier.
|
||||||
"nextTab" -> InputAction.NextTab
|
for (mod in heldButtons) {
|
||||||
"prevTab" -> InputAction.PrevTab
|
val id = bindings[GamepadChord(mod, code)]
|
||||||
else -> null
|
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 {
|
private fun controlUp(code: Int): Boolean {
|
||||||
this < -0.5f -> -1; this > 0.5f -> 1; else -> 0
|
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 {
|
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. */
|
/** Default Xbox/standard-layout mapping; every value is an action id. */
|
||||||
fun defaultBindings(): MutableMap<Int, String> = mutableMapOf(
|
fun defaultBindings(): MutableMap<GamepadChord, String> = mutableMapOf(
|
||||||
KeyEvent.KEYCODE_BUTTON_A to "playPause",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_A) to "playPause",
|
||||||
KeyEvent.KEYCODE_BUTTON_B to "stop",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_B) to "stop",
|
||||||
KeyEvent.KEYCODE_BUTTON_X to "clear",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_X) to "clear",
|
||||||
KeyEvent.KEYCODE_BUTTON_Y to "loop",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_Y) to "loop",
|
||||||
KeyEvent.KEYCODE_BUTTON_L1 to "prevTab",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_L1) to "prevTab",
|
||||||
KeyEvent.KEYCODE_BUTTON_R1 to "nextTab",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_BUTTON_R1) to "nextTab",
|
||||||
KeyEvent.KEYCODE_DPAD_UP to "up",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_UP) to "up",
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN to "down",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_DOWN) to "down",
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT to "left",
|
GamepadChord(GamepadChord.NONE, KeyEvent.KEYCODE_DPAD_LEFT) to "left",
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT to "right",
|
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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,16 @@ sealed interface InputAction {
|
|||||||
data object PrevTab : InputAction
|
data object PrevTab : InputAction
|
||||||
|
|
||||||
// ----- A raw, unmapped control (e.g. an unbound MIDI CC). Handlers may
|
// ----- A raw, unmapped control (e.g. an unbound MIDI CC). Handlers may
|
||||||
// ignore it; the settings screen uses it to "learn" new bindings. -----
|
// ignore it; the settings screen uses it to "learn" new bindings. For a
|
||||||
data class RawControl(val source: InputSource, val code: Int, val value: Float) : InputAction
|
// 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. */
|
/** Which physical device an action came from — handy for on-screen feedback. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ class InputRouter {
|
|||||||
/** True while the settings screen is waiting to learn a new binding. */
|
/** True while the settings screen is waiting to learn a new binding. */
|
||||||
@Volatile var learnMode: Boolean = false
|
@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) {
|
fun dispatch(action: InputAction) {
|
||||||
// tryEmit never suspends; if the buffer is somehow full we drop the
|
// 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).
|
// oldest by design (a missed nav key is harmless, a stall is not).
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
|||||||
* [MainActivity] forwards every hardware key here from its
|
* [MainActivity] forwards every hardware key here from its
|
||||||
* `onKeyDown` / `onKeyUp` overrides.
|
* `onKeyDown` / `onKeyUp` overrides.
|
||||||
*
|
*
|
||||||
* The letter rows form a two-octave "tracker piano" in the classic layout so you
|
* Two layers share the keyboard:
|
||||||
* can play and record notes without a MIDI device:
|
* - [bindings]: user-rebindable HOTKEYS (nav / transport / editing), editable
|
||||||
* lower octave : Z S X D C V G B H N J M (C .. B)
|
* from Settings → Keyboard Hotkeys. Resolved through [InputActions] plus a few
|
||||||
* upper octave : Q 2 W 3 E R 5 T 6 Y 7 U (C .. B, one octave up)
|
* 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. */
|
/** The base octave the keyboard piano plays in; adjustable with +/- keys. */
|
||||||
var baseOctave: Int = 4
|
var baseOctave: Int = 4
|
||||||
@@ -32,36 +44,67 @@ class KeyboardInput(private val router: InputRouter) {
|
|||||||
|
|
||||||
/** @return true if we consumed the event. */
|
/** @return true if we consumed the event. */
|
||||||
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
// Ignore auto-repeat for one-shot actions but let piano notes retrigger.
|
// Keyboard-hotkey learn: capture this key for the armed action, consume it.
|
||||||
val action: InputAction? = when (keyCode) {
|
if (router.learnMode && router.learnSource == InputSource.KEYBOARD) {
|
||||||
KeyEvent.KEYCODE_DPAD_UP -> InputAction.NavUp
|
router.dispatch(InputAction.RawControl(InputSource.KEYBOARD, keyCode, 1f))
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN -> InputAction.NavDown
|
return true
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT -> InputAction.NavLeft
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT -> InputAction.NavRight
|
|
||||||
KeyEvent.KEYCODE_TAB -> if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn
|
bindings[keyCode]?.let { id -> return handleBoundAction(id, event) }
|
||||||
KeyEvent.KEYCODE_SPACE -> InputAction.PlayPause
|
|
||||||
KeyEvent.KEYCODE_ENTER -> InputAction.Stop
|
// Piano fallback for unbound keys.
|
||||||
KeyEvent.KEYCODE_L -> InputAction.ToggleLoop
|
pianoMap[keyCode]?.let { offset ->
|
||||||
KeyEvent.KEYCODE_PAGE_UP -> InputAction.Increment(1)
|
router.dispatch(InputAction.NoteOn(pianoPitch(offset), velocity = 100))
|
||||||
KeyEvent.KEYCODE_PAGE_DOWN -> InputAction.Decrement(1)
|
return true
|
||||||
KeyEvent.KEYCODE_DEL -> InputAction.ClearCell
|
}
|
||||||
KeyEvent.KEYCODE_GRAVE -> InputAction.NoteOffCell
|
return false
|
||||||
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 ->
|
/** Run a bound hotkey. Handles the keyboard-local ids (octave nudge, and the
|
||||||
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
|
* Shift-aware column move) and delegates the rest to [InputActions]. */
|
||||||
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
private fun handleBoundAction(id: String, event: KeyEvent): Boolean {
|
||||||
InputAction.NoteOn(pitch, velocity = 100)
|
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 {
|
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean {
|
||||||
val offset = pianoMap[keyCode] ?: return false
|
val offset = pianoMap[keyCode] ?: return false
|
||||||
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
|
router.dispatch(InputAction.NoteOff(pianoPitch(offset)))
|
||||||
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
|
||||||
router.dispatch(InputAction.NoteOff(pitch))
|
|
||||||
return true
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
|||||||
* for the on-disk preset library.
|
* for the on-disk preset library.
|
||||||
*/
|
*/
|
||||||
object PresetIo {
|
object PresetIo {
|
||||||
private const val HEADER = "SIZZLE-PRESET 1"
|
const val HEADER = "SIZZLE-PRESET 1"
|
||||||
|
|
||||||
/** Serialize a slot's device + parameters to text. */
|
/** Serialize a slot's device + parameters to text. */
|
||||||
fun export(slot: ToolboxSlot): String {
|
fun export(slot: ToolboxSlot): String {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ object ProjectIo {
|
|||||||
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY)
|
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY)
|
||||||
project.toolbox.forEach { it.clearSlot() }
|
project.toolbox.forEach { it.clearSlot() }
|
||||||
project.mixer.channels.forEach { ch ->
|
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
|
ch.volume = 0.8f; ch.mute = false; ch.solo = false
|
||||||
}
|
}
|
||||||
var section = ""
|
var section = ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,20 @@ class Arrangement {
|
|||||||
fun patternAt(lane: Int, beat: Int): Int =
|
fun patternAt(lane: Int, beat: Int): Int =
|
||||||
if (beat in 0 until MAX_BEATS) slots[lane][beat] else EMPTY
|
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) {
|
fun set(lane: Int, beat: Int, patternId: Int) {
|
||||||
if (lane in 0 until LANE_COUNT && beat in 0 until MAX_BEATS) {
|
if (lane in 0 until LANE_COUNT && beat in 0 until MAX_BEATS) {
|
||||||
slots[lane][beat] = patternId
|
slots[lane][beat] = patternId
|
||||||
|
|||||||
@@ -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".
|
* by their slot index in the [Toolbox] (tab 3). A value of -1 means "no device".
|
||||||
*/
|
*/
|
||||||
class Mixer {
|
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. */
|
/** True if ANY channel is soloed — used to mute the non-soloed ones. */
|
||||||
fun anySolo(): Boolean = channels.any { it.solo }
|
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 CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep
|
||||||
const val FX_SLOTS = 4
|
const val FX_SLOTS = 4
|
||||||
const val NO_DEVICE = -1
|
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. */
|
/** MIDI channel (1..16) this mixer channel listens/sends on. */
|
||||||
var midiChannel: Int = 1,
|
var midiChannel: Int = 1,
|
||||||
/** Linear 0..1 fader. */
|
/** Linear 0..1 fader. */
|
||||||
var volume: Float = 0.8f,
|
var volume: Float = Mixer.DEFAULT_VOLUME,
|
||||||
var mute: Boolean = false,
|
var mute: Boolean = false,
|
||||||
var solo: Boolean = false,
|
var solo: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class Cell(
|
|||||||
fun clear() {
|
fun clear() {
|
||||||
note = EMPTY
|
note = EMPTY
|
||||||
velocity = MAX_VELOCITY
|
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. */
|
/** A detached copy (for the clipboard) — never aliases the source cell. */
|
||||||
@@ -61,9 +62,11 @@ class Pattern(
|
|||||||
var name: String = "PTN",
|
var name: String = "PTN",
|
||||||
var length: Int = 16,
|
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>> =
|
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]
|
fun cell(track: Int, line: Int): Cell = tracks[track][line]
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ class Pattern(
|
|||||||
for (t in 0 until TRACK_COUNT) {
|
for (t in 0 until TRACK_COUNT) {
|
||||||
val col = tracks[t]
|
val col = tracks[t]
|
||||||
when {
|
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)
|
newLength < col.size -> while (col.size > newLength) col.removeAt(col.size - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ data class ParamSpec(
|
|||||||
val min: Float = 0f,
|
val min: Float = 0f,
|
||||||
val max: Float = 1f,
|
val max: Float = 1f,
|
||||||
val choices: List<String> = emptyList(),
|
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()
|
val isEnum: Boolean get() = choices.isNotEmpty()
|
||||||
}
|
}
|
||||||
@@ -51,18 +53,22 @@ enum class ToolboxType(
|
|||||||
SAMPLER(
|
SAMPLER(
|
||||||
ToolboxKind.INSTRUMENT, "Sampler",
|
ToolboxKind.INSTRUMENT, "Sampler",
|
||||||
listOf(
|
listOf(
|
||||||
ParamSpec("startOctave", "Start Oct", 3f, 0f, 8f),
|
|
||||||
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
||||||
ParamSpec("sliceStart", "Slice Start", 0f, 0f, 1f),
|
// A 16-pad bank: each pad maps to a consecutive note from C2 (see
|
||||||
ParamSpec("sliceEnd", "Slice End", 1f, 0f, 1f),
|
// [SamplerPads]) and stores its own sample id + slice as string params
|
||||||
// "samplePath" is stored as a string param at runtime (see ToolboxSlot).
|
// ("pad0", "pad0S", "pad0E", …). Edited by ui/toolbox/SamplerEditor.
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SOUNDFONT(
|
SOUNDFONT(
|
||||||
ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
|
ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
|
||||||
listOf(
|
listOf(
|
||||||
ParamSpec("program", "Program", 0f, 0f, 127f),
|
ParamSpec("program", "Program", 0f, 0f, 127f, isInt = true),
|
||||||
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
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",
|
ToolboxKind.EFFECT, "MIDI Arpeggiator",
|
||||||
listOf(
|
listOf(
|
||||||
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
|
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
|
||||||
ParamSpec("octaves", "Octaves", 1f, 1f, 4f),
|
ParamSpec("octaves", "Octaves", 1f, 1f, 4f, isInt = true),
|
||||||
ParamSpec("division", "Division", 3f, 0f, 6f), // index into TimeDivision
|
ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true), // index into TimeDivision
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TRANSPOSER(
|
TRANSPOSER(
|
||||||
ToolboxKind.EFFECT, "MIDI Transposer",
|
ToolboxKind.EFFECT, "MIDI Transposer",
|
||||||
listOf(ParamSpec("semitones", "Semitones", 0f, -24f, 24f)),
|
listOf(ParamSpec("semitones", "Semitones", 0f, -24f, 24f, isInt = true)),
|
||||||
),
|
),
|
||||||
LFO(
|
LFO(
|
||||||
ToolboxKind.EFFECT, "MIDI LFO",
|
ToolboxKind.EFFECT, "MIDI LFO",
|
||||||
@@ -85,25 +91,29 @@ enum class ToolboxType(
|
|||||||
ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
|
ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
|
||||||
ParamSpec("phase", "Phase", 0f, 0f, 1f),
|
ParamSpec("phase", "Phase", 0f, 0f, 1f),
|
||||||
ParamSpec("depth", "Depth", 0.5f, 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.
|
// "target" (which parameter to modulate) is stored as a string param.
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DELAY(
|
DELAY(
|
||||||
ToolboxKind.EFFECT, "Tape Delay",
|
ToolboxKind.EFFECT, "Tape Delay",
|
||||||
listOf(
|
// Four independent tape heads, each with its own tempo-synced division and an
|
||||||
ParamSpec("division", "Division", 3f, 0f, 6f),
|
// on/off flag, plus the common feedback / dry-wet / tape-strength controls.
|
||||||
ParamSpec("feedback", "Feedback", 0.4f, 0f, 0.95f),
|
// Feedback tops out below 1 (no runaway). Edited by ui/toolbox/DelayEditor.
|
||||||
ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f),
|
buildList {
|
||||||
ParamSpec("heads", "Tape Heads", 4f, 1f, 4f),
|
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))
|
||||||
REVERB(
|
val headDefaults = intArrayOf(
|
||||||
ToolboxKind.EFFECT, "Digital Reverb",
|
DelayDivisions.indexOf("1/4"), DelayDivisions.indexOf("1/8"),
|
||||||
listOf(
|
DelayDivisions.indexOf("1/4."), DelayDivisions.indexOf("1/2"),
|
||||||
ParamSpec("amount", "Amount", 0.3f, 0f, 1f),
|
)
|
||||||
ParamSpec("tone", "Tone", 0.5f, 0f, 1f),
|
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(
|
FILTER(
|
||||||
ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
|
ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
|
||||||
@@ -111,6 +121,16 @@ enum class ToolboxType(
|
|||||||
ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
|
ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
|
||||||
ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
|
ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
|
||||||
ParamSpec("resonance", "Resonance", 0.2f, 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(
|
EQ10(
|
||||||
@@ -121,10 +141,35 @@ enum class ToolboxType(
|
|||||||
BITCRUSHER(
|
BITCRUSHER(
|
||||||
ToolboxKind.EFFECT, "Bitcrusher",
|
ToolboxKind.EFFECT, "Bitcrusher",
|
||||||
listOf(
|
listOf(
|
||||||
ParamSpec("bits", "Bits", 8f, 1f, 16f),
|
ParamSpec("bits", "Bits", 8f, 1f, 16f, isInt = true),
|
||||||
ParamSpec("downsample", "Downsample", 1f, 1f, 32f),
|
ParamSpec("downsample", "Downsample", 1f, 1f, 32f, isInt = true),
|
||||||
ParamSpec("drive", "Drive", 0.2f, 0f, 1f),
|
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
|
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
|
||||||
@@ -137,7 +182,71 @@ enum class ToolboxType(
|
|||||||
}.toMutableMap()
|
}.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) {
|
enum class TimeDivision(val label: String, val beatFraction: Double) {
|
||||||
WHOLE("1/1", 4.0), HALF("1/2", 2.0), QUARTER("1/4", 1.0),
|
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),
|
EIGHTH("1/8", 0.5), SIXTEENTH("1/16", 0.25),
|
||||||
@@ -179,9 +288,24 @@ class ToolboxSlot(
|
|||||||
values.clear()
|
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.
|
// 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 string(key: String, fallback: String = ""): String = values[key] ?: fallback
|
||||||
fun set(key: String, value: Float) { values[key] = value.toString() }
|
fun set(key: String, value: Float) { values[key] = value.toString(); version++ }
|
||||||
fun set(key: String, value: String) { values[key] = value }
|
fun set(key: String, value: String) { values[key] = value; version++ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.reactorcoremeltdown.sizzletracker.ui
|
package com.reactorcoremeltdown.sizzletracker.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -42,6 +43,11 @@ private val TABS = listOf(
|
|||||||
@Composable
|
@Composable
|
||||||
fun SizzleApp(vm: AppViewModel) {
|
fun SizzleApp(vm: AppViewModel) {
|
||||||
val c = LocalRetro.current
|
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)) {
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
// ----- Active screen -----
|
// ----- Active screen -----
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.reactorcoremeltdown.sizzletracker.ui
|
|||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@@ -34,13 +35,21 @@ class AppViewModel(
|
|||||||
private val router: InputRouter,
|
private val router: InputRouter,
|
||||||
private val gamepadInput: com.reactorcoremeltdown.sizzletracker.input.GamepadInput,
|
private val gamepadInput: com.reactorcoremeltdown.sizzletracker.input.GamepadInput,
|
||||||
private val midiInput: com.reactorcoremeltdown.sizzletracker.input.MidiInput,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
// ----- Observable UI state -----
|
// ----- Observable UI state -----
|
||||||
var selectedTab by mutableIntStateOf(0); private set
|
var selectedTab by mutableIntStateOf(0); private set
|
||||||
var revision by mutableIntStateOf(0); private set // bump to force grid redraw
|
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)
|
var palette by mutableStateOf(com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.AMBER)
|
||||||
|
private set
|
||||||
|
|
||||||
// Tracker cursor position.
|
// Tracker cursor position.
|
||||||
var cursorTrack by mutableIntStateOf(0); private set
|
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
|
* edits on an empty tick resume from these so entry continues where you left
|
||||||
* off instead of jumping to a fixed default. */
|
* off instead of jumping to a fixed default. */
|
||||||
var lastNote by mutableIntStateOf(Pitch.MIDDLE_C); private set
|
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). */
|
/** Live transport snapshot from the audio thread (drives the playhead). */
|
||||||
val transport get() = engine.transport
|
val transport get() = engine.transport
|
||||||
@@ -100,9 +129,70 @@ class AppViewModel(
|
|||||||
cursorLine = (cursorLine + dLine).coerceIn(0, p.length - 1)
|
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 nextColumn() { cursorColumn = nextOf(cursorColumn, +1) }
|
||||||
fun prevColumn() { 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.
|
* The core value-editing operation, shared by drag gestures and +/- input.
|
||||||
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
||||||
@@ -217,6 +307,84 @@ class AppViewModel(
|
|||||||
|
|
||||||
fun stop() = engine.stop()
|
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
|
// --------------------------------------------- project-level setting edits
|
||||||
fun setTempo(bpm: Float) { project.tempoBpm = bpm.coerceIn(20f, 300f); touched() }
|
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. */
|
/** The single dispatch point for keyboard / gamepad / MIDI actions. */
|
||||||
private fun onAction(action: InputAction) {
|
private fun onAction(action: InputAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
InputAction.NavUp -> moveCursor(0, -1)
|
// External up/down jump by the skip step; left/right traverse columns.
|
||||||
InputAction.NavDown -> moveCursor(0, +1)
|
InputAction.NavUp -> moveCursorVerticalWrap(-skipStep)
|
||||||
InputAction.NavLeft -> moveCursor(-1, 0)
|
InputAction.NavDown -> moveCursorVerticalWrap(+skipStep)
|
||||||
InputAction.NavRight -> moveCursor(+1, 0)
|
InputAction.NavLeft -> moveColumnFlat(-1)
|
||||||
|
InputAction.NavRight -> moveColumnFlat(+1)
|
||||||
InputAction.NextColumn -> nextColumn()
|
InputAction.NextColumn -> nextColumn()
|
||||||
InputAction.PrevColumn -> prevColumn()
|
InputAction.PrevColumn -> prevColumn()
|
||||||
is InputAction.Increment -> editFocused(action.amount)
|
is InputAction.Increment -> editFocused(action.amount)
|
||||||
@@ -250,13 +419,14 @@ class AppViewModel(
|
|||||||
InputAction.ClearCell -> clearFocused()
|
InputAction.ClearCell -> clearFocused()
|
||||||
InputAction.NoteOffCell -> noteOffFocused()
|
InputAction.NoteOffCell -> noteOffFocused()
|
||||||
is InputAction.NoteOn -> {
|
is InputAction.NoteOn -> {
|
||||||
|
markNoteOn(action.pitch)
|
||||||
engine.liveNoteOn(action.pitch, action.velocity)
|
engine.liveNoteOn(action.pitch, action.velocity)
|
||||||
// Live entry also writes the note into the focused NOTE cell.
|
// Live entry also writes the note into the focused NOTE cell.
|
||||||
if (cursorColumn == CellColumn.NOTE) {
|
if (cursorColumn == CellColumn.NOTE) {
|
||||||
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
|
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.PlayPause -> playPause()
|
||||||
InputAction.Stop -> stop()
|
InputAction.Stop -> stop()
|
||||||
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
|
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) {
|
fun beginLearn(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
|
||||||
learnTarget = actionId
|
learnTarget = actionId
|
||||||
learnSource = source
|
learnSource = source
|
||||||
|
router.learnSource = source
|
||||||
router.learnMode = true
|
router.learnMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelLearn() {
|
fun cancelLearn() {
|
||||||
learnTarget = null
|
learnTarget = null
|
||||||
learnSource = null
|
learnSource = null
|
||||||
|
router.learnSource = null
|
||||||
router.learnMode = false
|
router.learnMode = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An unbound control arrived. If we're learning and it matches the armed
|
/** A control arrived during learn. If it matches the armed source, store it as
|
||||||
* source, store the binding into the right handler's map. */
|
* 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) {
|
private fun onRawControl(action: InputAction.RawControl) {
|
||||||
val target = learnTarget ?: return
|
val target = learnTarget ?: return
|
||||||
if (action.source != learnSource) return
|
if (action.source != learnSource) return
|
||||||
when (action.source) {
|
when (action.source) {
|
||||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
||||||
midiInput.ccBindings[action.code] = target
|
midiInput.ccBindings[action.code] = target
|
||||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD -> {
|
||||||
gamepadInput.bindings[action.code] = target
|
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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
cancelLearn()
|
cancelLearn()
|
||||||
touched()
|
touched()
|
||||||
|
persistBindings()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current MIDI CC number bound to [actionId], or null. */
|
/** Current MIDI CC number bound to [actionId], or null. */
|
||||||
fun midiBindingFor(actionId: String): Int? =
|
fun midiBindingFor(actionId: String): Int? =
|
||||||
midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key
|
midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key
|
||||||
|
|
||||||
/** Current gamepad key code bound to [actionId], or null. */
|
/** Current gamepad chord (modifier + button, or plain button) bound to
|
||||||
fun gamepadBindingFor(actionId: String): Int? =
|
* [actionId], or null. */
|
||||||
|
fun gamepadBindingFor(actionId: String): com.reactorcoremeltdown.sizzletracker.input.GamepadChord? =
|
||||||
gamepadInput.bindings.entries.firstOrNull { it.value == actionId }?.key
|
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) {
|
fun clearBinding(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
|
||||||
when (source) {
|
when (source) {
|
||||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
||||||
midiInput.ccBindings.entries.removeAll { it.value == actionId }
|
midiInput.ccBindings.entries.removeAll { it.value == actionId }
|
||||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
|
||||||
gamepadInput.bindings.entries.removeAll { it.value == actionId }
|
gamepadInput.bindings.entries.removeAll { it.value == actionId }
|
||||||
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.KEYBOARD ->
|
||||||
|
keyboardInput.bindings.entries.removeAll { it.value == actionId }
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
touched()
|
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++ }
|
private fun touched() { revision++ }
|
||||||
@@ -330,20 +525,86 @@ class AppViewModel(
|
|||||||
touched()
|
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
|
// -------------------------------------------------------------- sampler
|
||||||
private val recorder = com.reactorcoremeltdown.sizzletracker.audio.SampleRecorder()
|
private val recorder = com.reactorcoremeltdown.sizzletracker.audio.SampleRecorder()
|
||||||
var isRecording by mutableStateOf(false); private set
|
var isRecording by mutableStateOf(false); private set
|
||||||
|
|
||||||
/** Store already-decoded sample bytes into a slot (from a WAV file import). */
|
/** Decode a WAV and assign it to Sampler [slot]'s [pad] (from a file import). */
|
||||||
fun loadSampleInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, id: String, bytes: ByteArray): Boolean {
|
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
|
val sample = com.reactorcoremeltdown.sizzletracker.audio.SampleStore.decodeWav(bytes) ?: return false
|
||||||
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
||||||
slot.set("samplePath", id)
|
val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
|
||||||
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
|
slot.set(pads.key(pad), id)
|
||||||
|
slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
|
||||||
touched()
|
touched()
|
||||||
return true
|
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 }
|
fun startRecording() { if (recorder.start()) isRecording = true }
|
||||||
|
|
||||||
// ------------------------------------------------------- audio device routing
|
// ------------------------------------------------------- audio device routing
|
||||||
@@ -369,17 +630,65 @@ class AppViewModel(
|
|||||||
fun applyNativeEngine(enabled: Boolean) {
|
fun applyNativeEngine(enabled: Boolean) {
|
||||||
engine.setNativeOutput(enabled)
|
engine.setNativeOutput(enabled)
|
||||||
nativeEngine = engine.useNativeOutput
|
nativeEngine = engine.useNativeOutput
|
||||||
|
persistSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop recording and load the captured audio into [slot]. */
|
/** Choose the colour theme and persist the choice. */
|
||||||
fun stopRecordingInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot) {
|
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()
|
val sample = recorder.stop()
|
||||||
isRecording = false
|
isRecording = false
|
||||||
if (sample != null) {
|
if (sample != null) {
|
||||||
val id = "rec-" + System.currentTimeMillis()
|
val id = "rec-" + System.currentTimeMillis()
|
||||||
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
||||||
slot.set("samplePath", id)
|
val pads = com.reactorcoremeltdown.sizzletracker.model.SamplerPads
|
||||||
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
|
slot.set(pads.key(pad), id)
|
||||||
|
slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
|
||||||
touched()
|
touched()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,8 +717,9 @@ class AppViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
|
/** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
|
||||||
fun auditionOn(slotIndex: Int, midi: Int) = engine.auditionSlotOn(slotIndex, midi)
|
fun auditionOn(slotIndex: Int, midi: Int) { markNoteOn(midi); engine.auditionSlotOn(slotIndex, midi) }
|
||||||
fun auditionOff(midi: Int) = engine.auditionSlotOff(midi)
|
fun auditionOff(midi: Int) { markNoteOff(midi); engine.auditionSlotOff(midi) }
|
||||||
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ project I/O
|
// ------------------------------------------------------------ project I/O
|
||||||
/** Serialize the current song to `.sng` text. */
|
/** Serialize the current song to `.sng` text. */
|
||||||
@@ -434,6 +744,96 @@ class AppViewModel(
|
|||||||
touched()
|
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 {
|
private fun nextOf(c: CellColumn, dir: Int): CellColumn {
|
||||||
val values = CellColumn.entries
|
val values = CellColumn.entries
|
||||||
return values[(c.ordinal + dir + values.size) % values.size]
|
return values[(c.ordinal + dir + values.size) % values.size]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,8 +62,10 @@ fun PianoOctave(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A single piano key face: light for white keys, dark for black, dimmed when
|
* 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
|
* [enabled] is false and accent-filled when [selected]. [pressed] lights the key
|
||||||
* supplied by the caller through [modifier].
|
* 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
|
@Composable
|
||||||
fun PianoKey(
|
fun PianoKey(
|
||||||
@@ -72,26 +74,29 @@ fun PianoKey(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
pressed: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
|
val lit = selected || pressed
|
||||||
val bg = when {
|
val bg = when {
|
||||||
selected -> c.accent
|
lit -> c.accent
|
||||||
isBlack -> if (enabled) BLACK_KEY else BLACK_KEY_OFF
|
isBlack -> if (enabled) BLACK_KEY else BLACK_KEY_OFF
|
||||||
else -> if (enabled) WHITE_KEY else WHITE_KEY_OFF
|
else -> if (enabled) WHITE_KEY else WHITE_KEY_OFF
|
||||||
}
|
}
|
||||||
val fg = when {
|
val fg = when {
|
||||||
selected -> c.background
|
lit -> c.background
|
||||||
isBlack -> if (enabled) Color(0xFFDDDDDD) else Color(0xFF555555)
|
isBlack -> if (enabled) Color(0xFFDDDDDD) else Color(0xFF555555)
|
||||||
else -> if (enabled) Color(0xFF141414) else Color(0xFF6A6A6A)
|
else -> if (enabled) Color(0xFF141414) else Color(0xFF6A6A6A)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.background(bg)
|
.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,
|
contentAlignment = Alignment.BottomCenter,
|
||||||
) {
|
) {
|
||||||
Text(
|
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),
|
modifier = Modifier.padding(bottom = 3.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,21 +48,24 @@ fun RetroButton(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
active: Boolean = false,
|
active: Boolean = false,
|
||||||
width: Dp? = null,
|
width: Dp? = null,
|
||||||
|
danger: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalRetro.current
|
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(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.then(if (width != null) Modifier.width(width) else Modifier)
|
.then(if (width != null) Modifier.width(width) else Modifier)
|
||||||
.border(1.dp, if (active) c.accent else c.grid, RectangleShape)
|
.border(1.dp, hue ?: c.grid, RectangleShape)
|
||||||
.background(if (active) c.accent.copy(alpha = 0.18f) else c.surface)
|
.background(hue?.copy(alpha = 0.18f) ?: c.surface)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = if (active) c.accent else c.text,
|
color = hue ?: c.text,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.reactorcoremeltdown.sizzletracker.ui.mixer
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -44,14 +48,74 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
|||||||
fun MixerScreen(vm: AppViewModel) {
|
fun MixerScreen(vm: AppViewModel) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||||
Row(Modifier.fillMaxSize().background(c.background).padding(6.dp),
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
RecordToolbar(vm)
|
||||||
vm.project.mixer.channels.forEach { channel ->
|
Row(
|
||||||
ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
|
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
|
@Composable
|
||||||
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
|
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@@ -93,7 +157,7 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
|
|||||||
|
|
||||||
SectionLabel("MIDI Ch")
|
SectionLabel("MIDI Ch")
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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(
|
Text(
|
||||||
"${channel.midiChannel}", color = c.text,
|
"${channel.midiChannel}", color = c.text,
|
||||||
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
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.
|
// Vertical fader fills the remaining strip height.
|
||||||
VerticalFader(
|
VerticalFader(
|
||||||
value = channel.volume,
|
value = channel.volume,
|
||||||
|
default = Mixer.DEFAULT_VOLUME,
|
||||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
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.
|
/** 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
|
@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
|
val c = LocalRetro.current
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.border(1.dp, c.grid, RectangleShape)
|
.border(1.dp, c.grid, RectangleShape)
|
||||||
.background(c.background)
|
.background(c.background)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(default) {
|
||||||
detectTapGestures { off -> onValueChange(snapZero(1f - off.y / size.height)) }
|
detectTapGestures { off -> onValueChange(snapDefault(1f - off.y / size.height, default)) }
|
||||||
}
|
}
|
||||||
.pointerInput(Unit) {
|
.pointerInput(default) {
|
||||||
detectDragGestures { change, _ ->
|
detectDragGestures { change, _ ->
|
||||||
change.consume()
|
change.consume()
|
||||||
onValueChange(snapZero(1f - change.position.y / size.height))
|
onValueChange(snapDefault(1f - change.position.y / size.height, default))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.BottomCenter,
|
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. */
|
/** Snap the fader to the [default] level when the raw value lands within a small
|
||||||
private fun snapZero(raw: Float): Float {
|
* 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)
|
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
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package com.reactorcoremeltdown.sizzletracker.ui.settings
|
package com.reactorcoremeltdown.sizzletracker.ui.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.AudioDeviceInfo
|
||||||
import android.media.AudioManager
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.ContentPaste
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
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.Palette
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.SportsEsports
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.dp
|
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.input.InputSource
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
|
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
|
* project save/load, theme selection + export, gamepad bindings, and a
|
||||||
* searchable / collapsible MIDI binding list.
|
* searchable / collapsible MIDI binding list.
|
||||||
*/
|
*/
|
||||||
private enum class SettingsSection { MAIN, GAMEPAD, MIDI }
|
private enum class SettingsSection { MAIN, GAMEPAD, KEYBOARD, MIDI }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(vm: AppViewModel) {
|
fun SettingsScreen(vm: AppViewModel) {
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
var section by remember { mutableStateOf(SettingsSection.MAIN) }
|
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) {
|
when (section) {
|
||||||
// Top level stays light: the heavy binding lists live in their own
|
// Top level stays light: the heavy binding lists live in their own
|
||||||
// subsections, so they only compose when the user actually opens them.
|
// subsections, so they only compose when the user actually opens them.
|
||||||
@@ -78,14 +93,20 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
Modifier.fillMaxSize().padding(12.dp),
|
Modifier.fillMaxSize().padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
item { PanicButton(vm) }
|
||||||
item { ProjectCard(vm) }
|
item { ProjectCard(vm) }
|
||||||
item { AudioDevicesCard(vm) }
|
item { AudioDevicesCard(vm) }
|
||||||
item { ThemeCard(vm) }
|
item { ThemeCard(vm) }
|
||||||
item {
|
item {
|
||||||
NavCard("Gamepad Bindings", "Rebind buttons", Icons.Filled.SportsEsports) {
|
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
|
||||||
section = SettingsSection.GAMEPAD
|
section = SettingsSection.GAMEPAD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
NavCard("Keyboard Hotkeys", "Rebind keyboard keys", Icons.Filled.Keyboard) {
|
||||||
|
section = SettingsSection.KEYBOARD
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
|
NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
|
||||||
section = SettingsSection.MIDI
|
section = SettingsSection.MIDI
|
||||||
@@ -99,8 +120,13 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
) {
|
) {
|
||||||
item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
|
item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
|
||||||
item {
|
item {
|
||||||
Text("Tap Learn, then press a button to bind it.",
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
"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 ->
|
items(BIND_MODULES.values.flatten()) { action ->
|
||||||
BindingRow(vm, action, InputSource.GAMEPAD)
|
BindingRow(vm, action, InputSource.GAMEPAD)
|
||||||
@@ -108,6 +134,24 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsSection.KEYBOARD -> LazyColumn(
|
||||||
|
Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
item { SubHeader("Keyboard Hotkeys") { section = SettingsSection.MAIN } }
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Tap Learn, then press a key to bind it. The two-octave typing " +
|
||||||
|
"piano (Z–M, Q–U) is fixed and unaffected.",
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(KEYBOARD_MODULES.values.flatten()) { action ->
|
||||||
|
BindingRow(vm, action, InputSource.KEYBOARD)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsSection.MIDI -> LazyColumn(
|
SettingsSection.MIDI -> LazyColumn(
|
||||||
Modifier.fillMaxSize().padding(12.dp),
|
Modifier.fillMaxSize().padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.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
|
// ---------------------------------------------------------------- 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
|
@Composable
|
||||||
private fun ProjectCard(vm: AppViewModel) {
|
private fun ProjectCard(vm: AppViewModel) {
|
||||||
val clipboard = LocalClipboardManager.current
|
val context = LocalContext.current
|
||||||
SettingsCard("Project", subtitle = "Interchange with desktop sizzletracker (.sng), or full save") {
|
@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)) {
|
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))
|
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||||
Text("Export .sng")
|
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))
|
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
|
||||||
Text("Import .sng")
|
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(
|
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,
|
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
|
@Composable
|
||||||
@@ -209,7 +378,7 @@ private fun ThemeCard(vm: AppViewModel) {
|
|||||||
RetroPalette.ALL.forEach { p ->
|
RetroPalette.ALL.forEach { p ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = vm.palette.name == p.name,
|
selected = vm.palette.name == p.name,
|
||||||
onClick = { vm.palette = p },
|
onClick = { vm.selectPalette(p) },
|
||||||
label = { Text(p.name) },
|
label = { Text(p.name) },
|
||||||
leadingIcon = { Icon(Icons.Filled.Palette, null) },
|
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")),
|
"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. */
|
/** One action row with its current binding, a Learn toggle, and a clear button. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
|
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
|
||||||
val listening = vm.learnTarget == action.id
|
val listening = vm.learnTarget == action.id
|
||||||
val code = if (source == InputSource.MIDI) vm.midiBindingFor(action.id) else vm.gamepadBindingFor(action.id)
|
val label: String
|
||||||
val label = code?.let { (if (source == InputSource.MIDI) "CC $it" else "key $it") } ?: "unbound"
|
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(
|
ListItem(
|
||||||
headlineContent = { Text(action.label) },
|
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) }) {
|
TextButton(onClick = { if (listening) vm.cancelLearn() else vm.beginLearn(action.id, source) }) {
|
||||||
Text(if (listening) "Listening…" else "Learn")
|
Text(if (listening) "Listening…" else "Learn")
|
||||||
}
|
}
|
||||||
if (code != null) {
|
if (bound) {
|
||||||
TextButton(onClick = { vm.clearBinding(action.id, source) }) { Text("✕") }
|
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). */
|
/** A label + dropdown that routes audio to a chosen device (or the default). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeviceRow(
|
private fun DeviceRow(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
|||||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One editable parameter row. Enumerated params ([ParamSpec.isEnum]) render as a
|
* One editable parameter row. Enumerated params ([ParamSpec.isEnum]) render as a
|
||||||
@@ -35,17 +36,24 @@ fun ParamControl(vm: AppViewModel, slot: ToolboxSlot, spec: ParamSpec) {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val value = slot.float(spec.key, spec.default)
|
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()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Row {
|
Row {
|
||||||
Text("${spec.label}: ", color = c.textDim,
|
Text("${spec.label}: ", color = c.textDim,
|
||||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
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)
|
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
}
|
}
|
||||||
Slider(
|
Slider(
|
||||||
value = value.coerceIn(spec.min, spec.max),
|
value = value.coerceIn(spec.min, spec.max),
|
||||||
valueRange = 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(
|
colors = SliderDefaults.colors(
|
||||||
thumbColor = c.accent, activeTrackColor = c.accent, inactiveTrackColor = c.grid,
|
thumbColor = c.accent, activeTrackColor = c.accent, inactiveTrackColor = c.grid,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -19,9 +21,11 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -29,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.reactorcoremeltdown.sizzletracker.audio.SampleStore
|
import com.reactorcoremeltdown.sizzletracker.audio.SampleStore
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.SamplerPads
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||||
@@ -36,56 +41,68 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Sampler's custom editor (replaces the generic parameter list for the
|
* The Sampler's custom editor: a 16-pad drum-style bank plus a per-pad detail area.
|
||||||
* 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
|
* The bottom half is a 4x4 grid of pads mapped to consecutive notes from C2
|
||||||
* octave + volume, and audition across a two-octave keyboard.
|
* ([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
|
@Composable
|
||||||
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
val context = LocalContext.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")
|
var pad by remember(slot.index) { mutableIntStateOf(0) }
|
||||||
val sliceStart = slot.float("sliceStart", 0f)
|
val sampleId = slot.string(SamplerPads.key(pad))
|
||||||
val sliceEnd = slot.float("sliceEnd", 1f)
|
val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f)
|
||||||
val startOctave = slot.float("startOctave", 3f).toInt()
|
val sliceEnd = slot.float(SamplerPads.sliceEndKey(pad), 1f)
|
||||||
val volume = slot.float("volume", 0.8f)
|
|
||||||
|
|
||||||
// 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 ->
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
uri ?: return@rememberLauncherForActivityResult
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
runCatching {
|
runCatching {
|
||||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
}.getOrNull()?.let { bytes ->
|
}.getOrNull()?.let { bytes -> vm.loadSampleInto(slot, pad, uri.toString(), bytes) }
|
||||||
val ok = vm.loadSampleInto(slot, uri.toString(), bytes)
|
|
||||||
if (!ok) { /* unsupported file; a toast could go here */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// ----- Source buttons -----
|
// ----- Selected pad + source buttons (act on the selected pad) -----
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
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/*")) }
|
RetroButton("IMPORT WAV") { picker.launch(arrayOf("audio/*")) }
|
||||||
if (vm.isRecording) {
|
if (vm.isRecording) {
|
||||||
RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot) }
|
RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot, pad) }
|
||||||
} else {
|
} else {
|
||||||
RetroButton("● RECORD") { vm.startRecording() }
|
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 sample = SampleStore.get(sampleId)
|
||||||
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||||
var dragging by remember { mutableIntStateOf(-1) }
|
var dragging by remember { mutableIntStateOf(-1) }
|
||||||
|
|
||||||
Canvas(
|
Canvas(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(130.dp)
|
.height(110.dp)
|
||||||
.background(c.surface)
|
.background(c.surface)
|
||||||
.pointerInput(sampleId) {
|
.pointerInput(sampleId, pad) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { off ->
|
onDragStart = { off ->
|
||||||
val frac = (off.x / size.width).coerceIn(0f, 1f)
|
val frac = (off.x / size.width).coerceIn(0f, 1f)
|
||||||
@@ -94,7 +111,8 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
|||||||
onDrag = { change, _ ->
|
onDrag = { change, _ ->
|
||||||
change.consume()
|
change.consume()
|
||||||
val frac = (change.position.x / size.width).coerceIn(0f, 1f)
|
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()
|
vm.bumpForToolbar()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -108,53 +126,170 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
|||||||
val bw = w / mins.size
|
val bw = w / mins.size
|
||||||
for (i in mins.indices) {
|
for (i in mins.indices) {
|
||||||
val x = i * bw
|
val x = i * bw
|
||||||
drawLine(
|
drawLine(c.text, Offset(x, mid - maxs[i] * mid), Offset(x, mid - mins[i] * mid), strokeWidth = 1f)
|
||||||
color = c.text,
|
|
||||||
start = Offset(x, mid - maxs[i] * mid),
|
|
||||||
end = Offset(x, mid - mins[i] * mid),
|
|
||||||
strokeWidth = 1f,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Shade the active slice region and draw the two markers.
|
|
||||||
val sx = sliceStart * w
|
val sx = sliceStart * w
|
||||||
val ex = sliceEnd * 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.accent, Offset(sx, 0f), Offset(sx, h), strokeWidth = 3f)
|
||||||
drawLine(c.playhead, Offset(ex, 0f), Offset(ex, h), strokeWidth = 3f)
|
drawLine(c.playhead, Offset(ex, 0f), Offset(ex, h), strokeWidth = 3f)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
if (sample == null) "No sample loaded — import a WAV or record one."
|
if (sample == null) "Pad empty — Import a WAV or Record to assign it."
|
||||||
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers to trim",
|
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers, then CLIP to trim",
|
||||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ----- Start octave + volume -----
|
// ----- Per-pad output volume: 16 vertical sliders laid out 8 x 2 -----
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Text(
|
||||||
Text("Start oct:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
"Pad volumes (drag a slider; tap selects that pad):",
|
||||||
modifier = Modifier.padding(top = 6.dp))
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||||
RetroButton("-") { slot.set("startOctave", (startOctave - 1).coerceAtLeast(0).toFloat()); vm.bumpForToolbar() }
|
)
|
||||||
Text(" $startOctave ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
PadVolumeBank(vm, slot, pad, rev, onSelect = { pad = it })
|
||||||
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))
|
|
||||||
|
|
||||||
// ----- Two-octave audition keyboard -----
|
// ----- 16-pad bank (bottom section) -----
|
||||||
Text("Audition (2 octaves):", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
Text(
|
||||||
val root = ((startOctave + 1) * 12).coerceIn(0, 103)
|
"Pads (from C2 — tap to test & select, long-press to clear):",
|
||||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
)
|
||||||
for (n in 0 until 24) {
|
// Pass rev so assign/clear (which only bump revision) redraw the pads: a
|
||||||
val midi = root + n
|
// changed Int param defeats Compose strong-skipping on these leaves.
|
||||||
RetroButton(Pitch.name(midi)) { vm.auditionSample(slot, midi) }
|
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. */
|
@Composable
|
||||||
private fun computePeaks(data: FloatArray, buckets: Int): Pair<FloatArray, FloatArray> {
|
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 mins = FloatArray(buckets)
|
||||||
val maxs = FloatArray(buckets)
|
val maxs = FloatArray(buckets)
|
||||||
if (data.isEmpty()) return mins to maxs
|
if (data.isEmpty()) return mins to maxs
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,37 @@
|
|||||||
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
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.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -29,19 +41,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.Pitch
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
|
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
|
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
|
||||||
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
|
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
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,6 +78,12 @@ fun ToolboxScreen(vm: AppViewModel) {
|
|||||||
var editing by remember { mutableStateOf<Int?>(null) }
|
var editing by remember { mutableStateOf<Int?>(null) }
|
||||||
var picking 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)) {
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(4),
|
columns = GridCells.Fixed(4),
|
||||||
@@ -87,7 +106,7 @@ fun ToolboxScreen(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
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 ->
|
picking?.let { index ->
|
||||||
@@ -148,70 +167,7 @@ private fun SlotTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------- test keyboard
|
// ---------------------------------------------------------- MIDI piano
|
||||||
|
|
||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A simple overlay list of all available instruments and effects to load. */
|
/** A simple overlay list of all available instruments and effects to load. */
|
||||||
@Composable
|
@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
|
@Composable
|
||||||
private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
|
private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
|
||||||
val type = slot.type ?: return
|
val type = slot.type ?: return
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
|
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)) {
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
|
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() } })
|
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 Sampler gets a bespoke editor (waveform + slicing + record);
|
||||||
// the LFO adds a modulation-target picker; everything else uses the
|
// the LFO adds a modulation-target picker; everything else uses the
|
||||||
// auto-generated parameter list.
|
// auto-generated parameter list.
|
||||||
when (type) {
|
when (type) {
|
||||||
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
|
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
|
||||||
|
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
|
||||||
|
ToolboxType.DELAY -> DelayEditor(vm, slot)
|
||||||
ToolboxType.LFO -> {
|
ToolboxType.LFO -> {
|
||||||
type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
||||||
LfoTargetPicker(vm, slot)
|
LfoTargetPicker(vm, slot)
|
||||||
}
|
}
|
||||||
else -> type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
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
|
@Composable
|
||||||
private fun PresetButton(label: String, onClick: () -> Unit) {
|
private fun PresetBar(vm: AppViewModel, slot: ToolboxSlot) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
Text(
|
val context = LocalContext.current
|
||||||
"[$label]",
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after save/import
|
||||||
color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
val type = slot.type ?: return
|
||||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClick() } }.padding(4.dp),
|
|
||||||
)
|
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -28,7 +29,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
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.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.TextStyle
|
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
|
* 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.
|
* 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
|
* Each lane is tied to its own tracker block (lane i ⟷ block i). Cells are NOT
|
||||||
* cell toggles that lane's block on/off at that beat; when the playhead crosses
|
* toggled by tapping; instead you SELECT cells (tap a single cell, or long-press
|
||||||
* an enabled cell, the sequencer plays that block. Tap a lane number to edit that
|
* and drag to sweep a rectangle) and then use the FILL / CLEAR toggle in the loop
|
||||||
* block in the tracker above.
|
* 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
|
@Composable
|
||||||
fun ArrangementRoll(vm: AppViewModel) {
|
fun ArrangementRoll(vm: AppViewModel) {
|
||||||
@@ -80,10 +87,25 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
val beatWidthPx = with(density) { beatWidth.toPx() }
|
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||||
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||||
|
|
||||||
var selStart by remember { mutableIntStateOf(-1) }
|
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none.
|
||||||
var selEnd by remember { mutableIntStateOf(-1) }
|
// 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 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
|
// Follow the playhead by scrolling horizontally. Collects the transport flow
|
||||||
// directly rather than keying a LaunchedEffect on a composition read of the beat,
|
// 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)) {
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
@@ -131,6 +179,8 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
Modifier
|
Modifier
|
||||||
.width(beatWidth * arr.lengthBeats)
|
.width(beatWidth * arr.lengthBeats)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
// Tap selects a single cell (no toggle). The ruler is
|
||||||
|
// display-only, so ruler taps are ignored.
|
||||||
.pointerInput(arr, sig) {
|
.pointerInput(arr, sig) {
|
||||||
detectTapGestures { off ->
|
detectTapGestures { off ->
|
||||||
if (off.y < rulerHeightPx) return@detectTapGestures
|
if (off.y < rulerHeightPx) return@detectTapGestures
|
||||||
@@ -138,12 +188,29 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
||||||
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||||
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 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.
|
selLane0 = lane; selLane1 = lane
|
||||||
if (arr.patternAt(lane, beat) != ArrModel.EMPTY) arr.set(lane, beat, ArrModel.EMPTY)
|
selBeat0 = beat; selBeat1 = beat
|
||||||
else arr.set(lane, beat, lane)
|
|
||||||
if (selStart < 0) selStart = beat else selEnd = beat
|
|
||||||
vm.bumpForToolbar()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 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,
|
// 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.
|
// Lane cells.
|
||||||
for (lane in 0 until ArrModel.LANE_COUNT) {
|
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||||
val laneY = rulerHeightPx + lane * laneH
|
val laneY = rulerHeightPx + lane * laneH
|
||||||
@@ -177,7 +266,7 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
val x = beat * beatWidthPx
|
val x = beat * beatWidthPx
|
||||||
val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY
|
val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY
|
||||||
val onPlayhead = transport.isPlaying && beat == transport.currentBeat
|
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 {
|
val fill = when {
|
||||||
filled -> c.accent.copy(alpha = 0.85f)
|
filled -> c.accent.copy(alpha = 0.85f)
|
||||||
onPlayhead -> c.playhead.copy(alpha = 0.3f)
|
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)
|
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.
|
// Playhead marker across the whole roll.
|
||||||
if (transport.isPlaying) {
|
if (transport.isPlaying) {
|
||||||
val px = transport.currentBeat * beatWidthPx
|
val px = transport.currentBeat * beatWidthPx
|
||||||
@@ -204,8 +307,11 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
LoopToolbar(
|
LoopToolbar(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
arr = arr,
|
arr = arr,
|
||||||
onAssignA = { assign(arr.regionA, selStart, selEnd); vm.bumpForToolbar() },
|
hasSelection = hasSelection,
|
||||||
onAssignB = { assign(arr.regionB, selStart, selEnd); vm.bumpForToolbar() },
|
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(
|
private fun LoopToolbar(
|
||||||
vm: AppViewModel,
|
vm: AppViewModel,
|
||||||
arr: ArrModel,
|
arr: ArrModel,
|
||||||
onAssignA: () -> Unit,
|
hasSelection: Boolean,
|
||||||
onAssignB: () -> Unit,
|
selectionAllFilled: Boolean,
|
||||||
|
onToggleCells: () -> Unit,
|
||||||
|
onTapA: () -> Unit,
|
||||||
|
onTapB: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().background(c.surface).padding(6.dp),
|
Modifier.fillMaxWidth().background(c.surface).horizontalScroll(rememberScrollState()).padding(6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
RetroButton("LOOP", active = arr.loopEnabled) {
|
RetroButton("LOOP", active = arr.loopEnabled) {
|
||||||
arr.loopEnabled = !arr.loopEnabled; vm.bumpForToolbar()
|
arr.loopEnabled = !arr.loopEnabled; vm.bumpForToolbar()
|
||||||
}
|
}
|
||||||
RegionControl("A", arr.regionA, onAssign = onAssignA, vm = vm)
|
// Fill/clear the current cell selection (replaces tap-to-toggle on cells).
|
||||||
RegionControl("B", arr.regionB, onAssign = onAssignB, vm = vm)
|
// 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
|
@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
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
RetroButton(name, active = region.enabled) {
|
// Highlighted when the region is set; tapping a highlighted button removes
|
||||||
region.enabled = !region.enabled; onAssign()
|
// the region, otherwise it assigns the current selection to it.
|
||||||
}
|
RetroButton(name, active = region.isValid, onClick = onTap)
|
||||||
RetroButton("-") { region.repeats = (region.repeats - 1).coerceAtLeast(1); vm.bumpForToolbar() }
|
// 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)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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')
|
|
||||||
@@ -7,18 +7,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.drawText
|
import androidx.compose.ui.text.drawText
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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
|
* 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
|
* 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
|
@Composable
|
||||||
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
||||||
@@ -58,11 +54,8 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
|||||||
val transportState = vm.transport.collectAsState()
|
val transportState = vm.transport.collectAsState()
|
||||||
val pattern = vm.project.activePattern()
|
val pattern = vm.project.activePattern()
|
||||||
val sig = vm.project.timeSignature
|
val sig = vm.project.timeSignature
|
||||||
val haptic = LocalHapticFeedback.current
|
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
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).
|
// Cache the text styles per palette (fontSize is resolved with the ambient density).
|
||||||
val mono = FontFamily.Monospace
|
val mono = FontFamily.Monospace
|
||||||
@@ -113,20 +106,12 @@ fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.pointerInput(pattern, widthPx, heightPx) {
|
.pointerInput(pattern, widthPx, heightPx) {
|
||||||
detectTapGestures(
|
// Tap moves the cursor to the cell/column. Note entry is done with
|
||||||
// Tap: just move the cursor to the cell/column.
|
// the punch-in keyboard or external controllers — there is no
|
||||||
onTap = { off ->
|
// long-press cell editor.
|
||||||
hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
|
detectTapGestures { 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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Subscribe the DRAW PHASE to edit revisions. Model mutations (edit,
|
// 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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,14 @@ fun TrackerScreen(vm: AppViewModel) {
|
|||||||
.height(2.dp)
|
.height(2.dp)
|
||||||
.background(c.accent),
|
.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()) {
|
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,
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
RetroDropdown(
|
|
||||||
label = "BLK",
|
|
||||||
options = project.patterns.map { it.id },
|
|
||||||
selected = vm.activeBlock,
|
|
||||||
optionLabel = { "${it + 1}" },
|
|
||||||
onSelect = vm::setActiveBlock,
|
|
||||||
)
|
|
||||||
RetroDropdown(
|
RetroDropdown(
|
||||||
label = "LEN",
|
label = "LEN",
|
||||||
options = project.timeSignature.lengthOptions,
|
options = project.timeSignature.lengthOptions,
|
||||||
@@ -130,6 +129,13 @@ private fun TransportToolbar(vm: AppViewModel) {
|
|||||||
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
|
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
|
||||||
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
|
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
|
// Row 3: selection + clipboard edit operations. SEL toggles rectangular
|
||||||
// select mode (the cursor then drags out a region); the rest act on that
|
// 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)
|
RetroButton("DEL", onClick = vm::deleteSelection)
|
||||||
// Insert a note-off ("===") at the cursor.
|
// Insert a note-off ("===") at the cursor.
|
||||||
RetroButton("OFF ===", onClick = vm::noteOffFocused)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user