Initial commit: Sizzletracker Android music tracker
A retro, grid-based music tracker for Android (Kotlin + Jetpack Compose, single Activity) with four equally-capable input methods (touch, keyboard, gamepad, MIDI) and four tabs: tracker, mixer, toolbox, settings. Highlights: - Tracker: Canvas-drawn 4-track pattern grid over an 8-lane arrangement roll, with a glyph cache and draw-phase state reads so the playhead and edits redraw without per-frame recomposition. - Audio: sample-accurate sequencer feeding a shared AudioEngine, driven by either a Kotlin AudioTrack loop or native Oboe/AAudio via JNI (16 KB-aligned native libs). media3 MediaSession for lock-screen/headset transport. - Toolbox: 16 instrument/effect slots with a 2-octave audition keyboard; single-tap select, double-tap edit, long-press clear. - Note entry: long-press cell popups (piano keyboard / value steppers) plus keyboard/gamepad stepping that resumes from the last note/channel entered. Velocity capped at 0x7F, channel at 16. - Selection/clipboard (cut/copy/paste/delete) and .sng import/export compatible with the reference desktop tool. - A `profile` build type (non-debuggable, debug-signed) for realistic on-device performance testing. - Developer handover documentation under docs/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Android / Gradle / IDE build artifacts
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
/app/build/
|
||||||
|
local.properties
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Android Studio / IntelliJ
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# NOTE: gradle/wrapper/gradle-wrapper.jar IS committed (Gradle's recommended
|
||||||
|
# practice) so ./gradlew works on a fresh clone with no extra setup.
|
||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Sizzletracker (Android)
|
||||||
|
|
||||||
|
A retro, grid-based **music tracker** for Android — monospace UI, pixel icons,
|
||||||
|
demoscene-style pattern editing — playable equally with **touch, keyboard,
|
||||||
|
gamepad, and MIDI** (USB or Bluetooth).
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<img alt="platform" src="https://img.shields.io/badge/platform-Android%208%2B-0b0d0e">
|
||||||
|
<img alt="language" src="https://img.shields.io/badge/kotlin-2.0-7cfc9a">
|
||||||
|
<img alt="ui" src="https://img.shields.io/badge/UI-Jetpack%20Compose-f2c14e">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Features at a glance
|
||||||
|
|
||||||
|
- **Tracker tab** — 4-track pattern grid (note / velocity-hex / MIDI-channel per
|
||||||
|
track), beat & bar coloring, tap-to-focus + drag-to-edit with haptics and
|
||||||
|
scale-aware note entry; plus an 8-lane arrangement piano-roll (up to 256 beats)
|
||||||
|
with A/B loop regions.
|
||||||
|
- **Mix tab** — 4 channels, each with an instrument + 4 FX slots, MIDI channel,
|
||||||
|
volume, mute/solo.
|
||||||
|
- **Toolbox tab** — 16 slots for instruments (NES synth, sampler, SF2/XI) and
|
||||||
|
effects (arp, transposer, LFO, tape delay, reverb, filters, 10-band EQ,
|
||||||
|
bitcrusher). Every device has an auto-generated editor and text presets.
|
||||||
|
- **Setup tab** — `.sng` project save/load, color themes (with import/export),
|
||||||
|
gamepad bindings, and searchable/collapsible MIDI bindings.
|
||||||
|
- **Background playback** with a media notification (Play/Pause/Stop).
|
||||||
|
- **Two-thread design** — Compose UI on the main thread, a sample-accurate sound
|
||||||
|
engine on its own high-priority thread.
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
Open the project in **Android Studio** (2024.1+), let it create the Gradle
|
||||||
|
wrapper if prompted, and Run on an **API 26+** device or emulator.
|
||||||
|
|
||||||
|
CLI (after the wrapper exists — `gradle wrapper --gradle-version 8.9`):
|
||||||
|
```
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
See **[docs/DEVELOPER_HANDOVER.md](docs/DEVELOPER_HANDOVER.md)** — a from-scratch
|
||||||
|
tour written for a developer new to Android: architecture, the file map, the
|
||||||
|
"one action / one song / two threads" model, how to add an instrument or input,
|
||||||
|
and an honest **implemented-vs-TODO** list.
|
||||||
|
|
||||||
|
Fastest orientation in code:
|
||||||
|
`model/Project.kt` → `input/InputAction.kt` → `ui/AppViewModel.kt` →
|
||||||
|
`audio/AudioEngine.kt`.
|
||||||
|
|
||||||
|
## Project format
|
||||||
|
|
||||||
|
Songs use a human-readable `.sng` text format (see `io/ProjectIo.kt`), aligned in
|
||||||
|
spirit with the desktop
|
||||||
|
[Sizzletracker](https://github.com/reactorcoremeltdown/sizzletracker); a byte-exact
|
||||||
|
compatibility shim is a listed TODO in the handover doc.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
TBD by the project owner.
|
||||||
121
app/build.gradle.kts
Normal file
121
app/build.gradle.kts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Build configuration for the single application module. This is where we turn
|
||||||
|
// on Compose, set the min/target Android versions, and list dependencies.
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.reactorcoremeltdown.sizzletracker"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
// Native (Oboe) low-latency output backend; see src/main/cpp.
|
||||||
|
// NDK r27+ builds native libs 16 KB-page-aligned by default and ships a
|
||||||
|
// 16 KB-aligned libc++_shared.so (required for Android 15 / Google Play).
|
||||||
|
ndkVersion = "27.2.12479018"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.reactorcoremeltdown.sizzletracker"
|
||||||
|
// minSdk 26 (Android 8.0) is the floor for the native MIDI API
|
||||||
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake { arguments += "-DANDROID_STL=c++_shared" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
version = "3.22.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// Shrinking is off by default so newcomers get readable stack traces.
|
||||||
|
// Flip to true once the app stabilises.
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A realistic-performance variant for testing UI responsiveness on-device.
|
||||||
|
// The `debug` build is `debuggable = true`, which makes ART run the app
|
||||||
|
// un-optimised and makes Compose several times slower — so measuring UI
|
||||||
|
// "snappiness" on a debug build is misleading. This variant flips
|
||||||
|
// debuggability off (the single biggest perf lever) while reusing the debug
|
||||||
|
// signing key, so it still installs without a release keystore. R8/shrinking
|
||||||
|
// stays off to avoid needing JNI/Compose keep-rules for now.
|
||||||
|
create("profile") {
|
||||||
|
initWith(getByName("debug"))
|
||||||
|
isDebuggable = false
|
||||||
|
isJniDebuggable = false
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
matchingFallbacks += "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
prefab = true // exposes the Oboe AAR's native headers/lib to CMake
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
|
// Compose UI toolkit — the BOM keeps every Compose artifact on one version.
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
// Background playback + media-style notification transport controls.
|
||||||
|
implementation(libs.androidx.media3.session)
|
||||||
|
implementation(libs.androidx.media3.common)
|
||||||
|
|
||||||
|
// Persistence for settings & themes.
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
// Coroutines for the audio/sequencer background work.
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
// Oboe: low-latency native audio (AAudio) — the native output backend.
|
||||||
|
// 1.9.3+ ships 16 KB-page-aligned .so files (required by Android 15 / Play).
|
||||||
|
implementation("com.google.oboe:oboe:1.10.0")
|
||||||
|
|
||||||
|
// JVM unit tests for the pure model/io logic (no device needed).
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
3
app/proguard-rules.pro
vendored
Normal file
3
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# ProGuard/R8 rules for release builds. Minification is currently disabled in
|
||||||
|
# build.gradle.kts (isMinifyEnabled = false), so this file is intentionally empty.
|
||||||
|
# Add keep-rules here if/when you enable shrinking and hit reflection issues.
|
||||||
66
app/src/main/AndroidManifest.xml
Normal file
66
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- ===== Permissions =====
|
||||||
|
RECORD_AUDIO : ad-hoc sample recorder (mic / USB audio in).
|
||||||
|
FOREGROUND_SERVICE : keep the sequencer alive while the app is backgrounded.
|
||||||
|
FOREGROUND_SERVICE_MEDIA_PLAYBACK : Android 14 requires a typed FGS.
|
||||||
|
POST_NOTIFICATIONS : show the media transport notification (Android 13+).
|
||||||
|
BLUETOOTH_CONNECT : Bluetooth MIDI + gamepad on Android 12+. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
|
<!-- Hardware we can *use* but do not *require* (so the app still installs on
|
||||||
|
devices without them). Code must always feature-detect at runtime. -->
|
||||||
|
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||||
|
<uses-feature android:name="android.software.midi" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".SizzleApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="false"
|
||||||
|
android:theme="@style/Theme.Sizzletracker">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden|navigation|keyboard"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/Theme.Sizzletracker">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Announce that we can be a USB-MIDI aware app; the system routes
|
||||||
|
attach events here so we can auto-connect controllers. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||||
|
android:resource="@xml/usb_device_filter" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Foreground service that owns the audio engine so playback survives
|
||||||
|
when the UI is not visible, and drives the media notification. -->
|
||||||
|
<service
|
||||||
|
android:name=".playback.PlaybackService"
|
||||||
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
20
app/src/main/cpp/CMakeLists.txt
Normal file
20
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Native build for the Oboe (AAudio) low-latency output backend.
|
||||||
|
cmake_minimum_required(VERSION 3.22.1)
|
||||||
|
project(sizzle_native)
|
||||||
|
|
||||||
|
# Oboe ships as a prefab package inside the com.google.oboe:oboe AAR; AGP exposes
|
||||||
|
# it here when `buildFeatures { prefab = true }` is set in build.gradle.kts.
|
||||||
|
find_package(oboe REQUIRED CONFIG)
|
||||||
|
|
||||||
|
add_library(sizzle_native SHARED native_audio.cpp)
|
||||||
|
|
||||||
|
target_link_libraries(sizzle_native
|
||||||
|
oboe::oboe
|
||||||
|
log)
|
||||||
|
|
||||||
|
# Align ELF load segments to 16 KB so the library works on Android 15+ devices
|
||||||
|
# using 16 KB memory pages (and passes Google Play's requirement). NDK r27+ does
|
||||||
|
# this by default; on r26 we set it explicitly. -Bsymbolic is unrelated; the key
|
||||||
|
# flag is max-page-size.
|
||||||
|
target_link_options(sizzle_native PRIVATE
|
||||||
|
"-Wl,-z,max-page-size=16384")
|
||||||
111
app/src/main/cpp/native_audio.cpp
Normal file
111
app/src/main/cpp/native_audio.cpp
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Native Oboe (AAudio) output backend for Sizzletracker.
|
||||||
|
//
|
||||||
|
// Opens a low-latency mono float Oboe output stream. On each real-time data
|
||||||
|
// callback it calls back into Kotlin (NativeAudioBridge.renderAudio) to fill a
|
||||||
|
// reused Java float[] from the shared AudioEngine.fillBlock, then copies that
|
||||||
|
// into Oboe's output buffer. This keeps ONE implementation of the synth/mixer
|
||||||
|
// (in Kotlin) while delivering audio through the lower-latency native path.
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <oboe/Oboe.h>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#define LOG_TAG "SizzleNative"
|
||||||
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||||
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
JavaVM *g_vm = nullptr;
|
||||||
|
jobject g_callback = nullptr; // global ref to the NativeAudioBridge
|
||||||
|
jmethodID g_renderMethod = nullptr;
|
||||||
|
jfloatArray g_buffer = nullptr; // reused Java float[] of g_frames length
|
||||||
|
int g_frames = 0;
|
||||||
|
|
||||||
|
// Fetch a JNIEnv for the (persistent) audio callback thread, attaching once.
|
||||||
|
JNIEnv *callbackEnv() {
|
||||||
|
JNIEnv *env = nullptr;
|
||||||
|
if (g_vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) == JNI_EDETACHED) {
|
||||||
|
g_vm->AttachCurrentThread(&env, nullptr);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SizzleCallback : public oboe::AudioStreamDataCallback {
|
||||||
|
public:
|
||||||
|
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData,
|
||||||
|
int32_t numFrames) override {
|
||||||
|
auto *out = static_cast<float *>(audioData);
|
||||||
|
JNIEnv *env = callbackEnv();
|
||||||
|
if (!env || !g_callback || !g_renderMethod || !g_buffer) {
|
||||||
|
for (int i = 0; i < numFrames; ++i) out[i] = 0.0f;
|
||||||
|
return oboe::DataCallbackResult::Continue;
|
||||||
|
}
|
||||||
|
const int n = numFrames < g_frames ? numFrames : g_frames;
|
||||||
|
// Ask Kotlin to render n frames into the reused Java buffer ...
|
||||||
|
env->CallVoidMethod(g_callback, g_renderMethod, g_buffer, n);
|
||||||
|
// ... then copy them out to Oboe (and pad any remainder with silence).
|
||||||
|
env->GetFloatArrayRegion(g_buffer, 0, n, out);
|
||||||
|
for (int i = n; i < numFrames; ++i) out[i] = 0.0f;
|
||||||
|
return oboe::DataCallbackResult::Continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SizzleCallback g_dataCallback;
|
||||||
|
std::shared_ptr<oboe::AudioStream> g_stream;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
|
||||||
|
g_vm = vm;
|
||||||
|
return JNI_VERSION_1_6;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT jboolean JNICALL
|
||||||
|
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStart(
|
||||||
|
JNIEnv *env, jobject /*thiz*/, jobject callback, jint sampleRate, jint framesPerCallback) {
|
||||||
|
g_frames = framesPerCallback;
|
||||||
|
g_callback = env->NewGlobalRef(callback);
|
||||||
|
jclass cls = env->GetObjectClass(callback);
|
||||||
|
g_renderMethod = env->GetMethodID(cls, "renderAudio", "([FI)V");
|
||||||
|
g_buffer = static_cast<jfloatArray>(env->NewGlobalRef(env->NewFloatArray(framesPerCallback)));
|
||||||
|
|
||||||
|
oboe::AudioStreamBuilder builder;
|
||||||
|
builder.setDirection(oboe::Direction::Output)
|
||||||
|
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||||
|
->setSharingMode(oboe::SharingMode::Shared)
|
||||||
|
->setFormat(oboe::AudioFormat::Float)
|
||||||
|
->setChannelCount(oboe::ChannelCount::Mono)
|
||||||
|
->setSampleRate(sampleRate)
|
||||||
|
->setFramesPerDataCallback(framesPerCallback)
|
||||||
|
->setDataCallback(&g_dataCallback);
|
||||||
|
|
||||||
|
oboe::Result result = builder.openStream(g_stream);
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
|
||||||
|
return JNI_FALSE;
|
||||||
|
}
|
||||||
|
result = g_stream->requestStart();
|
||||||
|
if (result != oboe::Result::OK) {
|
||||||
|
LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
|
||||||
|
g_stream->close();
|
||||||
|
g_stream.reset();
|
||||||
|
return JNI_FALSE;
|
||||||
|
}
|
||||||
|
LOGI("Oboe stream started (%d Hz, %d frames/callback)", sampleRate, framesPerCallback);
|
||||||
|
return JNI_TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT void JNICALL
|
||||||
|
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStop(
|
||||||
|
JNIEnv *env, jobject /*thiz*/) {
|
||||||
|
if (g_stream) {
|
||||||
|
g_stream->stop();
|
||||||
|
g_stream->close();
|
||||||
|
g_stream.reset();
|
||||||
|
}
|
||||||
|
if (g_buffer) { env->DeleteGlobalRef(g_buffer); g_buffer = nullptr; }
|
||||||
|
if (g_callback) { env->DeleteGlobalRef(g_callback); g_callback = nullptr; }
|
||||||
|
g_renderMethod = nullptr;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.KeyboardInput
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.MidiInput
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.playback.PlaybackService
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.SizzleApp as SizzleAppUi
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.SizzleTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single Activity. Its jobs are narrow and clear:
|
||||||
|
* 1. build the [AppViewModel] wired to the app-wide singletons,
|
||||||
|
* 2. host the Compose UI, themed by the ViewModel's current palette,
|
||||||
|
* 3. forward hardware key / motion events into the keyboard & gamepad handlers,
|
||||||
|
* 4. start MIDI listening and the background playback service.
|
||||||
|
*
|
||||||
|
* All four input methods converge on the same InputRouter -> ViewModel path, so
|
||||||
|
* there is no per-input special-casing beyond the translation done here.
|
||||||
|
*/
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val app get() = application as SizzleApp
|
||||||
|
|
||||||
|
// Handlers are app-wide singletons (shared with the Settings rebind UI).
|
||||||
|
private val keyboard: KeyboardInput get() = app.keyboardInput
|
||||||
|
private val gamepad: GamepadInput get() = app.gamepadInput
|
||||||
|
private val midi: MidiInput get() = app.midiInput
|
||||||
|
|
||||||
|
private val viewModel: AppViewModel by lazy {
|
||||||
|
ViewModelProvider(
|
||||||
|
this,
|
||||||
|
viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
AppViewModel(app.project, app.audioEngine, app.inputRouter, app.gamepadInput, app.midiInput)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)[AppViewModel::class.java]
|
||||||
|
}
|
||||||
|
|
||||||
|
private val requestPermissions = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { /* result ignored: features degrade gracefully if denied */ }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
askForPermissions()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
// viewModel.palette is Compose state, so reading it here recomposes
|
||||||
|
// the whole theme when the user picks a new colour scheme in Settings.
|
||||||
|
SizzleTheme(palette = viewModel.palette) {
|
||||||
|
SizzleAppUi(viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
midi.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Start the playback service only once we're definitively in the
|
||||||
|
// foreground — a plain startService() from a not-yet-foreground state
|
||||||
|
// throws BackgroundServiceStartNotAllowedException on Android 12+.
|
||||||
|
runCatching { PlaybackService.start(this) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
midi.stop()
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hardware input forwarding ----
|
||||||
|
// Physical keyboards and most gamepad buttons arrive as key events; we give
|
||||||
|
// the gamepad handler first refusal, then the keyboard handler.
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean =
|
||||||
|
gamepad.onKeyDown(keyCode, event) ||
|
||||||
|
keyboard.onKeyDown(keyCode, event) ||
|
||||||
|
super.onKeyDown(keyCode, event)
|
||||||
|
|
||||||
|
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
|
||||||
|
keyboard.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||||
|
|
||||||
|
// Analog sticks / triggers / D-pad hats arrive as generic motion events.
|
||||||
|
override fun onGenericMotionEvent(event: MotionEvent): Boolean =
|
||||||
|
gamepad.onMotion(event) || super.onGenericMotionEvent(event)
|
||||||
|
|
||||||
|
// ---- (helper removed; viewModel.palette is read directly in setContent) ----
|
||||||
|
|
||||||
|
// ---- Permissions ----
|
||||||
|
private fun askForPermissions() {
|
||||||
|
val needed = buildList {
|
||||||
|
add(Manifest.permission.RECORD_AUDIO)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
}
|
||||||
|
}.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (needed.isNotEmpty()) requestPermissions.launch(needed.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.KeyboardInput
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.MidiInput
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Application object is our (very small) dependency container. Instead of a
|
||||||
|
* DI framework — overkill for a learning-friendly codebase — the handful of
|
||||||
|
* app-wide singletons live here and are reached via `application as SizzleApp`.
|
||||||
|
*
|
||||||
|
* These objects outlive any single screen so playback and input keep working
|
||||||
|
* across rotation and tab switches.
|
||||||
|
*/
|
||||||
|
class SizzleApp : Application() {
|
||||||
|
|
||||||
|
/** The single in-memory song. Everything edits this one object. */
|
||||||
|
val project: Project by lazy { Project() }
|
||||||
|
|
||||||
|
/** Neutral input bus shared by touch / keyboard / gamepad / MIDI. */
|
||||||
|
val inputRouter: InputRouter by lazy { InputRouter() }
|
||||||
|
|
||||||
|
/** The real-time sound engine (started/stopped by the PlaybackService). */
|
||||||
|
val audioEngine: AudioEngine by lazy { AudioEngine() }
|
||||||
|
|
||||||
|
// Input source handlers are singletons so their (re)bindable maps are shared
|
||||||
|
// between MainActivity (which feeds them hardware events) and the Settings
|
||||||
|
// screen (which edits their bindings via MIDI-learn / gamepad rebind).
|
||||||
|
val keyboardInput: KeyboardInput by lazy { KeyboardInput(inputRouter) }
|
||||||
|
val gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
|
||||||
|
val midiInput: MidiInput by lazy { MidiInput(this, inputRouter) }
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
audioEngine.setProject(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioTrack
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real-time sound engine. It owns one [AudioTrack] and a dedicated render
|
||||||
|
* thread that is completely separate from the UI thread — this is what keeps
|
||||||
|
* playback smooth while the interface is being touched.
|
||||||
|
*
|
||||||
|
* The render loop is a classic tracker "sample-accurate sequencer": it counts
|
||||||
|
* audio samples and, exactly when enough samples have elapsed for one tracker
|
||||||
|
* line, it advances the playhead and triggers that line's notes. Because timing
|
||||||
|
* is derived from the audio clock (not `Handler.postDelayed`), there is no drift.
|
||||||
|
*
|
||||||
|
* PLAYBACK MODES
|
||||||
|
* --------------
|
||||||
|
* - ARRANGEMENT mode (default when the arrangement has any blocks): the
|
||||||
|
* sequencer walks a pre-expanded playlist of beats (with A/B loop repeats),
|
||||||
|
* and at each beat every filled lane plays one beat of its pattern. A lane
|
||||||
|
* keeps its own cursor so a block spanning several consecutive beats advances
|
||||||
|
* through the pattern beat-by-beat.
|
||||||
|
* - PATTERN-LOOP mode (when the arrangement is empty): the currently-edited
|
||||||
|
* pattern loops, so the Tracker tab is always audible while you edit.
|
||||||
|
*
|
||||||
|
* Voices form a [LANE_COUNT] x [TRACK_COUNT] matrix so lanes layer polyphonically
|
||||||
|
* while each (lane, track) stays monophonic (classic tracker channel behaviour).
|
||||||
|
* A voice's mixer channel is its track index, so mute/solo/volume apply per track.
|
||||||
|
*
|
||||||
|
* INSTRUMENTS: NES synth and Sampler are rendered here; the SoundFont loader
|
||||||
|
* still falls back to the synth. Each (lane, track) has both a [SynthVoice] and a
|
||||||
|
* [SampleVoice]; the channel's instrument type decides which one a note triggers.
|
||||||
|
* Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
|
||||||
|
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
|
||||||
|
* Kotlin/AudioTrack loop for an Oboe (C++/AAudio) engine is a later milestone.
|
||||||
|
*/
|
||||||
|
class AudioEngine(
|
||||||
|
private val sampleRate: Int = 48_000,
|
||||||
|
) {
|
||||||
|
// Sequencer voices: one synth + one sample voice per (lane, track).
|
||||||
|
private val seqVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SynthVoice(sampleRate) }
|
||||||
|
private val sampleVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SampleVoice(sampleRate) }
|
||||||
|
// ... plus a small pool for live auditioning from keyboard / MIDI / pads.
|
||||||
|
private val liveVoices = Array(LIVE_POLYPHONY) { SynthVoice(sampleRate) }
|
||||||
|
// Sample voices for previewing sampler instruments (toolbox keyboard / editor).
|
||||||
|
private val auditionSampleVoices = Array(AUDITION_POLYPHONY) { SampleVoice(sampleRate) }
|
||||||
|
// Per-(lane,track) arpeggiator state, driven by advanceArps.
|
||||||
|
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
|
||||||
|
|
||||||
|
private fun seqVoice(lane: Int, track: Int) = seqVoices[lane * Pattern.TRACK_COUNT + track]
|
||||||
|
private fun sampleVoice(lane: Int, track: Int) = sampleVoices[lane * Pattern.TRACK_COUNT + track]
|
||||||
|
|
||||||
|
/** One insert-effect instance bound to the toolbox slot that configures it. */
|
||||||
|
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect)
|
||||||
|
|
||||||
|
/** Per-channel effect chains (mixer FX slots resolved to real processors).
|
||||||
|
* Rebuilt when routing changes; parameters refresh live every block. */
|
||||||
|
private val channelChains = Array(Pattern.TRACK_COUNT) { mutableListOf<FxUnit>() }
|
||||||
|
|
||||||
|
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
||||||
|
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
||||||
|
|
||||||
|
/** The song being played. Swapped atomically; read on the audio thread. */
|
||||||
|
@Volatile private var project: Project? = null
|
||||||
|
|
||||||
|
@Volatile private var playing = false
|
||||||
|
@Volatile private var running = false
|
||||||
|
|
||||||
|
// ---- Shared position state (audio-thread only) ----
|
||||||
|
private var samplesIntoLine = 0.0
|
||||||
|
private var pendingTrigger = true
|
||||||
|
|
||||||
|
// ---- Pattern-loop mode ----
|
||||||
|
private var currentLine = 0
|
||||||
|
|
||||||
|
// ---- Arrangement mode ----
|
||||||
|
private var arrangementMode = false
|
||||||
|
/** Beat indices to play, in order, with A/B repeats already expanded. */
|
||||||
|
private var playlist: IntArray = IntArray(0)
|
||||||
|
private var playlistIndex = 0
|
||||||
|
private var lineInBeat = 0
|
||||||
|
private val laneCursor = IntArray(Arrangement.LANE_COUNT) // which beat of its pattern each lane is on
|
||||||
|
private val lanePrevPattern = IntArray(Arrangement.LANE_COUNT) // pattern played on the previous beat (-2 = none)
|
||||||
|
|
||||||
|
private val _transport = MutableStateFlow(TransportState())
|
||||||
|
val transport: StateFlow<TransportState> = _transport
|
||||||
|
|
||||||
|
private var renderThread: Thread? = null
|
||||||
|
private var track: AudioTrack? = null
|
||||||
|
|
||||||
|
fun setProject(p: Project) {
|
||||||
|
project = p
|
||||||
|
rebuildArrangement()
|
||||||
|
rebuildFxChains()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve each mixer channel's four FX slots into real effect processors.
|
||||||
|
* Call from the main thread (from [play], [setProject], or after the user
|
||||||
|
* changes a channel's FX routing). Effect *parameters* update live every
|
||||||
|
* block, so only add/remove of an effect needs a rebuild.
|
||||||
|
*/
|
||||||
|
fun rebuildFxChains() {
|
||||||
|
val proj = project ?: return
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val chain = channelChains[ch]
|
||||||
|
chain.clear()
|
||||||
|
for (slotIndex in proj.mixer.channels[ch].fxSlots) {
|
||||||
|
if (slotIndex < 0) continue
|
||||||
|
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
||||||
|
val type = slot.type ?: continue
|
||||||
|
AudioEffect.create(type, sampleRate)?.let { chain.add(FxUnit(slot, it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes the arrangement playlist and resets sequencer position. Call
|
||||||
|
* from the main thread (never the audio thread) — from [play] and whenever
|
||||||
|
* the arrangement or loop settings change.
|
||||||
|
*/
|
||||||
|
fun rebuildArrangement() {
|
||||||
|
val proj = project ?: return
|
||||||
|
val arr = proj.arrangement
|
||||||
|
arrangementMode = arr.slots.any { lane -> lane.any { it != Arrangement.EMPTY } }
|
||||||
|
playlist = buildPlaylist(arr)
|
||||||
|
playlistIndex = 0
|
||||||
|
lineInBeat = 0
|
||||||
|
laneCursor.fill(0)
|
||||||
|
lanePrevPattern.fill(-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand the arrangement (respecting loop + A/B regions) into a flat beat list. */
|
||||||
|
private fun buildPlaylist(arr: Arrangement): IntArray {
|
||||||
|
if (!arr.loopEnabled) return IntArray(arr.lengthBeats) { it } // play once, front to back
|
||||||
|
val a = arr.regionA
|
||||||
|
val b = arr.regionB
|
||||||
|
if (!a.isValid && !b.isValid) return IntArray(arr.lengthBeats) { it } // loop the whole song
|
||||||
|
val out = ArrayList<Int>()
|
||||||
|
if (a.isValid) repeat(a.repeats) { for (beat in a.start until a.end) out.add(beat) }
|
||||||
|
if (b.isValid) repeat(b.repeats) { for (beat in b.start until b.end) out.add(beat) }
|
||||||
|
return out.toIntArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- lifecycle
|
||||||
|
/** True while the low-latency native (Oboe) output backend is selected. */
|
||||||
|
@Volatile var useNativeOutput = false; private set
|
||||||
|
private var nativeBridge: NativeAudioBridge? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
// Prefer the native Oboe backend when enabled and its library loaded;
|
||||||
|
// otherwise (or if it fails to open) use the Kotlin AudioTrack loop.
|
||||||
|
if (useNativeOutput && NativeAudioBridge.isAvailable()) {
|
||||||
|
val bridge = NativeAudioBridge(this)
|
||||||
|
if (bridge.start(sampleRate, BLOCK_FRAMES)) { nativeBridge = bridge; return }
|
||||||
|
}
|
||||||
|
startAudioTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAudioTrack() {
|
||||||
|
val minBuf = AudioTrack.getMinBufferSize(
|
||||||
|
sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||||
|
).coerceAtLeast(BLOCK_FRAMES * 4)
|
||||||
|
|
||||||
|
track = AudioTrack.Builder()
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setAudioFormat(
|
||||||
|
AudioFormat.Builder()
|
||||||
|
.setSampleRate(sampleRate)
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
|
||||||
|
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setBufferSizeInBytes(minBuf)
|
||||||
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
|
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
||||||
|
.build()
|
||||||
|
.also { it.play() }
|
||||||
|
|
||||||
|
applyPreferredOutput() // route to the user-chosen USB/audio device, if any
|
||||||
|
|
||||||
|
renderThread = thread(name = "sizzle-audio", priority = Thread.MAX_PRIORITY) {
|
||||||
|
audioTrackLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch output backend between Oboe (native) and AudioTrack, restarting. */
|
||||||
|
fun setNativeOutput(enabled: Boolean) {
|
||||||
|
if (useNativeOutput == enabled) return
|
||||||
|
val wasRunning = running
|
||||||
|
if (wasRunning) release()
|
||||||
|
useNativeOutput = enabled
|
||||||
|
if (wasRunning) start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- USB / audio output device routing ----
|
||||||
|
@Volatile private var preferredOutput: android.media.AudioDeviceInfo? = null
|
||||||
|
|
||||||
|
/** Route playback to a specific output device (e.g. a USB DAC), or null for
|
||||||
|
* the system default. Applied immediately and re-applied on engine restart. */
|
||||||
|
fun setPreferredOutput(device: android.media.AudioDeviceInfo?) {
|
||||||
|
preferredOutput = device
|
||||||
|
applyPreferredOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPreferredOutput() {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||||
|
track?.setPreferredDevice(preferredOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the engine entirely (called from the PlaybackService on shutdown). */
|
||||||
|
fun release() {
|
||||||
|
running = false
|
||||||
|
nativeBridge?.stop()
|
||||||
|
nativeBridge = null
|
||||||
|
renderThread?.join(500)
|
||||||
|
renderThread = null
|
||||||
|
track?.run { stop(); release() }
|
||||||
|
track = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- transport
|
||||||
|
fun play() {
|
||||||
|
rebuildArrangement()
|
||||||
|
rebuildFxChains()
|
||||||
|
pendingTrigger = true
|
||||||
|
playing = true
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() { playing = false; allSeqVoicesOff(); publish() }
|
||||||
|
|
||||||
|
/** Stop playback. If already stopped, rewind to the very beginning. */
|
||||||
|
fun stop() {
|
||||||
|
if (!playing && currentLine == 0 && playlistIndex == 0 && lineInBeat == 0) return
|
||||||
|
val wasStopped = !playing
|
||||||
|
playing = false
|
||||||
|
allSeqVoicesOff()
|
||||||
|
if (wasStopped) {
|
||||||
|
currentLine = 0; samplesIntoLine = 0.0
|
||||||
|
playlistIndex = 0; lineInBeat = 0
|
||||||
|
laneCursor.fill(0); lanePrevPattern.fill(-2)
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------ live note audition
|
||||||
|
fun liveNoteOn(pitch: Int, velocity: Int) {
|
||||||
|
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
|
||||||
|
v.noteOn(pitch, velocity, SynthVoice.Wave.PULSE_50, 0.005, 0.1, 0.7, 0.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun liveNoteOff(pitch: Int) {
|
||||||
|
liveVoices.firstOrNull { it.isActive && it.pitch == pitch }?.noteOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- render loop
|
||||||
|
/** The Kotlin AudioTrack fallback loop: fill a block and push it. */
|
||||||
|
private fun audioTrackLoop() {
|
||||||
|
val block = FloatArray(BLOCK_FRAMES)
|
||||||
|
while (running) {
|
||||||
|
val t = track ?: break
|
||||||
|
fillBlock(block, BLOCK_FRAMES)
|
||||||
|
t.write(block, 0, BLOCK_FRAMES, AudioTrack.WRITE_BLOCKING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render [frames] mono samples of the whole mix into [out]. This is the single
|
||||||
|
* source of truth for audio: it is called by the Kotlin AudioTrack loop AND by
|
||||||
|
* the native Oboe callback (via [NativeAudioBridge]), so both output backends
|
||||||
|
* produce identical sound. Runs on an audio thread — must not allocate.
|
||||||
|
*/
|
||||||
|
fun fillBlock(out: FloatArray, frames: Int) {
|
||||||
|
val proj = project
|
||||||
|
// Per-block param housekeeping: LFOs modulate targets first, then effects
|
||||||
|
// read their (possibly modulated) parameters.
|
||||||
|
if (proj != null) {
|
||||||
|
applyLfos(proj)
|
||||||
|
updateFxParams(proj)
|
||||||
|
}
|
||||||
|
val anySolo = proj?.mixer?.anySolo() ?: false
|
||||||
|
|
||||||
|
for (i in 0 until frames) {
|
||||||
|
if (playing && proj != null) advanceSequencer(proj)
|
||||||
|
|
||||||
|
var mix = 0f
|
||||||
|
if (proj != null) {
|
||||||
|
channelSum.fill(0f)
|
||||||
|
for (vi in seqVoices.indices) {
|
||||||
|
channelSum[vi % Pattern.TRACK_COUNT] += seqVoices[vi].render() + sampleVoices[vi].render()
|
||||||
|
}
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val mc = proj.mixer.channels[ch]
|
||||||
|
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
||||||
|
val chain = channelChains[ch]
|
||||||
|
for (u in chain) s = u.effect.process(s)
|
||||||
|
mix += s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (lv in liveVoices) mix += lv.render() * LIVE_GAIN
|
||||||
|
for (av in auditionSampleVoices) mix += av.render() * LIVE_GAIN
|
||||||
|
out[i] = softClip(mix * MASTER_GAIN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push current slot parameters (+ tempo) into every live effect processor. */
|
||||||
|
private fun updateFxParams(proj: Project) {
|
||||||
|
val tempo = proj.tempoBpm
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val chain = channelChains[ch]
|
||||||
|
for (u in chain) u.effect.update(u.slot, tempo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MIDI LFO modulation ----
|
||||||
|
private val lfoPhase = DoubleArray(Project.TOOLBOX_SLOTS)
|
||||||
|
private val lfoSampleHold = DoubleArray(Project.TOOLBOX_SLOTS) { Math.random() * 2 - 1 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply every LFO slot's modulation to its target parameter, once per block.
|
||||||
|
* The target is stored as "slotIndex:paramKey"; the LFO oscillates the value
|
||||||
|
* around a captured centre by ±depth·(half the param's range). The LFO thus
|
||||||
|
* "takes over" its target parameter while active.
|
||||||
|
*/
|
||||||
|
private fun applyLfos(proj: Project) {
|
||||||
|
val secondsPerBeat = 60.0 / proj.tempoBpm
|
||||||
|
val blockSeconds = BLOCK_FRAMES.toDouble() / sampleRate
|
||||||
|
for (i in proj.toolbox.indices) {
|
||||||
|
val lfo = proj.toolbox[i]
|
||||||
|
if (lfo.type != ToolboxType.LFO) continue
|
||||||
|
val target = lfo.string("target")
|
||||||
|
val sep = target.indexOf(':')
|
||||||
|
if (sep <= 0) continue
|
||||||
|
val tIdx = target.substring(0, sep).toIntOrNull() ?: continue
|
||||||
|
if (tIdx == i) continue
|
||||||
|
val key = target.substring(sep + 1)
|
||||||
|
val tSlot = proj.toolbox.getOrNull(tIdx) ?: continue
|
||||||
|
val spec = tSlot.type?.params?.firstOrNull { it.key == key } ?: continue
|
||||||
|
|
||||||
|
val div = TimeDivision.fromIndex(lfo.float("division", 3f).toInt())
|
||||||
|
val freq = 1.0 / (div.beatFraction * secondsPerBeat).coerceAtLeast(1e-6)
|
||||||
|
val advanced = lfoPhase[i] + freq * blockSeconds
|
||||||
|
if (advanced >= 1.0) lfoSampleHold[i] = Math.random() * 2 - 1
|
||||||
|
lfoPhase[i] = advanced % 1.0
|
||||||
|
val phase = (lfoPhase[i] + lfo.float("phase", 0f)) % 1.0
|
||||||
|
|
||||||
|
val value = when (lfo.string("wave", "Sine")) {
|
||||||
|
"Triangle" -> 1.0 - 4.0 * abs(phase - 0.5)
|
||||||
|
"Saw" -> 2.0 * phase - 1.0
|
||||||
|
"Square" -> if (phase < 0.5) 1.0 else -1.0
|
||||||
|
"SampleHold" -> lfoSampleHold[i]
|
||||||
|
else -> sin(2 * Math.PI * phase)
|
||||||
|
}
|
||||||
|
val center = lfo.float("center", spec.default).toDouble()
|
||||||
|
val half = (spec.max - spec.min) / 2.0
|
||||||
|
val modulated = (center + lfo.float("depth", 0.5f) * value * half)
|
||||||
|
.coerceIn(spec.min.toDouble(), spec.max.toDouble())
|
||||||
|
tSlot.set(key, modulated.toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch one sample of sequencer time to the active playback mode. */
|
||||||
|
private fun advanceSequencer(proj: Project) {
|
||||||
|
if (arrangementMode) advanceArrangement(proj) else advancePatternLoop(proj)
|
||||||
|
advanceArps(proj) // arpeggiators retrigger between note events
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Pattern-loop mode (Tracker tab audition) -----
|
||||||
|
private fun advancePatternLoop(proj: Project) {
|
||||||
|
val pattern = proj.activePattern()
|
||||||
|
val samplesPerLine = samplesPerLine(proj)
|
||||||
|
|
||||||
|
if (pendingTrigger) {
|
||||||
|
triggerPatternLine(proj, pattern, currentLine)
|
||||||
|
pendingTrigger = false
|
||||||
|
}
|
||||||
|
samplesIntoLine += 1.0
|
||||||
|
if (samplesIntoLine >= samplesPerLine) {
|
||||||
|
samplesIntoLine -= samplesPerLine
|
||||||
|
currentLine++
|
||||||
|
if (currentLine >= pattern.length) currentLine = 0
|
||||||
|
pendingTrigger = true
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
|
||||||
|
// Pattern-loop plays on lane 0's voices (tracks 0..3 == channels 0..3).
|
||||||
|
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
triggerVoice(proj, lane = 0, track = t, cell = pattern.cell(t, line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a cell on (lane, track), applying the channel's MIDI effects:
|
||||||
|
* Transposer shifts the pitch, Arpeggiator (if present) captures the note and
|
||||||
|
* hands ongoing retriggering to [advanceArps] instead of sounding it once.
|
||||||
|
*/
|
||||||
|
private fun triggerVoice(proj: Project, lane: Int, track: Int, cell: com.reactorcoremeltdown.sizzletracker.model.Cell) {
|
||||||
|
val arp = arps[lane * Pattern.TRACK_COUNT + track]
|
||||||
|
when {
|
||||||
|
cell.isPlayable -> {
|
||||||
|
val velocity = cell.velocity.coerceIn(0, 127)
|
||||||
|
val note = (cell.note + channelTranspose(proj, track)).coerceIn(0, 127)
|
||||||
|
val arpSlot = channelArpSlot(proj, track)
|
||||||
|
if (arpSlot != null) {
|
||||||
|
val octaves = arpSlot.float("octaves", 1f).toInt()
|
||||||
|
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
|
||||||
|
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||||
|
arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples)
|
||||||
|
playNote(proj, lane, track, arp.currentNote(), velocity)
|
||||||
|
} else {
|
||||||
|
arp.stop()
|
||||||
|
playNote(proj, lane, track, note, velocity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell.isNoteOff -> {
|
||||||
|
arp.stop()
|
||||||
|
seqVoice(lane, track).noteOff(); sampleVoice(lane, track).noteOff()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually sound a note on (lane, track): a [SampleVoice] when the channel's
|
||||||
|
* instrument is a Sampler with a loaded sample, otherwise the NES synth. The
|
||||||
|
* unused voice of the pair is killed so they never double up. Shared by the
|
||||||
|
* sequencer and the arpeggiator.
|
||||||
|
*/
|
||||||
|
private fun playNote(proj: Project, lane: Int, track: Int, note: Int, velocity: Int) {
|
||||||
|
val synth = seqVoice(lane, track)
|
||||||
|
val samp = sampleVoice(lane, track)
|
||||||
|
val slot = proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot)
|
||||||
|
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
|
||||||
|
if (sample != null && slot != null) {
|
||||||
|
synth.kill()
|
||||||
|
val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
|
||||||
|
samp.noteOn(
|
||||||
|
sample, note, velocity, root,
|
||||||
|
slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
samp.kill()
|
||||||
|
val (wave, a, d, s, r) = synthParamsForTrack(proj, track)
|
||||||
|
synth.noteOn(note, velocity, wave, a, d, s, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
|
||||||
|
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
|
||||||
|
for (idx in proj.mixer.channels[track].fxSlots) {
|
||||||
|
if (idx < 0) continue
|
||||||
|
val slot = proj.toolbox.getOrNull(idx) ?: continue
|
||||||
|
if (slot.type == ToolboxType.ARPEGGIATOR) return slot
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance every active arpeggiator by one sample; retrigger on step boundaries. */
|
||||||
|
private fun advanceArps(proj: Project) {
|
||||||
|
for (i in arps.indices) {
|
||||||
|
val a = arps[i]
|
||||||
|
if (!a.active) continue
|
||||||
|
a.samplesLeft -= 1.0
|
||||||
|
if (a.samplesLeft <= 0.0) {
|
||||||
|
a.advance()
|
||||||
|
playNote(proj, i / Pattern.TRACK_COUNT, i % Pattern.TRACK_COUNT, a.currentNote(), a.velocity)
|
||||||
|
a.samplesLeft += a.stepSamples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */
|
||||||
|
private class ChannelArp {
|
||||||
|
var active = false; var base = -1; var velocity = 100
|
||||||
|
private var octaves = 1; private var mode = 0
|
||||||
|
var stepSamples = 0.0; var samplesLeft = 0.0
|
||||||
|
private var step = 0; private var dir = 1
|
||||||
|
|
||||||
|
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double) {
|
||||||
|
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
|
||||||
|
mode = when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
|
||||||
|
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() { active = false; base = -1 }
|
||||||
|
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
|
||||||
|
|
||||||
|
fun advance() {
|
||||||
|
when (mode) {
|
||||||
|
1 -> step = (step - 1 + octaves) % octaves
|
||||||
|
2 -> if (octaves > 1) {
|
||||||
|
step += dir
|
||||||
|
if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
|
||||||
|
else if (step <= 0) { step = 0; dir = 1 }
|
||||||
|
}
|
||||||
|
3 -> step = if (octaves > 1) (Math.random() * octaves).toInt() else 0
|
||||||
|
else -> step = (step + 1) % octaves
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
|
||||||
|
private fun channelTranspose(proj: Project, track: Int): Int {
|
||||||
|
var semis = 0
|
||||||
|
for (idx in proj.mixer.channels[track].fxSlots) {
|
||||||
|
if (idx < 0) continue
|
||||||
|
val slot = proj.toolbox.getOrNull(idx) ?: continue
|
||||||
|
if (slot.type == ToolboxType.TRANSPOSER) semis += slot.float("semitones", 0f).toInt()
|
||||||
|
}
|
||||||
|
return semis
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start auditioning a note on the instrument in toolbox [slotIndex] (used by
|
||||||
|
* the Toolbox test keyboard and the Sampler editor). Polyphonic. */
|
||||||
|
fun auditionSlotOn(slotIndex: Int, midi: Int) {
|
||||||
|
val proj = project ?: return
|
||||||
|
val slot = proj.toolbox.getOrNull(slotIndex)
|
||||||
|
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
|
||||||
|
if (sample != null && slot != null) {
|
||||||
|
val v = auditionSampleVoices.firstOrNull { !it.isActive } ?: auditionSampleVoices[0].also { it.kill() }
|
||||||
|
val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
|
||||||
|
v.noteOn(sample, midi, 110, root, slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f))
|
||||||
|
} else {
|
||||||
|
val (wave, a, d, s, r) = synthParamsForSlot(slot)
|
||||||
|
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
|
||||||
|
v.noteOn(midi, 110, wave, a, d, s, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release an auditioned note (matches both synth and sample audition pools). */
|
||||||
|
fun auditionSlotOff(midi: Int) {
|
||||||
|
liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
||||||
|
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Arrangement mode -----
|
||||||
|
private fun advanceArrangement(proj: Project) {
|
||||||
|
val arr = proj.arrangement
|
||||||
|
if (playlist.isEmpty()) { playing = false; publish(); return }
|
||||||
|
val linesPerBeat = proj.timeSignature.linesPerBeat
|
||||||
|
val samplesPerLine = samplesPerLine(proj)
|
||||||
|
val beat = playlist[playlistIndex.coerceIn(0, playlist.size - 1)]
|
||||||
|
|
||||||
|
if (pendingTrigger) {
|
||||||
|
if (lineInBeat == 0) advanceLaneCursors(proj, arr, beat, linesPerBeat)
|
||||||
|
triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
|
||||||
|
pendingTrigger = false
|
||||||
|
}
|
||||||
|
|
||||||
|
samplesIntoLine += 1.0
|
||||||
|
if (samplesIntoLine >= samplesPerLine) {
|
||||||
|
samplesIntoLine -= samplesPerLine
|
||||||
|
lineInBeat++
|
||||||
|
if (lineInBeat >= linesPerBeat) {
|
||||||
|
lineInBeat = 0
|
||||||
|
playlistIndex++
|
||||||
|
if (playlistIndex >= playlist.size) {
|
||||||
|
if (arr.loopEnabled) {
|
||||||
|
playlistIndex = 0
|
||||||
|
lanePrevPattern.fill(-2) // fresh cycle: blocks restart from beat 0
|
||||||
|
} else {
|
||||||
|
playing = false; allSeqVoicesOff(); publish(); return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingTrigger = true
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At each new beat, update every lane's within-pattern cursor. */
|
||||||
|
private fun advanceLaneCursors(proj: Project, arr: Arrangement, beat: Int, linesPerBeat: Int) {
|
||||||
|
for (lane in 0 until Arrangement.LANE_COUNT) {
|
||||||
|
val p = arr.patternAt(lane, beat)
|
||||||
|
if (p == Arrangement.EMPTY) {
|
||||||
|
lanePrevPattern[lane] = Arrangement.EMPTY
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (p == lanePrevPattern[lane]) {
|
||||||
|
val beats = ((proj.pattern(p)?.length ?: linesPerBeat) / linesPerBeat).coerceAtLeast(1)
|
||||||
|
laneCursor[lane] = (laneCursor[lane] + 1) % beats
|
||||||
|
} else {
|
||||||
|
laneCursor[lane] = 0
|
||||||
|
}
|
||||||
|
lanePrevPattern[lane] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerArrangementLine(
|
||||||
|
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
|
||||||
|
) {
|
||||||
|
for (lane in 0 until Arrangement.LANE_COUNT) {
|
||||||
|
val p = arr.patternAt(lane, beat)
|
||||||
|
if (p == Arrangement.EMPTY) continue
|
||||||
|
val pat = proj.pattern(p) ?: continue
|
||||||
|
val patLine = laneCursor[lane] * linesPerBeat + lineInBeat
|
||||||
|
if (patLine >= pat.length) continue
|
||||||
|
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
triggerVoice(proj, lane, t, pat.cell(t, patLine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the NES-synth settings for a track from its mixer->toolbox routing. */
|
||||||
|
private fun synthParamsForTrack(proj: Project, track: Int): SynthParams =
|
||||||
|
synthParamsForSlot(proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot))
|
||||||
|
|
||||||
|
/** Read a slot's NES-synth settings (default synth if it isn't an NES synth). */
|
||||||
|
private fun synthParamsForSlot(slot: ToolboxSlot?): SynthParams {
|
||||||
|
if (slot == null || slot.type != ToolboxType.NES_SYNTH) return DEFAULT_SYNTH
|
||||||
|
val wave = when (slot.string("wave", "Pulse50")) {
|
||||||
|
"Pulse12" -> SynthVoice.Wave.PULSE_12
|
||||||
|
"Pulse25" -> SynthVoice.Wave.PULSE_25
|
||||||
|
"Triangle" -> SynthVoice.Wave.TRIANGLE
|
||||||
|
"Noise" -> SynthVoice.Wave.NOISE
|
||||||
|
else -> SynthVoice.Wave.PULSE_50
|
||||||
|
}
|
||||||
|
return SynthParams(
|
||||||
|
wave,
|
||||||
|
slot.float("attack", 0.01f).toDouble(),
|
||||||
|
slot.float("decay", 0.15f).toDouble(),
|
||||||
|
slot.float("sustain", 0.6f).toDouble(),
|
||||||
|
slot.float("release", 0.1f).toDouble(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun samplesPerLine(proj: Project): Double =
|
||||||
|
sampleRate * (60.0 / proj.tempoBpm) / proj.timeSignature.linesPerBeat
|
||||||
|
|
||||||
|
private fun allSeqVoicesOff() {
|
||||||
|
seqVoices.forEach { it.noteOff() }
|
||||||
|
sampleVoices.forEach { it.noteOff() }
|
||||||
|
arps.forEach { it.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
val proj = project
|
||||||
|
val linesPerBeat = proj?.timeSignature?.linesPerBeat ?: 4
|
||||||
|
if (arrangementMode) {
|
||||||
|
val beat = if (playlist.isNotEmpty()) playlist[playlistIndex.coerceIn(0, playlist.size - 1)] else 0
|
||||||
|
_transport.value = TransportState(
|
||||||
|
isPlaying = playing,
|
||||||
|
currentLine = laneCursor[0] * linesPerBeat + lineInBeat,
|
||||||
|
currentBeat = beat,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_transport.value = TransportState(
|
||||||
|
isPlaying = playing,
|
||||||
|
currentLine = currentLine,
|
||||||
|
currentBeat = currentLine / linesPerBeat,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small destructurable holder for a voice's synth settings. */
|
||||||
|
private data class SynthParams(
|
||||||
|
val wave: SynthVoice.Wave, val a: Double, val d: Double, val s: Double, val r: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz
|
||||||
|
private const val LIVE_POLYPHONY = 8
|
||||||
|
private const val AUDITION_POLYPHONY = 6
|
||||||
|
private const val MASTER_GAIN = 0.35f
|
||||||
|
private const val LIVE_GAIN = 0.5f
|
||||||
|
private val DEFAULT_SYNTH = SynthParams(SynthVoice.Wave.PULSE_50, 0.01, 0.15, 0.6, 0.1)
|
||||||
|
|
||||||
|
/** Cheap soft clipper to keep the mix from harsh digital clipping. */
|
||||||
|
private fun softClip(x: Float): Float = when {
|
||||||
|
x > 1f -> 1f - 1f / (x + 1f)
|
||||||
|
x < -1f -> -1f + 1f / (-x + 1f)
|
||||||
|
else -> x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.tanh
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time audio effect processors used by the mixer's per-channel insert
|
||||||
|
* chains. Each effect reads its settings from the owning [ToolboxSlot] once per
|
||||||
|
* audio block (via [update]) and then processes the signal one sample at a time
|
||||||
|
* (via [process]) on the audio thread — so [process] must never allocate.
|
||||||
|
*
|
||||||
|
* The MIDI effects (arpeggiator / transposer / LFO) are NOT here: they alter note
|
||||||
|
* generation rather than the audio signal, and hook into the sequencer instead.
|
||||||
|
* Only the signal-processing effect types return a processor from [create].
|
||||||
|
*/
|
||||||
|
interface AudioEffect {
|
||||||
|
/** Refresh cached parameters from the slot. Called once per block. */
|
||||||
|
fun update(slot: ToolboxSlot, tempoBpm: Float)
|
||||||
|
/** Process one mono sample. */
|
||||||
|
fun process(x: Float): Float
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
|
||||||
|
ToolboxType.DELAY -> TapeDelay(sampleRate)
|
||||||
|
ToolboxType.REVERB -> Reverb(sampleRate)
|
||||||
|
ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
|
||||||
|
ToolboxType.BITCRUSHER -> Bitcrusher()
|
||||||
|
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
||||||
|
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tape delay: a single delay line with feedback, tempo-synced time and dry/wet.
|
||||||
|
// (The "tape heads" count is modelled but rendered as one tap here.)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private class TapeDelay(private val sampleRate: Int) : AudioEffect {
|
||||||
|
private val buffer = FloatArray(sampleRate * 2) // up to 2 s of delay
|
||||||
|
private var writeIndex = 0
|
||||||
|
private var delaySamples = sampleRate / 2
|
||||||
|
private var feedback = 0.4f
|
||||||
|
private var dryWet = 0.35f
|
||||||
|
|
||||||
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
|
val div = TimeDivision.fromIndex(slot.float("division", 3f).toInt())
|
||||||
|
val seconds = div.beatFraction * (60.0 / tempoBpm)
|
||||||
|
delaySamples = (seconds * sampleRate).toInt().coerceIn(1, buffer.size - 1)
|
||||||
|
feedback = slot.float("feedback", 0.4f).coerceIn(0f, 0.95f)
|
||||||
|
dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun process(x: Float): Float {
|
||||||
|
val readIndex = (writeIndex - delaySamples + buffer.size) % buffer.size
|
||||||
|
val delayed = buffer[readIndex]
|
||||||
|
buffer[writeIndex] = x + delayed * feedback
|
||||||
|
writeIndex = (writeIndex + 1) % buffer.size
|
||||||
|
return x * (1f - dryWet) + delayed * dryWet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RBJ biquad filter with selectable LP / HP / BP response.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private class BiquadFilterEffect(private val sampleRate: Int) : AudioEffect {
|
||||||
|
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
|
||||||
|
private var a1 = 0.0; private var a2 = 0.0
|
||||||
|
private var z1 = 0.0; private var z2 = 0.0 // transposed direct form II state
|
||||||
|
|
||||||
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
|
val type = slot.string("type", "LPF")
|
||||||
|
// Map 0..1 to ~20 Hz .. ~18 kHz logarithmically.
|
||||||
|
val cutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
|
||||||
|
val freq = 20.0 * 900.0.pow(cutoff.toDouble())
|
||||||
|
val q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
|
||||||
|
computeCoefficients(type, freq.coerceIn(20.0, sampleRate * 0.45), q)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeCoefficients(type: String, freq: Double, q: Double) {
|
||||||
|
val w0 = 2.0 * PI * freq / sampleRate
|
||||||
|
val cosW = cos(w0)
|
||||||
|
val alpha = sin(w0) / (2.0 * q)
|
||||||
|
val a0: Double
|
||||||
|
when (type) {
|
||||||
|
"HPF" -> {
|
||||||
|
b0 = (1 + cosW) / 2; b1 = -(1 + cosW); b2 = (1 + cosW) / 2
|
||||||
|
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||||
|
}
|
||||||
|
"BPF" -> {
|
||||||
|
b0 = alpha; b1 = 0.0; b2 = -alpha
|
||||||
|
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||||
|
}
|
||||||
|
else -> { // LPF
|
||||||
|
b0 = (1 - cosW) / 2; b1 = 1 - cosW; b2 = (1 - cosW) / 2
|
||||||
|
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalise by a0.
|
||||||
|
b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun process(x: Float): Float {
|
||||||
|
val input = x.toDouble()
|
||||||
|
val out = b0 * input + z1
|
||||||
|
z1 = b1 * input - a1 * out + z2
|
||||||
|
z2 = b2 * input - a2 * out
|
||||||
|
return out.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bitcrusher: bit-depth reduction + sample-rate reduction + drive.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private class Bitcrusher : AudioEffect {
|
||||||
|
private var levels = 256f
|
||||||
|
private var downsample = 1
|
||||||
|
private var drive = 1f
|
||||||
|
private var counter = 0
|
||||||
|
private var held = 0f
|
||||||
|
|
||||||
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
|
val bits = slot.float("bits", 8f).coerceIn(1f, 16f)
|
||||||
|
levels = 2f.pow(bits)
|
||||||
|
downsample = slot.float("downsample", 1f).toInt().coerceAtLeast(1)
|
||||||
|
drive = 1f + slot.float("drive", 0.2f) * 4f
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun process(x: Float): Float {
|
||||||
|
if (counter <= 0) {
|
||||||
|
counter = downsample
|
||||||
|
// Quantise to the reduced bit depth after applying drive.
|
||||||
|
val driven = tanh(x * drive)
|
||||||
|
held = (driven * levels).roundToInt() / levels
|
||||||
|
}
|
||||||
|
counter--
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schroeder/Freeverb-style mono reverb: parallel combs -> series allpasses.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private class Reverb(sampleRate: Int) : AudioEffect {
|
||||||
|
private val scale = sampleRate / 44100.0
|
||||||
|
private val combs = intArrayOf(1557, 1617, 1491, 1422).map { Comb((it * scale).toInt()) }
|
||||||
|
private val allpasses = intArrayOf(225, 556).map { Allpass((it * scale).toInt()) }
|
||||||
|
private var amount = 0.3f
|
||||||
|
|
||||||
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
|
amount = slot.float("amount", 0.3f).coerceIn(0f, 1f)
|
||||||
|
val damp = slot.float("tone", 0.5f).coerceIn(0f, 1f)
|
||||||
|
combs.forEach { it.damp = damp; it.feedback = 0.84f }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun process(x: Float): Float {
|
||||||
|
var y = 0f
|
||||||
|
for (c in combs) y += c.process(x)
|
||||||
|
y /= combs.size
|
||||||
|
for (a in allpasses) y = a.process(y)
|
||||||
|
return x * (1f - amount) + y * amount
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Comb(size: Int) {
|
||||||
|
private val buf = FloatArray(size.coerceAtLeast(1))
|
||||||
|
private var idx = 0
|
||||||
|
private var last = 0f
|
||||||
|
var feedback = 0.84f
|
||||||
|
var damp = 0.5f
|
||||||
|
fun process(x: Float): Float {
|
||||||
|
val out = buf[idx]
|
||||||
|
last = out * (1f - damp) + last * damp
|
||||||
|
buf[idx] = x + last * feedback
|
||||||
|
idx = (idx + 1) % buf.size
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Allpass(size: Int) {
|
||||||
|
private val buf = FloatArray(size.coerceAtLeast(1))
|
||||||
|
private var idx = 0
|
||||||
|
private val g = 0.5f
|
||||||
|
fun process(x: Float): Float {
|
||||||
|
val bufOut = buf[idx]
|
||||||
|
val out = -x + bufOut
|
||||||
|
buf[idx] = x + bufOut * g
|
||||||
|
idx = (idx + 1) % buf.size
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 10-band graphic EQ: ten peaking biquads at octave-spaced centre frequencies.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private class GraphicEq(private val sampleRate: Int) : AudioEffect {
|
||||||
|
private val centers = floatArrayOf(31f, 63f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
|
||||||
|
private val bands = Array(10) { PeakBiquad() }
|
||||||
|
|
||||||
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
|
for (i in bands.indices) {
|
||||||
|
val gainDb = slot.float("band$i", 0f).coerceIn(-1f, 1f) * 12f // -12..+12 dB
|
||||||
|
bands[i].set(sampleRate, centers[i], gainDb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun process(x: Float): Float {
|
||||||
|
var y = x
|
||||||
|
for (b in bands) y = b.process(y)
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PeakBiquad {
|
||||||
|
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
|
||||||
|
private var a1 = 0.0; private var a2 = 0.0
|
||||||
|
private var z1 = 0.0; private var z2 = 0.0
|
||||||
|
|
||||||
|
fun set(sampleRate: Int, freq: Float, gainDb: Float) {
|
||||||
|
if (freq >= sampleRate * 0.45f) return
|
||||||
|
val a = 10.0.pow(gainDb / 40.0)
|
||||||
|
val w0 = 2.0 * PI * freq / sampleRate
|
||||||
|
val cosW = cos(w0)
|
||||||
|
val alpha = sin(w0) / (2.0 * 1.0) // Q ~ 1 per band
|
||||||
|
val a0 = 1 + alpha / a
|
||||||
|
b0 = (1 + alpha * a) / a0
|
||||||
|
b1 = (-2 * cosW) / a0
|
||||||
|
b2 = (1 - alpha * a) / a0
|
||||||
|
a1 = (-2 * cosW) / a0
|
||||||
|
a2 = (1 - alpha / a) / a0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun process(x: Float): Float {
|
||||||
|
val input = x.toDouble()
|
||||||
|
val out = b0 * input + z1
|
||||||
|
z1 = b1 * input - a1 * out + z2
|
||||||
|
z2 = b2 * input - a2 * out
|
||||||
|
return out.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges the native Oboe (AAudio) output backend to the Kotlin [AudioEngine].
|
||||||
|
*
|
||||||
|
* The C++ side (see `src/main/cpp/native_audio.cpp`) opens a low-latency Oboe
|
||||||
|
* stream whose real-time data callback calls [renderAudio] via JNI, which simply
|
||||||
|
* delegates to [AudioEngine.fillBlock]. So the exact same synth/sequencer/mixer
|
||||||
|
* code produces the audio; only the *delivery* path changes (a native callback
|
||||||
|
* pulling data, vs. AudioTrack blocking writes), which is what lowers latency.
|
||||||
|
*
|
||||||
|
* If the native library fails to load (e.g. the NDK build was skipped) the whole
|
||||||
|
* feature is inert and the engine transparently uses its AudioTrack loop.
|
||||||
|
*/
|
||||||
|
class NativeAudioBridge(private val engine: AudioEngine) {
|
||||||
|
|
||||||
|
/** Invoked from the native audio callback thread. Must not allocate. */
|
||||||
|
@Suppress("unused") // called via JNI
|
||||||
|
fun renderAudio(buffer: FloatArray, frames: Int) {
|
||||||
|
engine.fillBlock(buffer, frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(sampleRate: Int, framesPerCallback: Int): Boolean =
|
||||||
|
if (LIB_LOADED) nativeStart(this, sampleRate, framesPerCallback) else false
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (LIB_LOADED) nativeStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private external fun nativeStart(callback: NativeAudioBridge, sampleRate: Int, framesPerCallback: Int): Boolean
|
||||||
|
private external fun nativeStop()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LIB_LOADED = runCatching { System.loadLibrary("sizzle_native") }.isSuccess
|
||||||
|
|
||||||
|
/** Whether the native Oboe backend is available on this build/device. */
|
||||||
|
fun isAvailable(): Boolean = LIB_LOADED
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ad-hoc sample recorder. Captures audio from the microphone (or a connected USB
|
||||||
|
* audio input, which the platform routes to the default source) into a mono float
|
||||||
|
* buffer that becomes a [SampleStore.Sample].
|
||||||
|
*
|
||||||
|
* Requires the RECORD_AUDIO permission (requested at app launch). Recording runs
|
||||||
|
* on its own thread so it never blocks the UI or the playback engine.
|
||||||
|
*/
|
||||||
|
class SampleRecorder(private val sampleRate: Int = 44_100) {
|
||||||
|
|
||||||
|
@Volatile private var recording = false
|
||||||
|
private var worker: Thread? = null
|
||||||
|
private val chunks = ArrayList<FloatArray>()
|
||||||
|
|
||||||
|
/** Preferred capture device (e.g. a USB audio input), or null for the default. */
|
||||||
|
var preferredInput: android.media.AudioDeviceInfo? = null
|
||||||
|
|
||||||
|
val isRecording: Boolean get() = recording
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission") // caller ensures RECORD_AUDIO is granted
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (recording) return true
|
||||||
|
val minBuf = AudioRecord.getMinBufferSize(
|
||||||
|
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||||
|
)
|
||||||
|
if (minBuf <= 0) return false
|
||||||
|
val record = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||||
|
minBuf * 2,
|
||||||
|
)
|
||||||
|
if (record.state != AudioRecord.STATE_INITIALIZED) { record.release(); return false }
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||||
|
record.setPreferredDevice(preferredInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.clear()
|
||||||
|
recording = true
|
||||||
|
record.startRecording()
|
||||||
|
worker = thread(name = "sizzle-rec") {
|
||||||
|
val buf = FloatArray(minBuf)
|
||||||
|
while (recording) {
|
||||||
|
val n = record.read(buf, 0, buf.size, AudioRecord.READ_BLOCKING)
|
||||||
|
if (n > 0) chunks.add(buf.copyOf(n))
|
||||||
|
// Safety cap: stop after ~30 s to avoid unbounded memory.
|
||||||
|
if (chunks.sumOf { it.size } > sampleRate * 30) recording = false
|
||||||
|
}
|
||||||
|
record.stop()
|
||||||
|
record.release()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop and return the recorded audio (null if nothing was captured). */
|
||||||
|
fun stop(): SampleStore.Sample? {
|
||||||
|
if (!recording && worker == null) return null
|
||||||
|
recording = false
|
||||||
|
worker?.join(1000)
|
||||||
|
worker = null
|
||||||
|
val total = chunks.sumOf { it.size }
|
||||||
|
if (total == 0) return null
|
||||||
|
val out = FloatArray(total)
|
||||||
|
var pos = 0
|
||||||
|
for (c in chunks) { c.copyInto(out, pos); pos += c.size }
|
||||||
|
chunks.clear()
|
||||||
|
return SampleStore.Sample(out, sampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A process-wide cache of decoded audio samples used by the Sampler instrument.
|
||||||
|
*
|
||||||
|
* Decoding (reading a file, parsing WAV) happens on a normal thread in the UI
|
||||||
|
* layer, which then [put]s the finished [Sample] here under a string id. The
|
||||||
|
* audio thread only ever [get]s already-decoded samples — it never touches the
|
||||||
|
* filesystem — so real-time playback stays allocation- and IO-free.
|
||||||
|
*
|
||||||
|
* The id stored in a toolbox slot's `samplePath` parameter is the key here.
|
||||||
|
*/
|
||||||
|
object SampleStore {
|
||||||
|
/** Decoded mono PCM plus the rate it was recorded at. */
|
||||||
|
class Sample(val data: FloatArray, val sampleRate: Int)
|
||||||
|
|
||||||
|
private val samples = ConcurrentHashMap<String, Sample>()
|
||||||
|
|
||||||
|
fun put(id: String, sample: Sample) { samples[id] = sample }
|
||||||
|
fun get(id: String): Sample? = if (id.isEmpty()) null else samples[id]
|
||||||
|
fun has(id: String): Boolean = samples.containsKey(id)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a WAV file (the common uncompressed formats) into a mono [Sample].
|
||||||
|
* Supports 8/16/24-bit PCM and 32-bit float, mono or stereo (stereo is
|
||||||
|
* down-mixed). Returns null if the bytes are not a WAV we understand.
|
||||||
|
*/
|
||||||
|
fun decodeWav(bytes: ByteArray): Sample? {
|
||||||
|
if (bytes.size < 44) return null
|
||||||
|
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
if (bytes[0].toInt().toChar() != 'R' || bytes[1].toInt().toChar() != 'I') return null // "RIFF"
|
||||||
|
bb.position(8)
|
||||||
|
if (readTag(bb) != "WAVE") return null
|
||||||
|
|
||||||
|
var audioFormat = 1
|
||||||
|
var channels = 1
|
||||||
|
var sampleRate = 44100
|
||||||
|
var bitsPerSample = 16
|
||||||
|
var dataOffset = -1
|
||||||
|
var dataLength = 0
|
||||||
|
|
||||||
|
// Walk the chunks looking for "fmt " and "data".
|
||||||
|
while (bb.remaining() >= 8) {
|
||||||
|
val tag = readTag(bb)
|
||||||
|
val size = bb.int
|
||||||
|
val next = bb.position() + size
|
||||||
|
when (tag) {
|
||||||
|
"fmt " -> {
|
||||||
|
audioFormat = bb.short.toInt() and 0xFFFF
|
||||||
|
channels = (bb.short.toInt() and 0xFFFF).coerceAtLeast(1)
|
||||||
|
sampleRate = bb.int
|
||||||
|
bb.int // byteRate
|
||||||
|
bb.short // blockAlign
|
||||||
|
bitsPerSample = bb.short.toInt() and 0xFFFF
|
||||||
|
}
|
||||||
|
"data" -> { dataOffset = bb.position(); dataLength = size }
|
||||||
|
}
|
||||||
|
if (dataOffset >= 0 && audioFormat != 0) { /* keep scanning is fine */ }
|
||||||
|
if (next <= bb.limit()) bb.position(next + (size and 1)) else break // chunks are word-aligned
|
||||||
|
}
|
||||||
|
if (dataOffset < 0) return null
|
||||||
|
|
||||||
|
val bytesPerSample = bitsPerSample / 8
|
||||||
|
if (bytesPerSample == 0) return null
|
||||||
|
val frameCount = dataLength / (bytesPerSample * channels)
|
||||||
|
val out = FloatArray(frameCount)
|
||||||
|
val data = ByteBuffer.wrap(bytes, dataOffset, dataLength).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
|
for (f in 0 until frameCount) {
|
||||||
|
var acc = 0f
|
||||||
|
for (c in 0 until channels) {
|
||||||
|
acc += when {
|
||||||
|
audioFormat == 3 && bitsPerSample == 32 -> data.float
|
||||||
|
bitsPerSample == 16 -> data.short / 32768f
|
||||||
|
bitsPerSample == 8 -> ((data.get().toInt() and 0xFF) - 128) / 128f
|
||||||
|
bitsPerSample == 24 -> {
|
||||||
|
val b0 = data.get().toInt() and 0xFF
|
||||||
|
val b1 = data.get().toInt() and 0xFF
|
||||||
|
val b2 = data.get().toInt() // signed high byte
|
||||||
|
((b2 shl 16) or (b1 shl 8) or b0) / 8388608f
|
||||||
|
}
|
||||||
|
bitsPerSample == 32 -> data.int / 2147483648f
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[f] = acc / channels // down-mix to mono
|
||||||
|
}
|
||||||
|
return Sample(out, sampleRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readTag(bb: ByteBuffer): String {
|
||||||
|
val b = ByteArray(4)
|
||||||
|
bb.get(b)
|
||||||
|
return String(b, Charsets.US_ASCII)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* playback rate (so a single sample is pitch-stretched across the keyboard), and
|
||||||
|
* the slice start/end markers bound the region that plays.
|
||||||
|
*
|
||||||
|
* Runs on the audio thread: [render] must not allocate. Linear interpolation
|
||||||
|
* keeps pitched playback smooth; short fade-in/out ramps avoid clicks.
|
||||||
|
*/
|
||||||
|
class SampleVoice(private val engineSampleRate: Int) {
|
||||||
|
private var sample: SampleStore.Sample? = null
|
||||||
|
private var position = 0.0 // fractional read index into sample.data
|
||||||
|
private var rate = 1.0 // samples advanced per output sample
|
||||||
|
private var startIndex = 0
|
||||||
|
private var endIndex = 0
|
||||||
|
private var amp = 0f
|
||||||
|
private var releasing = false
|
||||||
|
private var gain = 0f // current fade gain 0..1
|
||||||
|
var pitch = -1; private set
|
||||||
|
|
||||||
|
val isActive: Boolean get() = sample != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param root MIDI note at which the sample plays at its natural pitch.
|
||||||
|
* @param sliceStart/[sliceEnd] 0..1 fractions of the sample to play.
|
||||||
|
* @param volume 0..1 instrument volume.
|
||||||
|
*/
|
||||||
|
fun noteOn(
|
||||||
|
s: SampleStore.Sample, midi: Int, velocity: Int, root: Int,
|
||||||
|
sliceStart: Float, sliceEnd: Float, volume: Float,
|
||||||
|
) {
|
||||||
|
sample = s
|
||||||
|
pitch = midi
|
||||||
|
val len = s.data.size
|
||||||
|
val a = sliceStart.coerceIn(0f, 1f)
|
||||||
|
val b = sliceEnd.coerceIn(0f, 1f)
|
||||||
|
startIndex = (minOf(a, b) * len).toInt().coerceIn(0, (len - 1).coerceAtLeast(0))
|
||||||
|
endIndex = (maxOf(a, b) * len).toInt().coerceIn(startIndex + 1, len)
|
||||||
|
position = startIndex.toDouble()
|
||||||
|
// Pitch ratio * sample-rate conversion.
|
||||||
|
val semis = (midi - root) / 12.0
|
||||||
|
rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate)
|
||||||
|
amp = (velocity / 127f).coerceIn(0f, 1f) * volume
|
||||||
|
releasing = false
|
||||||
|
gain = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun noteOff() { releasing = true }
|
||||||
|
|
||||||
|
fun kill() { sample = null; pitch = -1; gain = 0f }
|
||||||
|
|
||||||
|
fun render(): Float {
|
||||||
|
val s = sample ?: return 0f
|
||||||
|
val data = s.data
|
||||||
|
val i = position.toInt()
|
||||||
|
if (i >= endIndex - 1 || i >= data.size - 1) { kill(); return 0f }
|
||||||
|
|
||||||
|
// Linear interpolation between neighbouring samples.
|
||||||
|
val frac = (position - i).toFloat()
|
||||||
|
val out = data[i] + (data[i + 1] - data[i]) * frac
|
||||||
|
|
||||||
|
// Fade in quickly, and fade out when releasing, to avoid clicks.
|
||||||
|
gain = if (releasing) (gain - FADE_STEP).coerceAtLeast(0f)
|
||||||
|
else (gain + FADE_STEP).coerceAtMost(1f)
|
||||||
|
if (releasing && gain <= 0f) { kill(); return 0f }
|
||||||
|
|
||||||
|
position += rate
|
||||||
|
return out * amp * gain
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FADE_STEP = 0.01f // ~2 ms ramp at 48 kHz
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One playing note (a "voice"). This is a deliberately simple NES/Famicom-style
|
||||||
|
* tone generator: a few band-limited-ish waveforms plus a linear ADSR envelope.
|
||||||
|
* It renders sample-by-sample into the mix buffer.
|
||||||
|
*
|
||||||
|
* Everything here runs on the audio thread and must never allocate or block.
|
||||||
|
*/
|
||||||
|
class SynthVoice(private val sampleRate: Int) {
|
||||||
|
|
||||||
|
enum class Wave { PULSE_12, PULSE_25, PULSE_50, TRIANGLE, NOISE }
|
||||||
|
|
||||||
|
private enum class Stage { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
|
||||||
|
|
||||||
|
// --- Note / oscillator state ---
|
||||||
|
private var wave = Wave.PULSE_50
|
||||||
|
private var phase = 0.0 // 0..1
|
||||||
|
private var phaseInc = 0.0 // per-sample phase advance
|
||||||
|
private var noiseReg = 0x7FFF // linear-feedback shift register for noise
|
||||||
|
var pitch = -1; private set // MIDI note this voice is playing, -1 idle
|
||||||
|
|
||||||
|
// --- Envelope (times are in seconds) ---
|
||||||
|
private var stage = Stage.IDLE
|
||||||
|
private var env = 0.0
|
||||||
|
private var attack = 0.01; private var decay = 0.15
|
||||||
|
private var sustainLevel = 0.6; private var release = 0.1
|
||||||
|
private var amp = 0.0 // note velocity as 0..1
|
||||||
|
|
||||||
|
val isActive: Boolean get() = stage != Stage.IDLE
|
||||||
|
|
||||||
|
/** Start (or retrigger) this voice. Called from the sequencer/live input. */
|
||||||
|
fun noteOn(midi: Int, velocity: Int, w: Wave, a: Double, d: Double, s: Double, r: Double) {
|
||||||
|
pitch = midi
|
||||||
|
wave = w
|
||||||
|
attack = a.coerceAtLeast(0.001); decay = d; sustainLevel = s; release = r
|
||||||
|
amp = (velocity / 127.0).coerceIn(0.0, 1.0)
|
||||||
|
phaseInc = midiToHz(midi) / sampleRate
|
||||||
|
stage = Stage.ATTACK
|
||||||
|
// env is intentionally NOT reset to 0 on retrigger to avoid clicks.
|
||||||
|
}
|
||||||
|
|
||||||
|
fun noteOff() {
|
||||||
|
if (stage != Stage.IDLE) stage = Stage.RELEASE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard stop with no release tail (used on transport stop / voice steal). */
|
||||||
|
fun kill() {
|
||||||
|
stage = Stage.IDLE; env = 0.0; pitch = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one sample and return its signed float value (roughly -1..1). */
|
||||||
|
fun render(): Float {
|
||||||
|
if (stage == Stage.IDLE) return 0f
|
||||||
|
advanceEnvelope()
|
||||||
|
val osc = oscillator()
|
||||||
|
// advance oscillator phase
|
||||||
|
phase += phaseInc
|
||||||
|
if (phase >= 1.0) phase -= 1.0
|
||||||
|
return (osc * env * amp).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun oscillator(): Double = when (wave) {
|
||||||
|
Wave.PULSE_12 -> if (phase < 0.125) 1.0 else -1.0
|
||||||
|
Wave.PULSE_25 -> if (phase < 0.25) 1.0 else -1.0
|
||||||
|
Wave.PULSE_50 -> if (phase < 0.5) 1.0 else -1.0
|
||||||
|
Wave.TRIANGLE -> 1.0 - 4.0 * kotlin.math.abs((phase % 1.0) - 0.5) // -1..1
|
||||||
|
Wave.NOISE -> {
|
||||||
|
// 15-bit LFSR clocked at the note frequency for a pitched-noise feel.
|
||||||
|
if (phase < phaseInc) {
|
||||||
|
val bit = (noiseReg xor (noiseReg shr 1)) and 1
|
||||||
|
noiseReg = (noiseReg shr 1) or (bit shl 14)
|
||||||
|
}
|
||||||
|
if (noiseReg and 1 == 0) 1.0 else -1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun advanceEnvelope() {
|
||||||
|
val dt = 1.0 / sampleRate
|
||||||
|
when (stage) {
|
||||||
|
Stage.ATTACK -> {
|
||||||
|
env += dt / attack
|
||||||
|
if (env >= 1.0) { env = 1.0; stage = Stage.DECAY }
|
||||||
|
}
|
||||||
|
Stage.DECAY -> {
|
||||||
|
env -= dt / decay.coerceAtLeast(0.001) * (1.0 - sustainLevel)
|
||||||
|
if (env <= sustainLevel) { env = sustainLevel; stage = Stage.SUSTAIN }
|
||||||
|
}
|
||||||
|
Stage.SUSTAIN -> { /* hold until noteOff */ }
|
||||||
|
Stage.RELEASE -> {
|
||||||
|
env -= dt / release.coerceAtLeast(0.001) * sustainLevel.coerceAtLeast(0.001)
|
||||||
|
if (env <= 0.0) kill()
|
||||||
|
}
|
||||||
|
Stage.IDLE -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Equal-tempered MIDI note -> frequency in Hz (A4 = 69 = 440 Hz). */
|
||||||
|
fun midiToHz(midi: Int): Double = 440.0 * Math.pow(2.0, (midi - 69) / 12.0)
|
||||||
|
|
||||||
|
@Suppress("unused") private const val TWO_PI = 2.0 * PI
|
||||||
|
@Suppress("unused") private fun s(x: Double) = sin(x) // kept for future waves
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.audio
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small, immutable snapshot of "where playback is" that the audio thread
|
||||||
|
* publishes and the UI reads to draw the playhead. Kept as a plain value class so
|
||||||
|
* copying it across threads is cheap and lock-free.
|
||||||
|
*/
|
||||||
|
data class TransportState(
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
/** Absolute line index within the currently-playing pattern. */
|
||||||
|
val currentLine: Int = 0,
|
||||||
|
/** Beat index within the arrangement (0 until arrangement.lengthBeats). */
|
||||||
|
val currentBeat: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.input
|
||||||
|
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates USB / Bluetooth gamepad input into [InputAction]s. Two kinds of
|
||||||
|
* events arrive:
|
||||||
|
* - button presses -> [KeyEvent] (handled in [onKeyDown])
|
||||||
|
* - stick / trigger / D-pad-as-hat movement -> [MotionEvent] (handled in [onMotion])
|
||||||
|
*
|
||||||
|
* The button map is user-rebindable: [bindings] maps an Android key-code to an
|
||||||
|
* action id string, editable from the Settings tab. A sensible default layout is
|
||||||
|
* provided so the pad works out of the box.
|
||||||
|
*/
|
||||||
|
class GamepadInput(
|
||||||
|
private val router: InputRouter,
|
||||||
|
var bindings: MutableMap<Int, String> = defaultBindings(),
|
||||||
|
) {
|
||||||
|
// Debounce hat/stick navigation so a held stick doesn't fire every frame.
|
||||||
|
private var lastHatX = 0
|
||||||
|
private var lastHatY = 0
|
||||||
|
|
||||||
|
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD) &&
|
||||||
|
!event.isFromSource(InputDevice.SOURCE_JOYSTICK)
|
||||||
|
) return false
|
||||||
|
|
||||||
|
val actionId = bindings[keyCode]
|
||||||
|
if (actionId == null) {
|
||||||
|
// Unbound button: forward as raw so "learn" mode can capture it.
|
||||||
|
if (router.learnMode) router.dispatch(InputAction.RawControl(InputSource.GAMEPAD, keyCode, 1f))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
actionFromId(actionId)?.let { router.dispatch(it); return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle analog sticks, triggers, and D-pad hat axes. */
|
||||||
|
fun onMotion(event: MotionEvent): Boolean {
|
||||||
|
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||||
|
|
||||||
|
// D-pad reported as HAT axes on many pads.
|
||||||
|
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X).toSign()
|
||||||
|
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y).toSign()
|
||||||
|
if (hatX != lastHatX) {
|
||||||
|
lastHatX = hatX
|
||||||
|
when (hatX) { -1 -> router.dispatch(InputAction.NavLeft); 1 -> router.dispatch(InputAction.NavRight) }
|
||||||
|
}
|
||||||
|
if (hatY != lastHatY) {
|
||||||
|
lastHatY = hatY
|
||||||
|
when (hatY) { -1 -> router.dispatch(InputAction.NavUp); 1 -> router.dispatch(InputAction.NavDown) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right stick vertical edits the focused value (up = increment).
|
||||||
|
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||||
|
when {
|
||||||
|
ry < -EDIT_THRESHOLD -> router.dispatch(InputAction.Increment(1))
|
||||||
|
ry > EDIT_THRESHOLD -> router.dispatch(InputAction.Decrement(1))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionFromId(id: String): InputAction? = when (id) {
|
||||||
|
"playPause" -> InputAction.PlayPause
|
||||||
|
"stop" -> InputAction.Stop
|
||||||
|
"loop" -> InputAction.ToggleLoop
|
||||||
|
"up" -> InputAction.NavUp
|
||||||
|
"down" -> InputAction.NavDown
|
||||||
|
"left" -> InputAction.NavLeft
|
||||||
|
"right" -> InputAction.NavRight
|
||||||
|
"increment" -> InputAction.Increment(1)
|
||||||
|
"decrement" -> InputAction.Decrement(1)
|
||||||
|
"clear" -> InputAction.ClearCell
|
||||||
|
"nextTab" -> InputAction.NextTab
|
||||||
|
"prevTab" -> InputAction.PrevTab
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Float.toSign(): Int = when {
|
||||||
|
this < -0.5f -> -1; this > 0.5f -> 1; else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EDIT_THRESHOLD = 0.6f
|
||||||
|
|
||||||
|
/** Default Xbox/standard-layout mapping; every value is an action id. */
|
||||||
|
fun defaultBindings(): MutableMap<Int, String> = mutableMapOf(
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A to "playPause",
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B to "stop",
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X to "clear",
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y to "loop",
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L1 to "prevTab",
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R1 to "nextTab",
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP to "up",
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN to "down",
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT to "left",
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT to "right",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.input
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heart of the "four equal input methods" requirement. Touch, physical
|
||||||
|
* keyboard, gamepad and MIDI never talk to the app directly — each is translated
|
||||||
|
* into one of these neutral [InputAction]s and fed through the same [InputRouter].
|
||||||
|
* That means a feature only has to be implemented once (in the router/handler)
|
||||||
|
* and it instantly works from every input device.
|
||||||
|
*
|
||||||
|
* If you want to support a new gesture or control, add a case here first, then
|
||||||
|
* teach each source how to produce it and the handler how to react.
|
||||||
|
*/
|
||||||
|
sealed interface InputAction {
|
||||||
|
|
||||||
|
// ----- Grid navigation (cursor movement in the tracker) -----
|
||||||
|
data object NavUp : InputAction
|
||||||
|
data object NavDown : InputAction
|
||||||
|
data object NavLeft : InputAction
|
||||||
|
data object NavRight : InputAction
|
||||||
|
/** Move to the next / previous editable column within the focused track. */
|
||||||
|
data object NextColumn : InputAction
|
||||||
|
data object PrevColumn : InputAction
|
||||||
|
|
||||||
|
// ----- Value editing on the focused cell -----
|
||||||
|
/** +1 step: pitch cycles within the active scale; velocity/channel +1. */
|
||||||
|
data class Increment(val amount: Int = 1) : InputAction
|
||||||
|
data class Decrement(val amount: Int = 1) : InputAction
|
||||||
|
/** Clear the focused cell / place a note-off. */
|
||||||
|
data object ClearCell : InputAction
|
||||||
|
data object NoteOffCell : InputAction
|
||||||
|
|
||||||
|
// ----- Live note entry (also used to audition instruments) -----
|
||||||
|
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction
|
||||||
|
data class NoteOff(val pitch: Int) : InputAction
|
||||||
|
|
||||||
|
// ----- Transport -----
|
||||||
|
data object PlayPause : InputAction
|
||||||
|
/** First tap: stop. Second tap while stopped: rewind to start. */
|
||||||
|
data object Stop : InputAction
|
||||||
|
data object ToggleLoop : InputAction
|
||||||
|
|
||||||
|
// ----- Navigation between the four tabs -----
|
||||||
|
data class SelectTab(val index: Int) : InputAction
|
||||||
|
data object NextTab : InputAction
|
||||||
|
data object PrevTab : InputAction
|
||||||
|
|
||||||
|
// ----- A raw, unmapped control (e.g. an unbound MIDI CC). Handlers may
|
||||||
|
// ignore it; the settings screen uses it to "learn" new bindings. -----
|
||||||
|
data class RawControl(val source: InputSource, val code: Int, val value: Float) : InputAction
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which physical device an action came from — handy for on-screen feedback. */
|
||||||
|
enum class InputSource { TOUCH, KEYBOARD, GAMEPAD, MIDI }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.input
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny message bus. Every input source calls [dispatch]; the app's ViewModel
|
||||||
|
* collects [actions] and reacts. Using a flow (rather than a direct callback)
|
||||||
|
* decouples the sources from the UI and lets us buffer bursts of MIDI events
|
||||||
|
* without dropping them.
|
||||||
|
*
|
||||||
|
* When "MIDI learn" mode is on, controls that aren't bound to anything are still
|
||||||
|
* forwarded as [InputAction.RawControl] so the settings screen can capture them.
|
||||||
|
*/
|
||||||
|
class InputRouter {
|
||||||
|
// extraBufferCapacity gives us headroom for fast MIDI/gamepad bursts.
|
||||||
|
private val _actions = MutableSharedFlow<InputAction>(extraBufferCapacity = 128)
|
||||||
|
val actions: SharedFlow<InputAction> = _actions.asSharedFlow()
|
||||||
|
|
||||||
|
/** True while the settings screen is waiting to learn a new binding. */
|
||||||
|
@Volatile var learnMode: Boolean = false
|
||||||
|
|
||||||
|
fun dispatch(action: InputAction) {
|
||||||
|
// tryEmit never suspends; if the buffer is somehow full we drop the
|
||||||
|
// oldest by design (a missed nav key is harmless, a stall is not).
|
||||||
|
_actions.tryEmit(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.input
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates physical (USB / Bluetooth) keyboard events into [InputAction]s.
|
||||||
|
* [MainActivity] forwards every hardware key here from its
|
||||||
|
* `onKeyDown` / `onKeyUp` overrides.
|
||||||
|
*
|
||||||
|
* The letter rows form a two-octave "tracker piano" in the classic layout so you
|
||||||
|
* can play and record notes without a MIDI device:
|
||||||
|
* lower octave : Z S X D C V G B H N J M (C .. B)
|
||||||
|
* upper octave : Q 2 W 3 E R 5 T 6 Y 7 U (C .. B, one octave up)
|
||||||
|
*/
|
||||||
|
class KeyboardInput(private val router: InputRouter) {
|
||||||
|
|
||||||
|
/** The base octave the keyboard piano plays in; adjustable with +/- keys. */
|
||||||
|
var baseOctave: Int = 4
|
||||||
|
|
||||||
|
// keyCode -> semitone offset from the base octave's C.
|
||||||
|
private val pianoMap: Map<Int, Int> = mapOf(
|
||||||
|
KeyEvent.KEYCODE_Z to 0, KeyEvent.KEYCODE_S to 1, KeyEvent.KEYCODE_X to 2,
|
||||||
|
KeyEvent.KEYCODE_D to 3, KeyEvent.KEYCODE_C to 4, KeyEvent.KEYCODE_V to 5,
|
||||||
|
KeyEvent.KEYCODE_G to 6, KeyEvent.KEYCODE_B to 7, KeyEvent.KEYCODE_H to 8,
|
||||||
|
KeyEvent.KEYCODE_N to 9, KeyEvent.KEYCODE_J to 10, KeyEvent.KEYCODE_M to 11,
|
||||||
|
KeyEvent.KEYCODE_Q to 12, KeyEvent.KEYCODE_2 to 13, KeyEvent.KEYCODE_W to 14,
|
||||||
|
KeyEvent.KEYCODE_3 to 15, KeyEvent.KEYCODE_E to 16, KeyEvent.KEYCODE_R to 17,
|
||||||
|
KeyEvent.KEYCODE_5 to 18, KeyEvent.KEYCODE_T to 19, KeyEvent.KEYCODE_6 to 20,
|
||||||
|
KeyEvent.KEYCODE_Y to 21, KeyEvent.KEYCODE_7 to 22, KeyEvent.KEYCODE_U to 23,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @return true if we consumed the event. */
|
||||||
|
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
// Ignore auto-repeat for one-shot actions but let piano notes retrigger.
|
||||||
|
val action: InputAction? = when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> InputAction.NavUp
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> InputAction.NavDown
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> InputAction.NavLeft
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> InputAction.NavRight
|
||||||
|
KeyEvent.KEYCODE_TAB -> if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn
|
||||||
|
KeyEvent.KEYCODE_SPACE -> InputAction.PlayPause
|
||||||
|
KeyEvent.KEYCODE_ENTER -> InputAction.Stop
|
||||||
|
KeyEvent.KEYCODE_L -> InputAction.ToggleLoop
|
||||||
|
KeyEvent.KEYCODE_PAGE_UP -> InputAction.Increment(1)
|
||||||
|
KeyEvent.KEYCODE_PAGE_DOWN -> InputAction.Decrement(1)
|
||||||
|
KeyEvent.KEYCODE_DEL -> InputAction.ClearCell
|
||||||
|
KeyEvent.KEYCODE_GRAVE -> InputAction.NoteOffCell
|
||||||
|
KeyEvent.KEYCODE_MINUS -> { baseOctave = (baseOctave - 1).coerceIn(0, 8); null }
|
||||||
|
KeyEvent.KEYCODE_EQUALS -> { baseOctave = (baseOctave + 1).coerceIn(0, 8); null }
|
||||||
|
else -> pianoMap[keyCode]?.let { offset ->
|
||||||
|
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
|
||||||
|
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
|
InputAction.NoteOn(pitch, velocity = 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return action?.let { router.dispatch(it); true } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean {
|
||||||
|
val offset = pianoMap[keyCode] ?: return false
|
||||||
|
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
|
||||||
|
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
|
router.dispatch(InputAction.NoteOff(pitch))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.input
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.midi.MidiDeviceInfo
|
||||||
|
import android.media.midi.MidiManager
|
||||||
|
import android.media.midi.MidiOutputPort
|
||||||
|
import android.media.midi.MidiReceiver
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges hardware MIDI (USB *and* Bluetooth — Android surfaces both through the
|
||||||
|
* same [MidiManager]) into the app's [InputAction] stream.
|
||||||
|
*
|
||||||
|
* MIDI note-on/off become live notes; MIDI Control Change (CC) messages are
|
||||||
|
* matched against [ccBindings] so any knob/pad can drive navigation or transport.
|
||||||
|
* Unmatched CCs are forwarded as [InputAction.RawControl] so the Settings screen
|
||||||
|
* can "learn" them.
|
||||||
|
*
|
||||||
|
* Note: this handles MIDI *input*. Sending MIDI out lives in the audio/midi
|
||||||
|
* output module; both share the same [MidiManager].
|
||||||
|
*/
|
||||||
|
class MidiInput(
|
||||||
|
context: Context,
|
||||||
|
private val router: InputRouter,
|
||||||
|
/** CC number -> action id, editable from Settings (MIDI bindings section). */
|
||||||
|
var ccBindings: MutableMap<Int, String> = mutableMapOf(),
|
||||||
|
) {
|
||||||
|
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val openPorts = mutableListOf<MidiOutputPort>()
|
||||||
|
|
||||||
|
/** Start listening. Opens every currently-attached input-capable device and
|
||||||
|
* watches for hot-plug events. Safe to call if the device has no MIDI. */
|
||||||
|
fun start() {
|
||||||
|
val mm = midiManager ?: return
|
||||||
|
mm.devices.forEach(::openDevice)
|
||||||
|
mm.registerDeviceCallback(object : MidiManager.DeviceCallback() {
|
||||||
|
override fun onDeviceAdded(device: MidiDeviceInfo) = openDevice(device)
|
||||||
|
}, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
openPorts.forEach { runCatching { it.close() } }
|
||||||
|
openPorts.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openDevice(info: MidiDeviceInfo) {
|
||||||
|
// We want devices that can SEND to us (they expose output ports).
|
||||||
|
if (info.outputPortCount == 0) return
|
||||||
|
midiManager?.openDevice(info, { device ->
|
||||||
|
val port = device?.openOutputPort(0) ?: return@openDevice
|
||||||
|
port.connect(parser)
|
||||||
|
openPorts += port
|
||||||
|
}, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the raw MIDI byte stream. MIDI status bytes have the high bit set;
|
||||||
|
* the low nibble is the channel, the high nibble the message type. */
|
||||||
|
private val parser = object : MidiReceiver() {
|
||||||
|
override fun onSend(data: ByteArray, offset: Int, count: Int, timestamp: Long) {
|
||||||
|
var i = offset
|
||||||
|
val end = offset + count
|
||||||
|
while (i < end) {
|
||||||
|
val status = data[i].toInt() and 0xFF
|
||||||
|
if (status < 0x80) { i++; continue } // skip stray data bytes
|
||||||
|
val type = status and 0xF0
|
||||||
|
when (type) {
|
||||||
|
0x90 -> { // Note On
|
||||||
|
val note = data.getOrZero(i + 1)
|
||||||
|
val vel = data.getOrZero(i + 2)
|
||||||
|
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
|
||||||
|
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
0x80 -> { // Note Off
|
||||||
|
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
|
||||||
|
}
|
||||||
|
0xB0 -> { // Control Change
|
||||||
|
val cc = data.getOrZero(i + 1)
|
||||||
|
val value = data.getOrZero(i + 2)
|
||||||
|
handleCc(cc, value); i += 3
|
||||||
|
}
|
||||||
|
0xC0, 0xD0 -> i += 2 // program change / channel pressure: 1 data byte
|
||||||
|
else -> i += 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCc(cc: Int, value: Int) {
|
||||||
|
val actionId = ccBindings[cc]
|
||||||
|
if (actionId == null) {
|
||||||
|
router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Momentary controls fire on the "press" half (value >= 64).
|
||||||
|
val action = when (actionId) {
|
||||||
|
"playPause" -> if (value >= 64) InputAction.PlayPause else null
|
||||||
|
"stop" -> if (value >= 64) InputAction.Stop else null
|
||||||
|
"loop" -> if (value >= 64) InputAction.ToggleLoop else null
|
||||||
|
"up" -> if (value >= 64) InputAction.NavUp else null
|
||||||
|
"down" -> if (value >= 64) InputAction.NavDown else null
|
||||||
|
"left" -> if (value >= 64) InputAction.NavLeft else null
|
||||||
|
"right" -> if (value >= 64) InputAction.NavRight else null
|
||||||
|
"increment" -> InputAction.Increment(1)
|
||||||
|
"decrement" -> InputAction.Decrement(1)
|
||||||
|
"clear" -> if (value >= 64) InputAction.ClearCell else null
|
||||||
|
"nextTab" -> if (value >= 64) InputAction.NextTab else null
|
||||||
|
"prevTab" -> if (value >= 64) InputAction.PrevTab else null
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
action?.let(router::dispatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.getOrZero(index: Int): Int =
|
||||||
|
if (index < size) this[index].toInt() and 0xFF else 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.io
|
||||||
|
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and writes a single instrument/effect preset as plain, human-readable
|
||||||
|
* text. The format is deliberately trivial so a person can hand-edit it and a
|
||||||
|
* machine can parse it with two lines of code:
|
||||||
|
*
|
||||||
|
* SIZZLE-PRESET 1
|
||||||
|
* TYPE=NES_SYNTH
|
||||||
|
* NAME=Lead
|
||||||
|
* wave=Pulse25
|
||||||
|
* attack=0.01
|
||||||
|
* ...
|
||||||
|
*
|
||||||
|
* The same routine is used for the clipboard copy/paste in the Toolbox tab and
|
||||||
|
* for the on-disk preset library.
|
||||||
|
*/
|
||||||
|
object PresetIo {
|
||||||
|
private const val HEADER = "SIZZLE-PRESET 1"
|
||||||
|
|
||||||
|
/** Serialize a slot's device + parameters to text. */
|
||||||
|
fun export(slot: ToolboxSlot): String {
|
||||||
|
val type = slot.type ?: return "$HEADER\nTYPE=EMPTY"
|
||||||
|
return buildString {
|
||||||
|
appendLine(HEADER)
|
||||||
|
appendLine("TYPE=${type.name}")
|
||||||
|
appendLine("NAME=${slot.name}")
|
||||||
|
// Emit params in the type's declared order for stable, readable files.
|
||||||
|
type.params.forEach { spec ->
|
||||||
|
appendLine("${spec.key}=${slot.string(spec.key, spec.default.toString())}")
|
||||||
|
}
|
||||||
|
// Emit any extra string params the engine stored (e.g. samplePath).
|
||||||
|
slot.values.keys
|
||||||
|
.filter { key -> type.params.none { it.key == key } }
|
||||||
|
.forEach { key -> appendLine("$key=${slot.values[key]}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse preset [text] into [slot], overwriting its current contents. Unknown
|
||||||
|
* or malformed lines are ignored so a partially-edited file still loads.
|
||||||
|
* @return true if a valid device type was found and applied.
|
||||||
|
*/
|
||||||
|
fun import(slot: ToolboxSlot, text: String): Boolean {
|
||||||
|
val lines = text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
val map = LinkedHashMap<String, String>()
|
||||||
|
var type: ToolboxType? = null
|
||||||
|
for (line in lines) {
|
||||||
|
if (line == HEADER || !line.contains('=')) continue
|
||||||
|
val (key, value) = line.split('=', limit = 2)
|
||||||
|
when (key) {
|
||||||
|
"TYPE" -> type = ToolboxType.entries.firstOrNull { it.name == value }
|
||||||
|
"NAME" -> slot.name = value
|
||||||
|
else -> map[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val t = type ?: return false
|
||||||
|
slot.fill(t) // reset to defaults for the type ...
|
||||||
|
slot.name = slot.name.ifBlank { t.displayName }
|
||||||
|
map.forEach { (k, v) -> slot.set(k, v) } // ... then apply the saved values
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.io
|
||||||
|
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Mixer
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Scale
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves and loads a whole [Project] to/from the `.sng` text format.
|
||||||
|
*
|
||||||
|
* The format is line-oriented and human-readable (sections in [BRACKETS], one
|
||||||
|
* record per line). It is our own layout, aligned in spirit with the reference
|
||||||
|
* Sizzletracker project (github.com/reactorcoremeltdown/sizzletracker). A field
|
||||||
|
* mapping to that project's exact byte layout is the remaining work — see
|
||||||
|
* DEVELOPER_HANDOVER.md, "Project format". Because this writer/reader is fully
|
||||||
|
* round-trip safe on its own, the app is usable today and the compatibility
|
||||||
|
* shim can be added without touching any other code.
|
||||||
|
*
|
||||||
|
* Only non-empty tracker cells are written, keeping songs compact.
|
||||||
|
*/
|
||||||
|
object ProjectIo {
|
||||||
|
private const val HEADER = "SIZZLETRACKER-SNG 1"
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- write
|
||||||
|
fun save(project: Project): String = buildString {
|
||||||
|
appendLine(HEADER)
|
||||||
|
appendLine("NAME=${project.name}")
|
||||||
|
appendLine("TEMPO=${project.tempoBpm}")
|
||||||
|
appendLine("SIG=${project.timeSignature.name}")
|
||||||
|
appendLine("SCALE=${project.scale.name}")
|
||||||
|
appendLine("ROOT=${project.rootNote}")
|
||||||
|
appendLine("ACTIVE=${project.activePatternId}")
|
||||||
|
|
||||||
|
project.patterns.forEach { p ->
|
||||||
|
appendLine("[PATTERN id=${p.id} name=${p.name} len=${p.length}]")
|
||||||
|
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
for (line in 0 until p.length) {
|
||||||
|
val cell = p.cell(t, line)
|
||||||
|
if (!cell.isEmpty) appendLine("$t,$line,${cell.note},${cell.velocity},${cell.channel}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val arr = project.arrangement
|
||||||
|
appendLine("[ARRANGEMENT len=${arr.lengthBeats} loop=${arr.loopEnabled.b()}]")
|
||||||
|
for (lane in arr.slots.indices) {
|
||||||
|
for (beat in 0 until arr.lengthBeats) {
|
||||||
|
val pid = arr.slots[lane][beat]
|
||||||
|
if (pid >= 0) appendLine("$lane,$beat,$pid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendLine("[REGION A en=${arr.regionA.enabled.b()} s=${arr.regionA.start} e=${arr.regionA.end} r=${arr.regionA.repeats}]")
|
||||||
|
appendLine("[REGION B en=${arr.regionB.enabled.b()} s=${arr.regionB.start} e=${arr.regionB.end} r=${arr.regionB.repeats}]")
|
||||||
|
|
||||||
|
appendLine("[MIXER]")
|
||||||
|
project.mixer.channels.forEach { ch ->
|
||||||
|
appendLine(
|
||||||
|
"${ch.index},${ch.instrumentSlot},${ch.fxSlots.joinToString(",")}," +
|
||||||
|
"${ch.midiChannel},${ch.volume},${ch.mute.b()},${ch.solo.b()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendLine("[TOOLBOX]")
|
||||||
|
project.toolbox.forEach { slot ->
|
||||||
|
if (!slot.isEmpty) {
|
||||||
|
val params = slot.values.entries.joinToString(",") { "${it.key}=${it.value}" }
|
||||||
|
appendLine("${slot.index}=${slot.type!!.name};${slot.name};$params")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- read
|
||||||
|
/** Parse [text] into a fresh [Project]. Malformed lines are skipped. */
|
||||||
|
fun load(text: String): Project = loadInto(Project(), text)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse [text] into the *existing* [project], replacing its contents. Used to
|
||||||
|
* load a song into the app's shared singleton without swapping the object the
|
||||||
|
* ViewModel and audio engine already hold a reference to.
|
||||||
|
*/
|
||||||
|
fun loadInto(project: Project, text: String): Project {
|
||||||
|
// Reset everything to a clean slate first.
|
||||||
|
project.patterns.clear()
|
||||||
|
for (lane in project.arrangement.slots.indices)
|
||||||
|
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY)
|
||||||
|
project.toolbox.forEach { it.clearSlot() }
|
||||||
|
project.mixer.channels.forEach { ch ->
|
||||||
|
ch.instrumentSlot = -1; ch.fxSlots.fill(-1); ch.midiChannel = 1
|
||||||
|
ch.volume = 0.8f; ch.mute = false; ch.solo = false
|
||||||
|
}
|
||||||
|
var section = ""
|
||||||
|
var current: Pattern? = null
|
||||||
|
|
||||||
|
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||||
|
when {
|
||||||
|
line == HEADER -> {}
|
||||||
|
line.startsWith("[PATTERN") -> {
|
||||||
|
val attrs = parseAttrs(line)
|
||||||
|
current = Pattern(
|
||||||
|
id = attrs["id"]?.toIntOrNull() ?: project.patterns.size,
|
||||||
|
name = attrs["name"] ?: "PTN",
|
||||||
|
length = attrs["len"]?.toIntOrNull() ?: 16,
|
||||||
|
).also { project.patterns.add(it) }
|
||||||
|
section = "PATTERN"
|
||||||
|
}
|
||||||
|
line.startsWith("[ARRANGEMENT") -> {
|
||||||
|
val attrs = parseAttrs(line)
|
||||||
|
project.arrangement.lengthBeats = attrs["len"]?.toIntOrNull() ?: 64
|
||||||
|
project.arrangement.loopEnabled = attrs["loop"] == "1"
|
||||||
|
section = "ARRANGEMENT"
|
||||||
|
}
|
||||||
|
line.startsWith("[REGION A") -> applyRegion(project.arrangement.regionA, parseAttrs(line))
|
||||||
|
line.startsWith("[REGION B") -> applyRegion(project.arrangement.regionB, parseAttrs(line))
|
||||||
|
line.startsWith("[MIXER]") -> section = "MIXER"
|
||||||
|
line.startsWith("[TOOLBOX]") -> section = "TOOLBOX"
|
||||||
|
line.contains('=') && !line.startsWith("[") && section == "" ->
|
||||||
|
applyHeaderField(project, line)
|
||||||
|
section == "PATTERN" -> applyPatternCell(current, line)
|
||||||
|
section == "ARRANGEMENT" -> applyArrangementSlot(project, line)
|
||||||
|
section == "MIXER" -> applyMixerRow(project, line)
|
||||||
|
section == "TOOLBOX" -> applyToolboxRow(project, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Guarantee one block per lane (lane i is tied to block i), padding any
|
||||||
|
// that the file did not define so the arrangement/editor always resolve.
|
||||||
|
for (id in 0 until com.reactorcoremeltdown.sizzletracker.model.Arrangement.LANE_COUNT) {
|
||||||
|
if (project.patterns.none { it.id == id }) project.patterns.add(Pattern(id, name = "BLK${id + 1}"))
|
||||||
|
}
|
||||||
|
project.patterns.sortBy { it.id }
|
||||||
|
project.activePatternId = project.activePatternId.coerceIn(0, project.patterns.size - 1)
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- small helpers -----
|
||||||
|
private fun Boolean.b() = if (this) "1" else "0"
|
||||||
|
|
||||||
|
private fun parseAttrs(line: String): Map<String, String> =
|
||||||
|
line.trim('[', ']').substringAfter(' ', "").split(' ')
|
||||||
|
.mapNotNull { tok -> tok.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }
|
||||||
|
.toMap()
|
||||||
|
|
||||||
|
private fun applyHeaderField(p: Project, line: String) {
|
||||||
|
val (k, v) = line.split('=', limit = 2)
|
||||||
|
when (k) {
|
||||||
|
"NAME" -> p.name = v
|
||||||
|
"TEMPO" -> p.tempoBpm = v.toFloatOrNull() ?: 120f
|
||||||
|
"SIG" -> p.timeSignature = runCatching { TimeSignature.valueOf(v) }.getOrDefault(TimeSignature.FOUR_FOUR)
|
||||||
|
"SCALE" -> p.scale = runCatching { Scale.valueOf(v) }.getOrDefault(Scale.CHROMATIC)
|
||||||
|
"ROOT" -> p.rootNote = v.toIntOrNull() ?: 0
|
||||||
|
"ACTIVE" -> p.activePatternId = v.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPatternCell(pattern: Pattern?, line: String) {
|
||||||
|
val p = pattern ?: return
|
||||||
|
val f = line.split(',')
|
||||||
|
if (f.size < 5) return
|
||||||
|
val t = f[0].toIntOrNull() ?: return
|
||||||
|
val ln = f[1].toIntOrNull() ?: return
|
||||||
|
if (t !in 0 until Pattern.TRACK_COUNT || ln !in 0 until p.length) return
|
||||||
|
p.cell(t, ln).apply {
|
||||||
|
note = f[2].toIntOrNull() ?: Cell.EMPTY
|
||||||
|
velocity = (f[3].toIntOrNull() ?: Cell.MAX_VELOCITY).coerceIn(0, Cell.MAX_VELOCITY)
|
||||||
|
channel = (f[4].toIntOrNull() ?: 1).coerceIn(1, Cell.MAX_CHANNEL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyArrangementSlot(p: Project, line: String) {
|
||||||
|
val f = line.split(',')
|
||||||
|
if (f.size < 3) return
|
||||||
|
p.arrangement.set(f[0].toInt(), f[1].toInt(), f[2].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyRegion(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, a: Map<String, String>) {
|
||||||
|
region.enabled = a["en"] == "1"
|
||||||
|
region.start = a["s"]?.toIntOrNull() ?: 0
|
||||||
|
region.end = a["e"]?.toIntOrNull() ?: 0
|
||||||
|
region.repeats = a["r"]?.toIntOrNull() ?: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyMixerRow(p: Project, line: String) {
|
||||||
|
val f = line.split(',')
|
||||||
|
if (f.size < 10) return
|
||||||
|
val idx = f[0].toIntOrNull() ?: return
|
||||||
|
val ch = p.mixer.channels.getOrNull(idx) ?: return
|
||||||
|
ch.instrumentSlot = f[1].toInt()
|
||||||
|
for (i in 0 until Mixer.FX_SLOTS) ch.fxSlots[i] = f[2 + i].toInt()
|
||||||
|
ch.midiChannel = f[6].toInt()
|
||||||
|
ch.volume = f[7].toFloat()
|
||||||
|
ch.mute = f[8] == "1"
|
||||||
|
ch.solo = f[9] == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyToolboxRow(p: Project, line: String) {
|
||||||
|
val idx = line.substringBefore('=').toIntOrNull() ?: return
|
||||||
|
val rest = line.substringAfter('=')
|
||||||
|
val parts = rest.split(';')
|
||||||
|
if (parts.size < 2) return
|
||||||
|
val type = ToolboxType.entries.firstOrNull { it.name == parts[0] } ?: return
|
||||||
|
val slot = p.toolbox.getOrNull(idx) ?: return
|
||||||
|
slot.fill(type)
|
||||||
|
slot.name = parts[1]
|
||||||
|
if (parts.size >= 3 && parts[2].isNotBlank()) {
|
||||||
|
parts[2].split(',').forEach { kv ->
|
||||||
|
val e = kv.split('=', limit = 2)
|
||||||
|
if (e.size == 2) slot.set(e[0], e[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.io
|
||||||
|
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and writes the reference desktop **sizzletracker `.sng`** format
|
||||||
|
* (github.com/reactorcoremeltdown/sizzletracker) so songs interchange with the
|
||||||
|
* CLI version. It is line-oriented text:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* version 1
|
||||||
|
* bpm 120
|
||||||
|
* sig 4 4
|
||||||
|
*
|
||||||
|
* block A 16 4
|
||||||
|
* roll ####
|
||||||
|
* track T1 0
|
||||||
|
* 0 C-4 64 01
|
||||||
|
* 4 E-4 .. ..
|
||||||
|
* 8 OFF .. ..
|
||||||
|
* endblock
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The per-block `roll` (one char per beat, `#` = plays) maps directly onto our
|
||||||
|
* lane-per-block arrangement. Velocity is hexadecimal; `..` means "default"; a
|
||||||
|
* step channel of `..` inherits the track's declared channel. This format carries
|
||||||
|
* the musical data only — mixer/toolbox settings use our own [ProjectIo].
|
||||||
|
*/
|
||||||
|
object SngFormat {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- write
|
||||||
|
fun export(project: Project): String = buildString {
|
||||||
|
appendLine("version 1")
|
||||||
|
appendLine("bpm ${project.tempoBpm.toInt()}")
|
||||||
|
appendLine("sig ${project.timeSignature.beatsPerBar} 4")
|
||||||
|
appendLine()
|
||||||
|
|
||||||
|
val arr = project.arrangement
|
||||||
|
for (block in project.patterns) {
|
||||||
|
val rollUsed = (0 until arr.lengthBeats).any { arr.patternAt(block.id, it) != Arrangement.EMPTY }
|
||||||
|
val hasNotes = (0 until Pattern.TRACK_COUNT).any { t ->
|
||||||
|
(0 until block.length).any { !block.cell(t, it).isEmpty }
|
||||||
|
}
|
||||||
|
if (!rollUsed && !hasNotes) continue // skip untouched blocks
|
||||||
|
|
||||||
|
appendLine("block ${block.name} ${block.length} ${project.timeSignature.beatsPerBar}")
|
||||||
|
|
||||||
|
val roll = buildString {
|
||||||
|
for (beat in 0 until arr.lengthBeats) {
|
||||||
|
append(if (arr.patternAt(block.id, beat) != Arrangement.EMPTY) '#' else '.')
|
||||||
|
}
|
||||||
|
}.trimEnd('.')
|
||||||
|
if (roll.isNotEmpty()) appendLine("roll $roll")
|
||||||
|
|
||||||
|
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val steps = (0 until block.length).filter { !block.cell(t, it).isEmpty }
|
||||||
|
if (steps.isEmpty()) continue
|
||||||
|
val defChannel = (project.mixer.channels[t].midiChannel - 1).coerceAtLeast(0)
|
||||||
|
appendLine("track T${t + 1} $defChannel")
|
||||||
|
for (line in steps) {
|
||||||
|
val cell = block.cell(t, line)
|
||||||
|
val note = if (cell.isNoteOff) "OFF" else Pitch.name(cell.note)
|
||||||
|
val vel = cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||||
|
val chan = cell.channel.toString().padStart(2, '0')
|
||||||
|
appendLine("$line $note $vel $chan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendLine("endblock")
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- read
|
||||||
|
fun import(text: String): Project = importInto(Project(), text)
|
||||||
|
|
||||||
|
/** Parse `.sng` [text] into the existing [project] (resetting its content). */
|
||||||
|
fun importInto(project: Project, text: String): Project {
|
||||||
|
// Clear blocks and arrangement (keep the 8 lane-blocks, wipe their cells).
|
||||||
|
project.patterns.forEach { p -> for (t in 0 until Pattern.TRACK_COUNT) p.tracks[t].forEach { it.clear() } }
|
||||||
|
for (lane in project.arrangement.slots.indices) project.arrangement.slots[lane].fill(Arrangement.EMPTY)
|
||||||
|
|
||||||
|
var blockIndex = 0
|
||||||
|
var current: Pattern? = null
|
||||||
|
var track = 0
|
||||||
|
var trackChannel = 1
|
||||||
|
var maxRoll = 0
|
||||||
|
|
||||||
|
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||||
|
val tok = line.split(Regex("\\s+"))
|
||||||
|
when (tok[0]) {
|
||||||
|
"version" -> {}
|
||||||
|
"bpm" -> project.tempoBpm = tok.getOrNull(1)?.toFloatOrNull() ?: 120f
|
||||||
|
"sig" -> project.timeSignature = when (tok.getOrNull(1)?.toIntOrNull()) {
|
||||||
|
3 -> TimeSignature.THREE_FOUR
|
||||||
|
5 -> TimeSignature.FIVE_FOUR
|
||||||
|
else -> TimeSignature.FOUR_FOUR
|
||||||
|
}
|
||||||
|
"block" -> {
|
||||||
|
current = project.patterns.getOrNull(blockIndex)?.also { blk ->
|
||||||
|
tok.getOrNull(1)?.let { blk.name = it }
|
||||||
|
tok.getOrNull(2)?.toIntOrNull()?.let { blk.resize(it) }
|
||||||
|
}
|
||||||
|
blockIndex++
|
||||||
|
track = 0; trackChannel = 1
|
||||||
|
}
|
||||||
|
"roll" -> current?.let { blk ->
|
||||||
|
val roll = tok.getOrNull(1) ?: ""
|
||||||
|
roll.forEachIndexed { beat, ch -> if (ch == '#') project.arrangement.set(blk.id, beat, blk.id) }
|
||||||
|
maxRoll = maxOf(maxRoll, roll.length)
|
||||||
|
}
|
||||||
|
"track" -> {
|
||||||
|
track = tok.getOrNull(1)?.filter { it.isDigit() }?.toIntOrNull()?.minus(1)?.coerceIn(0, Pattern.TRACK_COUNT - 1) ?: 0
|
||||||
|
trackChannel = tok.getOrNull(2)?.toIntOrNull()?.plus(1)?.coerceIn(1, 16) ?: 1
|
||||||
|
}
|
||||||
|
"endblock" -> current = null
|
||||||
|
else -> applyStep(project, current, track, trackChannel, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxRoll > 0) project.arrangement.lengthBeats = maxOf(project.arrangement.lengthBeats, maxRoll)
|
||||||
|
project.activePatternId = 0
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyStep(project: Project, block: Pattern?, track: Int, trackChannel: Int, tok: List<String>) {
|
||||||
|
val blk = block ?: return
|
||||||
|
val tick = tok.getOrNull(0)?.toIntOrNull() ?: return
|
||||||
|
if (tick !in 0 until blk.length) return
|
||||||
|
val cell = blk.cell(track, tick)
|
||||||
|
cell.note = Pitch.parse(tok.getOrElse(1) { "..." })
|
||||||
|
cell.velocity = (tok.getOrNull(2)?.takeIf { it != ".." }?.toIntOrNull(16) ?: Cell.MAX_VELOCITY)
|
||||||
|
.coerceIn(0, Cell.MAX_VELOCITY)
|
||||||
|
cell.channel = (tok.getOrNull(3)?.takeIf { it != ".." }?.toIntOrNull() ?: trackChannel)
|
||||||
|
.coerceIn(1, Cell.MAX_CHANNEL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lower-half piano-roll arrangement. It has [LANE_COUNT] lanes stacked
|
||||||
|
* vertically and up to [MAX_BEATS] beats horizontally. Each slot holds the id of
|
||||||
|
* the [Pattern] to trigger on that beat (or [EMPTY]). Per the spec, one marked
|
||||||
|
* slot triggers exactly ONE beat of that pattern/block, even if the pattern is
|
||||||
|
* longer — so the arrangement grid resolution is one-beat-per-column.
|
||||||
|
*/
|
||||||
|
class Arrangement {
|
||||||
|
/** slots[lane][beat] = patternId, or [EMPTY]. */
|
||||||
|
val slots: Array<IntArray> = Array(LANE_COUNT) { IntArray(MAX_BEATS) { EMPTY } }
|
||||||
|
|
||||||
|
/** How many beats are actually in use; the roll can grow up to [MAX_BEATS]. */
|
||||||
|
var lengthBeats: Int = 64
|
||||||
|
|
||||||
|
/** Loop transport state (the toolbar under the roll). */
|
||||||
|
var loopEnabled: Boolean = false
|
||||||
|
val regionA: LoopRegion = LoopRegion()
|
||||||
|
val regionB: LoopRegion = LoopRegion()
|
||||||
|
|
||||||
|
fun patternAt(lane: Int, beat: Int): Int =
|
||||||
|
if (beat in 0 until MAX_BEATS) slots[lane][beat] else EMPTY
|
||||||
|
|
||||||
|
fun set(lane: Int, beat: Int, patternId: Int) {
|
||||||
|
if (lane in 0 until LANE_COUNT && beat in 0 until MAX_BEATS) {
|
||||||
|
slots[lane][beat] = patternId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LANE_COUNT = 8
|
||||||
|
const val MAX_BEATS = 256
|
||||||
|
const val EMPTY = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An A or B loop region on the arrangement roll. [start] and [end] are beat
|
||||||
|
* indices (inclusive start, exclusive end); [repeats] is how many times the
|
||||||
|
* region plays before control moves on. A region is only active when it has a
|
||||||
|
* non-empty span AND [enabled] is set (toggled by the A/B buttons).
|
||||||
|
*/
|
||||||
|
class LoopRegion(
|
||||||
|
var enabled: Boolean = false,
|
||||||
|
var start: Int = 0,
|
||||||
|
var end: Int = 0,
|
||||||
|
var repeats: Int = 2,
|
||||||
|
) {
|
||||||
|
val isValid: Boolean get() = enabled && end > start
|
||||||
|
val spanBeats: Int get() = (end - start).coerceAtLeast(0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mixer has exactly [CHANNEL_COUNT] channels, one per tracker track. Each
|
||||||
|
* channel routes through one instrument and up to four effects, all referenced
|
||||||
|
* by their slot index in the [Toolbox] (tab 3). A value of -1 means "no device".
|
||||||
|
*/
|
||||||
|
class Mixer {
|
||||||
|
val channels: List<MixerChannel> = List(CHANNEL_COUNT) { MixerChannel(it) }
|
||||||
|
|
||||||
|
/** True if ANY channel is soloed — used to mute the non-soloed ones. */
|
||||||
|
fun anySolo(): Boolean = channels.any { it.solo }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep
|
||||||
|
const val FX_SLOTS = 4
|
||||||
|
const val NO_DEVICE = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MixerChannel(
|
||||||
|
val index: Int,
|
||||||
|
/** Toolbox slot index of the instrument feeding this channel, or -1. */
|
||||||
|
var instrumentSlot: Int = Mixer.NO_DEVICE,
|
||||||
|
/** Toolbox slot indices of up to four insert effects, -1 where unused. */
|
||||||
|
val fxSlots: IntArray = IntArray(Mixer.FX_SLOTS) { Mixer.NO_DEVICE },
|
||||||
|
/** MIDI channel (1..16) this mixer channel listens/sends on. */
|
||||||
|
var midiChannel: Int = 1,
|
||||||
|
/** Linear 0..1 fader. */
|
||||||
|
var volume: Float = 0.8f,
|
||||||
|
var mute: Boolean = false,
|
||||||
|
var solo: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** Whether this channel should actually produce sound given the solo state. */
|
||||||
|
fun audible(anySolo: Boolean): Boolean = !mute && (!anySolo || solo)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Musical primitives shared across the whole app: pitches, scales and time
|
||||||
|
* signatures. Everything here is deliberately small, immutable and free of any
|
||||||
|
* Android dependency so it can be unit-tested and reasoned about in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A MIDI note number, 0..127, where 60 == middle C (C4 in the convention we use).
|
||||||
|
* We keep notes as plain Ints in the hot paths (patterns) for memory reasons and
|
||||||
|
* only wrap them when we need naming/formatting helpers.
|
||||||
|
*/
|
||||||
|
object Pitch {
|
||||||
|
const val LOWEST = 0
|
||||||
|
const val HIGHEST = 127
|
||||||
|
const val MIDDLE_C = 60
|
||||||
|
|
||||||
|
private val NAMES = arrayOf(
|
||||||
|
"C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Human/tracker-readable name, e.g. 60 -> "C-4", 61 -> "C#4". */
|
||||||
|
fun name(midi: Int): String {
|
||||||
|
if (midi !in LOWEST..HIGHEST) return "..."
|
||||||
|
val octave = midi / 12 - 1 // MIDI 12 == C0 in this convention
|
||||||
|
return NAMES[midi % 12] + octave
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of [name]: parse "C-4" / "C#4" / "OFF" / "..." into a note value.
|
||||||
|
* Returns [Cell.OFF] for a note-off, [Cell.EMPTY] for empty/unparseable input.
|
||||||
|
* Used by the sizzletracker `.sng` importer.
|
||||||
|
*/
|
||||||
|
fun parse(text: String): Int {
|
||||||
|
val t = text.trim()
|
||||||
|
if (t == "OFF" || t == "===") return Cell.OFF
|
||||||
|
if (t.length < 3 || t.startsWith(".")) return Cell.EMPTY
|
||||||
|
val idx = NAMES.indexOf(t.substring(0, 2))
|
||||||
|
if (idx < 0) return Cell.EMPTY
|
||||||
|
val octave = t.substring(2).toIntOrNull() ?: return Cell.EMPTY
|
||||||
|
return ((octave + 1) * 12 + idx).coerceIn(LOWEST, HIGHEST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three time signatures the tracker supports. The key idea in this app is
|
||||||
|
* that a *beat* is made of a fixed number of tracker *lines* (ticks), and that
|
||||||
|
* number is what changes between signatures:
|
||||||
|
* - 3/4 -> 3 lines per beat
|
||||||
|
* - 4/4 -> 4 lines per beat
|
||||||
|
* - 5/4 -> 5 lines per beat
|
||||||
|
* A *bar* is always [beatsPerBar] beats long (here, equal to the numerator).
|
||||||
|
*/
|
||||||
|
enum class TimeSignature(
|
||||||
|
val label: String,
|
||||||
|
val linesPerBeat: Int,
|
||||||
|
val beatsPerBar: Int,
|
||||||
|
/** Allowed pattern lengths (in lines) offered in the length dropdown. */
|
||||||
|
val lengthOptions: List<Int>,
|
||||||
|
) {
|
||||||
|
THREE_FOUR("3/4", linesPerBeat = 3, beatsPerBar = 3, lengthOptions = listOf(12, 24, 48)),
|
||||||
|
FOUR_FOUR("4/4", linesPerBeat = 4, beatsPerBar = 4, lengthOptions = listOf(16, 32, 64)),
|
||||||
|
FIVE_FOUR("5/4", linesPerBeat = 5, beatsPerBar = 5, lengthOptions = listOf(20, 40, 80));
|
||||||
|
|
||||||
|
/** Lines per bar = lines-per-beat * beats-per-bar. */
|
||||||
|
val linesPerBar: Int get() = linesPerBeat * beatsPerBar
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A musical scale expressed as semitone offsets from the root (0..11).
|
||||||
|
* Used by the tracker's note stepping (keyboard/gamepad +/- and the touch editor)
|
||||||
|
* so the user can only land on in-key notes when a non-chromatic scale is selected.
|
||||||
|
*/
|
||||||
|
enum class Scale(val label: String, val intervals: List<Int>) {
|
||||||
|
CHROMATIC("Chromatic", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)),
|
||||||
|
MAJOR("Major", listOf(0, 2, 4, 5, 7, 9, 11)),
|
||||||
|
MINOR("Natural Minor", listOf(0, 2, 3, 5, 7, 8, 10)),
|
||||||
|
HARMONIC_MINOR("Harmonic Minor", listOf(0, 2, 3, 5, 7, 8, 11)),
|
||||||
|
DORIAN("Dorian", listOf(0, 2, 3, 5, 7, 9, 10)),
|
||||||
|
PHRYGIAN("Phrygian", listOf(0, 1, 3, 5, 7, 8, 10)),
|
||||||
|
MIXOLYDIAN("Mixolydian", listOf(0, 2, 4, 5, 7, 9, 10)),
|
||||||
|
PENTATONIC_MAJOR("Pentatonic Major", listOf(0, 2, 4, 7, 9)),
|
||||||
|
PENTATONIC_MINOR("Pentatonic Minor", listOf(0, 3, 5, 7, 10)),
|
||||||
|
BLUES("Blues", listOf(0, 3, 5, 6, 7, 10));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next in-scale MIDI note above [fromMidi] when [direction] is
|
||||||
|
* +1, or below when -1, given a scale [root] (0=C .. 11=B). If [fromMidi] is
|
||||||
|
* off-scale we snap to the nearest in-scale note first. This is the single
|
||||||
|
* function that powers the tracker's note stepping (keyboard/gamepad + editor).
|
||||||
|
*/
|
||||||
|
fun step(fromMidi: Int, root: Int, direction: Int): Int {
|
||||||
|
if (this == CHROMATIC) {
|
||||||
|
return (fromMidi + direction).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
|
}
|
||||||
|
// Build the set of pitch-classes that are in-key.
|
||||||
|
val inKey = intervals.map { (it + root) % 12 }.toSortedSet()
|
||||||
|
var candidate = fromMidi
|
||||||
|
// Walk one semitone at a time until we hit an in-key pitch-class.
|
||||||
|
do {
|
||||||
|
candidate += direction
|
||||||
|
if (candidate !in Pitch.LOWEST..Pitch.HIGHEST) {
|
||||||
|
return fromMidi // hit the edge, don't move
|
||||||
|
}
|
||||||
|
} while ((candidate % 12) !in inKey)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One cell in the tracker grid: a single note event on a single line of a single
|
||||||
|
* track. All three fields map directly to the three on-screen columns:
|
||||||
|
* - [note] : MIDI note number, or [EMPTY], or [OFF] (note-off).
|
||||||
|
* - [velocity] : 0..127 (MIDI range), shown to the user as two hex digits (00..7F).
|
||||||
|
* - [channel] : MIDI channel 1..16, shown as a decimal.
|
||||||
|
*
|
||||||
|
* We use plain mutable fields (not a `data class` copy) because the tracker grid
|
||||||
|
* is edited cell-by-cell in tight gestures and we want to avoid allocating a new
|
||||||
|
* object on every drag tick.
|
||||||
|
*/
|
||||||
|
class Cell(
|
||||||
|
var note: Int = EMPTY,
|
||||||
|
var velocity: Int = MAX_VELOCITY, // default to full velocity so new notes are audible
|
||||||
|
var channel: Int = 1,
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = note == EMPTY
|
||||||
|
val isNoteOff: Boolean get() = note == OFF
|
||||||
|
val isPlayable: Boolean get() = note in Pitch.LOWEST..Pitch.HIGHEST
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
note = EMPTY
|
||||||
|
velocity = MAX_VELOCITY
|
||||||
|
channel = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A detached copy (for the clipboard) — never aliases the source cell. */
|
||||||
|
fun copyOf(): Cell = Cell(note, velocity, channel)
|
||||||
|
|
||||||
|
/** Overwrite this cell's fields from [other] (paste). */
|
||||||
|
fun setFrom(other: Cell) {
|
||||||
|
note = other.note
|
||||||
|
velocity = other.velocity
|
||||||
|
channel = other.channel
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EMPTY = -1
|
||||||
|
const val OFF = -2
|
||||||
|
/** MIDI velocity ceiling (0x7F). Values are clamped to 0..[MAX_VELOCITY]. */
|
||||||
|
const val MAX_VELOCITY = 0x7F
|
||||||
|
/** MIDI channel ceiling. Channels run 1..[MAX_CHANNEL]. */
|
||||||
|
const val MAX_CHANNEL = 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The four editable columns per track. Only NOTE is pitch; the rest are values. */
|
||||||
|
enum class CellColumn { NOTE, VELOCITY, CHANNEL }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pattern (a "block" in arrangement terms): [TRACK_COUNT] parallel tracks, each
|
||||||
|
* a vertical column of [length] cells. Length is one of the current time
|
||||||
|
* signature's allowed values (see [TimeSignature.lengthOptions]).
|
||||||
|
*
|
||||||
|
* The grid is stored as `tracks[trackIndex][lineIndex]`.
|
||||||
|
*/
|
||||||
|
class Pattern(
|
||||||
|
val id: Int,
|
||||||
|
var name: String = "PTN",
|
||||||
|
var length: Int = 16,
|
||||||
|
) {
|
||||||
|
/** tracks[t][line]. Always [TRACK_COUNT] tracks; each list has [length] cells. */
|
||||||
|
val tracks: Array<MutableList<Cell>> =
|
||||||
|
Array(TRACK_COUNT) { MutableList(length) { Cell() } }
|
||||||
|
|
||||||
|
fun cell(track: Int, line: Int): Cell = tracks[track][line]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the pattern to [newLength], preserving existing cells where possible.
|
||||||
|
* Called when the length dropdown or the time signature changes.
|
||||||
|
*/
|
||||||
|
fun resize(newLength: Int) {
|
||||||
|
if (newLength == length) return
|
||||||
|
for (t in 0 until TRACK_COUNT) {
|
||||||
|
val col = tracks[t]
|
||||||
|
when {
|
||||||
|
newLength > col.size -> repeat(newLength - col.size) { col.add(Cell()) }
|
||||||
|
newLength < col.size -> while (col.size > newLength) col.removeAt(col.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length = newLength
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Fixed by the spec: the tracker upper half always has four tracks. */
|
||||||
|
const val TRACK_COUNT = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole song in memory. This single object is what gets saved to / loaded
|
||||||
|
* from the `.sng` file format and what the audio engine reads while playing.
|
||||||
|
* Keeping one root object makes serialization and undo/redo straightforward.
|
||||||
|
*
|
||||||
|
* Persistence is done by our own text serializer (see
|
||||||
|
* [com.reactorcoremeltdown.sizzletracker.io.ProjectIo]), not kotlinx, so the
|
||||||
|
* model classes carry no serialization annotations.
|
||||||
|
*/
|
||||||
|
class Project {
|
||||||
|
var name: String = "untitled"
|
||||||
|
|
||||||
|
// ----- Global transport -----
|
||||||
|
var tempoBpm: Float = 120f
|
||||||
|
var timeSignature: TimeSignature = TimeSignature.FOUR_FOUR
|
||||||
|
var scale: Scale = Scale.CHROMATIC
|
||||||
|
/** Root note as a pitch-class 0..11 (0 = C). Used with [scale] for entry. */
|
||||||
|
var rootNote: Int = 0
|
||||||
|
|
||||||
|
// ----- Content -----
|
||||||
|
/**
|
||||||
|
* One pattern/block per arrangement lane. Lane i is tied to block i (the
|
||||||
|
* "sizzletracker CLI" model): enabling a cell on arrangement lane i schedules
|
||||||
|
* block i to play when the playhead reaches that beat. Block id == lane index.
|
||||||
|
*/
|
||||||
|
val patterns: MutableList<Pattern> = MutableList(Arrangement.LANE_COUNT) {
|
||||||
|
// Default to the shortest length for the signature: 16 ticks for 4/4.
|
||||||
|
Pattern(id = it, name = "BLK${it + 1}", length = timeSignature.lengthOptions[0])
|
||||||
|
}
|
||||||
|
val arrangement: Arrangement = Arrangement()
|
||||||
|
val mixer: Mixer = Mixer()
|
||||||
|
/** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */
|
||||||
|
val toolbox: MutableList<ToolboxSlot> = MutableList(TOOLBOX_SLOTS) { ToolboxSlot(it) }
|
||||||
|
|
||||||
|
fun pattern(id: Int): Pattern? = patterns.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
/** Which pattern the tracker tab is currently editing. */
|
||||||
|
var activePatternId: Int = 0
|
||||||
|
|
||||||
|
fun activePattern(): Pattern = pattern(activePatternId) ?: patterns.first()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TOOLBOX_SLOTS = 16
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The third tab is a toolbox of 16 slots. Every slot can hold one instrument OR
|
||||||
|
* one effect. Rather than a separate class per device (which would be a lot of
|
||||||
|
* near-identical code for a newcomer to wade through), we describe each device
|
||||||
|
* type with [ToolboxType] and store its settings as a simple
|
||||||
|
* name -> value map. That map is exactly what we serialize to a human-readable
|
||||||
|
* preset, and it is what the audio engine reads at render time.
|
||||||
|
*
|
||||||
|
* When you add a new instrument or effect you only need to:
|
||||||
|
* 1. add an entry to [ToolboxType] with its default parameters, and
|
||||||
|
* 2. teach the audio engine how to interpret those parameters.
|
||||||
|
* The UI (a generic parameter editor) and preset save/load work automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
enum class ToolboxKind { INSTRUMENT, EFFECT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single tunable parameter's description: its key, a friendly label, the value
|
||||||
|
* range and a default. [choices] being non-empty marks an enumerated parameter
|
||||||
|
* (rendered as a dropdown, e.g. a waveform selector) instead of a slider.
|
||||||
|
*/
|
||||||
|
data class ParamSpec(
|
||||||
|
val key: String,
|
||||||
|
val label: String,
|
||||||
|
val default: Float = 0f,
|
||||||
|
val min: Float = 0f,
|
||||||
|
val max: Float = 1f,
|
||||||
|
val choices: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
val isEnum: Boolean get() = choices.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ToolboxType(
|
||||||
|
val kind: ToolboxKind,
|
||||||
|
val displayName: String,
|
||||||
|
val params: List<ParamSpec>,
|
||||||
|
) {
|
||||||
|
// ---------- Instruments ----------
|
||||||
|
NES_SYNTH(
|
||||||
|
ToolboxKind.INSTRUMENT, "NES Synth",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("wave", "Wave", choices = listOf("Pulse12", "Pulse25", "Pulse50", "Triangle", "Noise")),
|
||||||
|
ParamSpec("attack", "Attack", 0.01f, 0f, 1f),
|
||||||
|
ParamSpec("decay", "Decay", 0.15f, 0f, 1f),
|
||||||
|
ParamSpec("sustain", "Sustain", 0.6f, 0f, 1f),
|
||||||
|
ParamSpec("release", "Release", 0.1f, 0f, 1f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SAMPLER(
|
||||||
|
ToolboxKind.INSTRUMENT, "Sampler",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("startOctave", "Start Oct", 3f, 0f, 8f),
|
||||||
|
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
||||||
|
ParamSpec("sliceStart", "Slice Start", 0f, 0f, 1f),
|
||||||
|
ParamSpec("sliceEnd", "Slice End", 1f, 0f, 1f),
|
||||||
|
// "samplePath" is stored as a string param at runtime (see ToolboxSlot).
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SOUNDFONT(
|
||||||
|
ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("program", "Program", 0f, 0f, 127f),
|
||||||
|
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ---------- Effects ----------
|
||||||
|
ARPEGGIATOR(
|
||||||
|
ToolboxKind.EFFECT, "MIDI Arpeggiator",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
|
||||||
|
ParamSpec("octaves", "Octaves", 1f, 1f, 4f),
|
||||||
|
ParamSpec("division", "Division", 3f, 0f, 6f), // index into TimeDivision
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TRANSPOSER(
|
||||||
|
ToolboxKind.EFFECT, "MIDI Transposer",
|
||||||
|
listOf(ParamSpec("semitones", "Semitones", 0f, -24f, 24f)),
|
||||||
|
),
|
||||||
|
LFO(
|
||||||
|
ToolboxKind.EFFECT, "MIDI LFO",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
|
||||||
|
ParamSpec("phase", "Phase", 0f, 0f, 1f),
|
||||||
|
ParamSpec("depth", "Depth", 0.5f, 0f, 1f),
|
||||||
|
ParamSpec("division", "Division", 3f, 0f, 6f),
|
||||||
|
// "target" (which parameter to modulate) is stored as a string param.
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DELAY(
|
||||||
|
ToolboxKind.EFFECT, "Tape Delay",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("division", "Division", 3f, 0f, 6f),
|
||||||
|
ParamSpec("feedback", "Feedback", 0.4f, 0f, 0.95f),
|
||||||
|
ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f),
|
||||||
|
ParamSpec("heads", "Tape Heads", 4f, 1f, 4f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
REVERB(
|
||||||
|
ToolboxKind.EFFECT, "Digital Reverb",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("amount", "Amount", 0.3f, 0f, 1f),
|
||||||
|
ParamSpec("tone", "Tone", 0.5f, 0f, 1f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FILTER(
|
||||||
|
ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
|
||||||
|
ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
|
||||||
|
ParamSpec("resonance", "Resonance", 0.2f, 0f, 1f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
EQ10(
|
||||||
|
ToolboxKind.EFFECT, "10-Band EQ",
|
||||||
|
// 10 gain bands, each -1..+1 mapping to roughly -12..+12 dB.
|
||||||
|
(0 until 10).map { ParamSpec("band$it", "Band ${it + 1}", 0f, -1f, 1f) },
|
||||||
|
),
|
||||||
|
BITCRUSHER(
|
||||||
|
ToolboxKind.EFFECT, "Bitcrusher",
|
||||||
|
listOf(
|
||||||
|
ParamSpec("bits", "Bits", 8f, 1f, 16f),
|
||||||
|
ParamSpec("downsample", "Downsample", 1f, 1f, 32f),
|
||||||
|
ParamSpec("drive", "Drive", 0.2f, 0f, 1f),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
|
||||||
|
|
||||||
|
/** Fresh default parameter map for a new instance of this type. */
|
||||||
|
fun defaultValues(): MutableMap<String, String> =
|
||||||
|
params.associate { spec ->
|
||||||
|
spec.key to if (spec.isEnum) spec.choices[spec.default.toInt().coerceIn(0, spec.choices.lastIndex)]
|
||||||
|
else spec.default.toString()
|
||||||
|
}.toMutableMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tempo-synced note lengths used by arpeggiator / LFO / delay "division" params. */
|
||||||
|
enum class TimeDivision(val label: String, val beatFraction: Double) {
|
||||||
|
WHOLE("1/1", 4.0), HALF("1/2", 2.0), QUARTER("1/4", 1.0),
|
||||||
|
EIGHTH("1/8", 0.5), SIXTEENTH("1/16", 0.25),
|
||||||
|
THIRTYSECOND("1/32", 0.125), DOTTED_EIGHTH("1/8.", 0.75);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromIndex(i: Int): TimeDivision = entries[i.coerceIn(0, entries.lastIndex)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occupied (or empty) toolbox slot. [type] == null means the slot is empty.
|
||||||
|
* [values] holds every parameter as a string so the same structure serializes
|
||||||
|
* cleanly to text and round-trips without loss. String-only params such as a
|
||||||
|
* sample file path live in the same map under keys the engine knows about
|
||||||
|
* (e.g. "samplePath", "target").
|
||||||
|
*
|
||||||
|
* (Serialized via [com.reactorcoremeltdown.sizzletracker.io.PresetIo], not
|
||||||
|
* kotlinx, so no @Serializable annotation is needed here.)
|
||||||
|
*/
|
||||||
|
class ToolboxSlot(
|
||||||
|
val index: Int,
|
||||||
|
var type: ToolboxType? = null,
|
||||||
|
var name: String = "",
|
||||||
|
val values: MutableMap<String, String> = mutableMapOf(),
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = type == null
|
||||||
|
|
||||||
|
fun fill(newType: ToolboxType) {
|
||||||
|
type = newType
|
||||||
|
name = newType.displayName
|
||||||
|
values.clear()
|
||||||
|
values.putAll(newType.defaultValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSlot() {
|
||||||
|
type = null
|
||||||
|
name = ""
|
||||||
|
values.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed accessors used by the audio engine and UI.
|
||||||
|
fun float(key: String, fallback: Float = 0f): Float = values[key]?.toFloatOrNull() ?: fallback
|
||||||
|
fun string(key: String, fallback: String = ""): String = values[key] ?: fallback
|
||||||
|
fun set(key: String, value: Float) { values[key] = value.toString() }
|
||||||
|
fun set(key: String, value: String) { values[key] = value }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.playback
|
||||||
|
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.SimpleBasePlayer
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts our custom [AudioEngine] to the media3 [Player] interface via
|
||||||
|
* [SimpleBasePlayer], so a [androidx.media3.session.MediaSession] can expose the
|
||||||
|
* transport to the system: lock-screen controls, Bluetooth/headset media buttons,
|
||||||
|
* and the media notification. We only implement play/pause/stop — the tracker
|
||||||
|
* itself is the real transport; this is the "remote control" surface.
|
||||||
|
*
|
||||||
|
* Call [refresh] whenever the engine's transport changes so the reported state
|
||||||
|
* (and therefore the notification) stays in sync with on-screen play/pause.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
class EnginePlayer(private val engine: AudioEngine) : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||||
|
|
||||||
|
private val mediaItem = MediaItem.Builder()
|
||||||
|
.setMediaId("sizzletracker-song")
|
||||||
|
.setMediaMetadata(
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle("Sizzletracker")
|
||||||
|
.setArtist("Song")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun getState(): State {
|
||||||
|
val playing = engine.transport.value.isPlaying
|
||||||
|
return State.Builder()
|
||||||
|
.setAvailableCommands(
|
||||||
|
Player.Commands.Builder()
|
||||||
|
.addAll(
|
||||||
|
Player.COMMAND_PLAY_PAUSE,
|
||||||
|
Player.COMMAND_STOP,
|
||||||
|
Player.COMMAND_PREPARE,
|
||||||
|
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||||
|
Player.COMMAND_GET_METADATA,
|
||||||
|
Player.COMMAND_GET_TIMELINE,
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setPlaybackState(Player.STATE_READY)
|
||||||
|
.setPlayWhenReady(playing, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
||||||
|
.setPlaylist(
|
||||||
|
listOf(
|
||||||
|
MediaItemData.Builder("sizzletracker-song")
|
||||||
|
.setMediaItem(mediaItem)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish the latest engine state to any connected media controllers. */
|
||||||
|
fun refresh() = invalidateState()
|
||||||
|
|
||||||
|
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||||
|
if (playWhenReady) engine.play() else engine.pause()
|
||||||
|
invalidateState()
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||||
|
|
||||||
|
override fun handleStop(): ListenableFuture<*> {
|
||||||
|
engine.stop()
|
||||||
|
invalidateState()
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.SizzleApp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A media3 [MediaSessionService] that owns the audio engine's lifecycle and hosts
|
||||||
|
* a [MediaSession] wrapping it (via [EnginePlayer]). media3 automatically provides
|
||||||
|
* the media notification, lock-screen controls, and Bluetooth/headset media-button
|
||||||
|
* handling from the session — so the same transport the on-screen buttons drive is
|
||||||
|
* also controllable from outside the app, and playback continues in the background.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
class PlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
|
private val engine get() = (application as SizzleApp).audioEngine
|
||||||
|
private var session: MediaSession? = null
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
engine.start()
|
||||||
|
val player = EnginePlayer(engine)
|
||||||
|
session = MediaSession.Builder(this, player).build()
|
||||||
|
// Keep the session's reported state in sync with the engine (the on-screen
|
||||||
|
// Play/Pause talks to the engine directly, so we mirror it here).
|
||||||
|
scope.launch { engine.transport.collect { player.refresh() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
session?.run { player.release(); release() }
|
||||||
|
session = null
|
||||||
|
engine.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Start the service from a foreground context (safe: plain startService,
|
||||||
|
* MediaSessionService promotes itself to foreground when playback begins). */
|
||||||
|
fun start(context: Context) {
|
||||||
|
context.startService(Intent(context, PlaybackService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.GridView
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material.icons.filled.Widgets
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.mixer.MixerScreen
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.settings.SettingsScreen
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.toolbox.ToolboxScreen
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.tracker.TrackerScreen
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-level layout: the active tab's screen fills the space above a standard
|
||||||
|
* Material 3 [NavigationBar]. The pattern grid / mixer still use the bespoke retro
|
||||||
|
* look, but chrome (this bar and the Settings tab) uses stock Material components
|
||||||
|
* for familiar, accessible interaction.
|
||||||
|
*/
|
||||||
|
private data class TabSpec(val title: String, val icon: ImageVector)
|
||||||
|
|
||||||
|
private val TABS = listOf(
|
||||||
|
TabSpec("Tracker", Icons.Filled.GridView),
|
||||||
|
TabSpec("Mix", Icons.Filled.Tune),
|
||||||
|
TabSpec("Toolbox", Icons.Filled.Widgets),
|
||||||
|
TabSpec("Setup", Icons.Filled.Settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SizzleApp(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
|
// ----- Active screen -----
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
when (vm.selectedTab) {
|
||||||
|
0 -> TrackerScreen(vm)
|
||||||
|
1 -> MixerScreen(vm)
|
||||||
|
2 -> ToolboxScreen(vm)
|
||||||
|
3 -> SettingsScreen(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ----- Standard Material navigation bar -----
|
||||||
|
NavigationBar(containerColor = c.surface) {
|
||||||
|
TABS.forEachIndexed { index, tab ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = vm.selectedTab == index,
|
||||||
|
onClick = { vm.selectTab(index) },
|
||||||
|
icon = { Icon(tab.icon, contentDescription = tab.title) },
|
||||||
|
label = { Text(tab.title) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = c.background,
|
||||||
|
selectedTextColor = c.accent,
|
||||||
|
indicatorColor = c.accent,
|
||||||
|
unselectedIconColor = c.textDim,
|
||||||
|
unselectedTextColor = c.textDim,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.InputAction
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.CellColumn
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single screen-facing state holder. It sits between the UI (Compose) and
|
||||||
|
* the model/engine, and it is the ONE place that reacts to [InputAction]s. Touch
|
||||||
|
* gestures call its methods directly; keyboard/gamepad/MIDI reach it through the
|
||||||
|
* [InputRouter] flow collected in [init]. Either way the same code runs, which is
|
||||||
|
* how all four input methods stay equally capable.
|
||||||
|
*
|
||||||
|
* Recomposition trick: the model classes are plain (non-observable) for audio
|
||||||
|
* performance, so after we mutate the grid we bump [revision]. Compose reads that
|
||||||
|
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
|
||||||
|
*/
|
||||||
|
class AppViewModel(
|
||||||
|
val project: Project,
|
||||||
|
private val engine: AudioEngine,
|
||||||
|
private val router: InputRouter,
|
||||||
|
private val gamepadInput: com.reactorcoremeltdown.sizzletracker.input.GamepadInput,
|
||||||
|
private val midiInput: com.reactorcoremeltdown.sizzletracker.input.MidiInput,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// ----- Observable UI state -----
|
||||||
|
var selectedTab by mutableIntStateOf(0); private set
|
||||||
|
var revision by mutableIntStateOf(0); private set // bump to force grid redraw
|
||||||
|
/** Active colour theme; read by [MainActivity] to drive SizzleTheme. */
|
||||||
|
var palette by mutableStateOf(com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.AMBER)
|
||||||
|
|
||||||
|
// Tracker cursor position.
|
||||||
|
var cursorTrack by mutableIntStateOf(0); private set
|
||||||
|
var cursorLine by mutableIntStateOf(0); private set
|
||||||
|
var cursorColumn by mutableStateOf(CellColumn.NOTE); private set
|
||||||
|
|
||||||
|
// ----- Selection & clipboard (tracker edit ops) -----
|
||||||
|
/** When on, the cursor drags out a rectangular selection from [selAnchorTrack]/
|
||||||
|
* [selAnchorLine] to the live cursor; edit ops act on that rectangle. */
|
||||||
|
var selectMode by mutableStateOf(false); private set
|
||||||
|
var selAnchorTrack by mutableIntStateOf(-1); private set
|
||||||
|
var selAnchorLine by mutableIntStateOf(-1); private set
|
||||||
|
/** Copied cells, indexed [trackOffset][lineOffset]; null until first copy/cut. */
|
||||||
|
private var clipboard: Array<Array<Cell>>? = null
|
||||||
|
val hasSelection: Boolean get() = selectMode && selAnchorTrack >= 0
|
||||||
|
val canPaste: Boolean get() = clipboard != null
|
||||||
|
|
||||||
|
// ----- Entry memory -----
|
||||||
|
/** The last note & channel actually entered (by any input). Keyboard/gamepad
|
||||||
|
* edits on an empty tick resume from these so entry continues where you left
|
||||||
|
* off instead of jumping to a fixed default. */
|
||||||
|
var lastNote by mutableIntStateOf(Pitch.MIDDLE_C); private set
|
||||||
|
var lastChannel by mutableIntStateOf(1); private set
|
||||||
|
|
||||||
|
/** Live transport snapshot from the audio thread (drives the playhead). */
|
||||||
|
val transport get() = engine.transport
|
||||||
|
|
||||||
|
// ----- Binding-learn state (Settings tab) -----
|
||||||
|
/** The action id currently waiting to be bound, or null. */
|
||||||
|
var learnTarget by mutableStateOf<String?>(null); private set
|
||||||
|
private var learnSource: com.reactorcoremeltdown.sizzletracker.input.InputSource? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Collect neutral input actions from every non-touch device.
|
||||||
|
viewModelScope.launch {
|
||||||
|
router.actions.collect(::onAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- tab control
|
||||||
|
fun selectTab(index: Int) { selectedTab = index.coerceIn(0, 3) }
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- cursor & edit
|
||||||
|
fun focusCell(track: Int, line: Int, column: CellColumn) {
|
||||||
|
val p = project.activePattern()
|
||||||
|
cursorTrack = track.coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||||
|
cursorLine = line.coerceIn(0, p.length - 1)
|
||||||
|
cursorColumn = column
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun focusedCell(): Cell =
|
||||||
|
project.activePattern().cell(cursorTrack, cursorLine)
|
||||||
|
|
||||||
|
fun moveCursor(dTrack: Int, dLine: Int) {
|
||||||
|
val p = project.activePattern()
|
||||||
|
cursorTrack = (cursorTrack + dTrack).coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||||
|
cursorLine = (cursorLine + dLine).coerceIn(0, p.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextColumn() { cursorColumn = nextOf(cursorColumn, +1) }
|
||||||
|
fun prevColumn() { cursorColumn = nextOf(cursorColumn, -1) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core value-editing operation, shared by drag gestures and +/- input.
|
||||||
|
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
||||||
|
*/
|
||||||
|
fun editFocused(direction: Int) {
|
||||||
|
val cell = focusedCell()
|
||||||
|
when (cursorColumn) {
|
||||||
|
CellColumn.NOTE -> {
|
||||||
|
// Seed an empty tick from the last note entered so filling resumes
|
||||||
|
// where you left off rather than at a fixed default.
|
||||||
|
val seed = if (cell.isPlayable) cell.note else lastNote
|
||||||
|
cell.note = project.scale.step(seed, project.rootNote, direction)
|
||||||
|
lastNote = cell.note
|
||||||
|
}
|
||||||
|
CellColumn.VELOCITY -> cell.velocity = (cell.velocity + direction).coerceIn(0, Cell.MAX_VELOCITY)
|
||||||
|
CellColumn.CHANNEL -> {
|
||||||
|
// Likewise seed an empty tick's channel from the last one entered.
|
||||||
|
val seed = if (cell.isEmpty) lastChannel else cell.channel
|
||||||
|
cell.channel = (seed + direction).coerceIn(1, Cell.MAX_CHANNEL)
|
||||||
|
lastChannel = cell.channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFocused() { focusedCell().clear(); touched() }
|
||||||
|
fun noteOffFocused() { focusedCell().note = Cell.OFF; touched() }
|
||||||
|
|
||||||
|
/** The cell under the cursor — for editor popups to read/display live values. */
|
||||||
|
fun cellUnderCursor(): Cell = project.activePattern().cell(cursorTrack, cursorLine)
|
||||||
|
|
||||||
|
/** Set the focused cell's note directly (piano popup / live entry). Remembers
|
||||||
|
* it as the last note so subsequent nudges resume from here. */
|
||||||
|
fun setFocusedNote(midi: Int) {
|
||||||
|
val n = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
|
focusedCell().note = n
|
||||||
|
lastNote = n
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the focused cell's channel directly, remembering it. */
|
||||||
|
fun setFocusedChannel(channel: Int) {
|
||||||
|
val ch = channel.coerceIn(1, Cell.MAX_CHANNEL)
|
||||||
|
focusedCell().channel = ch
|
||||||
|
lastChannel = ch
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------- selection & clipboard
|
||||||
|
/** The selected track range (inclusive). Anchor..cursor, or just the cursor
|
||||||
|
* cell when there is no active selection. */
|
||||||
|
val selTrackRange: IntRange
|
||||||
|
get() = if (hasSelection) minOf(selAnchorTrack, cursorTrack)..maxOf(selAnchorTrack, cursorTrack)
|
||||||
|
else cursorTrack..cursorTrack
|
||||||
|
val selLineRange: IntRange
|
||||||
|
get() = if (hasSelection) minOf(selAnchorLine, cursorLine)..maxOf(selAnchorLine, cursorLine)
|
||||||
|
else cursorLine..cursorLine
|
||||||
|
|
||||||
|
/** Toggle select mode. Turning it on anchors the selection at the cursor;
|
||||||
|
* turning it off clears the selection (the clipboard is untouched). */
|
||||||
|
fun toggleSelectMode() {
|
||||||
|
selectMode = !selectMode
|
||||||
|
if (selectMode) { selAnchorTrack = cursorTrack; selAnchorLine = cursorLine }
|
||||||
|
else { selAnchorTrack = -1; selAnchorLine = -1 }
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy the selection (or, with none, the focused cell) into the clipboard. */
|
||||||
|
fun copySelection() {
|
||||||
|
val p = project.activePattern()
|
||||||
|
val tr = selTrackRange; val lr = selLineRange
|
||||||
|
clipboard = Array(tr.count()) { ti ->
|
||||||
|
Array(lr.count()) { li -> p.cell(tr.first + ti, lr.first + li).copyOf() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the selected cells (or the focused cell when nothing is selected). */
|
||||||
|
fun deleteSelection() {
|
||||||
|
val p = project.activePattern()
|
||||||
|
for (t in selTrackRange) for (l in selLineRange) p.cell(t, l).clear()
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy then clear — a standard cut. */
|
||||||
|
fun cutSelection() { copySelection(); deleteSelection() }
|
||||||
|
|
||||||
|
/** Paste the clipboard with its top-left at the cursor, clipped to the grid. */
|
||||||
|
fun pasteClipboard() {
|
||||||
|
val cb = clipboard ?: return
|
||||||
|
val p = project.activePattern()
|
||||||
|
for (ti in cb.indices) for (li in cb[ti].indices) {
|
||||||
|
val t = cursorTrack + ti
|
||||||
|
val l = cursorLine + li
|
||||||
|
if (t < Pattern.TRACK_COUNT && l < p.length) p.cell(t, l).setFrom(cb[ti][li])
|
||||||
|
}
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which block (0..7) the tracker is editing. Lane i is tied to block i. */
|
||||||
|
val activeBlock: Int get() = project.activePatternId
|
||||||
|
fun setActiveBlock(i: Int) {
|
||||||
|
project.activePatternId = i.coerceIn(0, project.patterns.size - 1)
|
||||||
|
cursorLine = cursorLine.coerceIn(0, project.activePattern().length - 1)
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- transport control
|
||||||
|
fun playPause() {
|
||||||
|
val t = transport.value
|
||||||
|
if (t.isPlaying) engine.pause() else engine.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() = engine.stop()
|
||||||
|
|
||||||
|
// --------------------------------------------- project-level setting edits
|
||||||
|
fun setTempo(bpm: Float) { project.tempoBpm = bpm.coerceIn(20f, 300f); touched() }
|
||||||
|
|
||||||
|
fun setTimeSignature(sig: TimeSignature) {
|
||||||
|
project.timeSignature = sig
|
||||||
|
// Snap pattern length to the shortest option for the new signature.
|
||||||
|
val p = project.activePattern()
|
||||||
|
if (p.length !in sig.lengthOptions) p.resize(sig.lengthOptions[0])
|
||||||
|
cursorLine = cursorLine.coerceIn(0, p.length - 1)
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPatternLength(len: Int) {
|
||||||
|
project.activePattern().resize(len)
|
||||||
|
cursorLine = cursorLine.coerceIn(0, len - 1)
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------- input action router
|
||||||
|
/** The single dispatch point for keyboard / gamepad / MIDI actions. */
|
||||||
|
private fun onAction(action: InputAction) {
|
||||||
|
when (action) {
|
||||||
|
InputAction.NavUp -> moveCursor(0, -1)
|
||||||
|
InputAction.NavDown -> moveCursor(0, +1)
|
||||||
|
InputAction.NavLeft -> moveCursor(-1, 0)
|
||||||
|
InputAction.NavRight -> moveCursor(+1, 0)
|
||||||
|
InputAction.NextColumn -> nextColumn()
|
||||||
|
InputAction.PrevColumn -> prevColumn()
|
||||||
|
is InputAction.Increment -> editFocused(action.amount)
|
||||||
|
is InputAction.Decrement -> editFocused(-action.amount)
|
||||||
|
InputAction.ClearCell -> clearFocused()
|
||||||
|
InputAction.NoteOffCell -> noteOffFocused()
|
||||||
|
is InputAction.NoteOn -> {
|
||||||
|
engine.liveNoteOn(action.pitch, action.velocity)
|
||||||
|
// Live entry also writes the note into the focused NOTE cell.
|
||||||
|
if (cursorColumn == CellColumn.NOTE) {
|
||||||
|
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is InputAction.NoteOff -> engine.liveNoteOff(action.pitch)
|
||||||
|
InputAction.PlayPause -> playPause()
|
||||||
|
InputAction.Stop -> stop()
|
||||||
|
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
|
||||||
|
is InputAction.SelectTab -> selectTab(action.index)
|
||||||
|
InputAction.NextTab -> selectTab(selectedTab + 1)
|
||||||
|
InputAction.PrevTab -> selectTab(selectedTab - 1)
|
||||||
|
is InputAction.RawControl -> onRawControl(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- binding learn
|
||||||
|
/** Arm MIDI-learn / gamepad-rebind: the next matching control is bound to
|
||||||
|
* [actionId]. [source] restricts which device kind will be captured. */
|
||||||
|
fun beginLearn(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
|
||||||
|
learnTarget = actionId
|
||||||
|
learnSource = source
|
||||||
|
router.learnMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelLearn() {
|
||||||
|
learnTarget = null
|
||||||
|
learnSource = null
|
||||||
|
router.learnMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unbound control arrived. If we're learning and it matches the armed
|
||||||
|
* source, store the binding into the right handler's map. */
|
||||||
|
private fun onRawControl(action: InputAction.RawControl) {
|
||||||
|
val target = learnTarget ?: return
|
||||||
|
if (action.source != learnSource) return
|
||||||
|
when (action.source) {
|
||||||
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
||||||
|
midiInput.ccBindings[action.code] = target
|
||||||
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
|
||||||
|
gamepadInput.bindings[action.code] = target
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
cancelLearn()
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current MIDI CC number bound to [actionId], or null. */
|
||||||
|
fun midiBindingFor(actionId: String): Int? =
|
||||||
|
midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key
|
||||||
|
|
||||||
|
/** Current gamepad key code bound to [actionId], or null. */
|
||||||
|
fun gamepadBindingFor(actionId: String): Int? =
|
||||||
|
gamepadInput.bindings.entries.firstOrNull { it.value == actionId }?.key
|
||||||
|
|
||||||
|
fun clearBinding(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
|
||||||
|
when (source) {
|
||||||
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
||||||
|
midiInput.ccBindings.entries.removeAll { it.value == actionId }
|
||||||
|
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
|
||||||
|
gamepadInput.bindings.entries.removeAll { it.value == actionId }
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun touched() { revision++ }
|
||||||
|
|
||||||
|
/** Public hook so screens that mutate the model directly (e.g. dropdowns in
|
||||||
|
* the toolbar, mixer faders) can request a redraw. */
|
||||||
|
fun bumpForToolbar() = touched()
|
||||||
|
|
||||||
|
/** Call after changing mixer FX routing so the audio engine rebuilds the
|
||||||
|
* per-channel insert chains without waiting for the next Play. */
|
||||||
|
fun rebuildAudioRouting() {
|
||||||
|
engine.rebuildFxChains()
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- sampler
|
||||||
|
private val recorder = com.reactorcoremeltdown.sizzletracker.audio.SampleRecorder()
|
||||||
|
var isRecording by mutableStateOf(false); private set
|
||||||
|
|
||||||
|
/** Store already-decoded sample bytes into a slot (from a WAV file import). */
|
||||||
|
fun loadSampleInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, id: String, bytes: ByteArray): Boolean {
|
||||||
|
val sample = com.reactorcoremeltdown.sizzletracker.audio.SampleStore.decodeWav(bytes) ?: return false
|
||||||
|
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
||||||
|
slot.set("samplePath", id)
|
||||||
|
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
|
||||||
|
touched()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startRecording() { if (recorder.start()) isRecording = true }
|
||||||
|
|
||||||
|
// ------------------------------------------------------- audio device routing
|
||||||
|
/** Ids of the chosen output/input devices (for the Settings UI), null = default. */
|
||||||
|
var outputDeviceId by mutableStateOf<Int?>(null); private set
|
||||||
|
var inputDeviceId by mutableStateOf<Int?>(null); private set
|
||||||
|
|
||||||
|
fun setAudioOutput(device: android.media.AudioDeviceInfo?) {
|
||||||
|
engine.setPreferredOutput(device)
|
||||||
|
outputDeviceId = device?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAudioInput(device: android.media.AudioDeviceInfo?) {
|
||||||
|
recorder.preferredInput = device
|
||||||
|
inputDeviceId = device?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the native Oboe output backend is selected. */
|
||||||
|
var nativeEngine by mutableStateOf(false); private set
|
||||||
|
val nativeEngineAvailable: Boolean
|
||||||
|
get() = com.reactorcoremeltdown.sizzletracker.audio.NativeAudioBridge.isAvailable()
|
||||||
|
|
||||||
|
fun applyNativeEngine(enabled: Boolean) {
|
||||||
|
engine.setNativeOutput(enabled)
|
||||||
|
nativeEngine = engine.useNativeOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop recording and load the captured audio into [slot]. */
|
||||||
|
fun stopRecordingInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot) {
|
||||||
|
val sample = recorder.stop()
|
||||||
|
isRecording = false
|
||||||
|
if (sample != null) {
|
||||||
|
val id = "rec-" + System.currentTimeMillis()
|
||||||
|
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
||||||
|
slot.set("samplePath", id)
|
||||||
|
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point an LFO slot at another device's parameter, capturing that param's
|
||||||
|
* current value as the modulation centre. Pass targetSlotIndex < 0 to clear. */
|
||||||
|
fun setLfoTarget(
|
||||||
|
lfoSlot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot,
|
||||||
|
targetSlotIndex: Int,
|
||||||
|
key: String,
|
||||||
|
) {
|
||||||
|
if (targetSlotIndex < 0) {
|
||||||
|
lfoSlot.set("target", "")
|
||||||
|
} else {
|
||||||
|
val tSlot = project.toolbox.getOrNull(targetSlotIndex)
|
||||||
|
val spec = tSlot?.type?.params?.firstOrNull { it.key == key }
|
||||||
|
lfoSlot.set("target", "$targetSlotIndex:$key")
|
||||||
|
lfoSlot.set("center", tSlot?.float(key, spec?.default ?: 0f) ?: 0f)
|
||||||
|
}
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview a note of the Sampler slot's loaded sample (one-shot, editor use). */
|
||||||
|
fun auditionSample(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, midi: Int) {
|
||||||
|
engine.auditionSlotOn(slot.index, midi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
|
||||||
|
fun auditionOn(slotIndex: Int, midi: Int) = engine.auditionSlotOn(slotIndex, midi)
|
||||||
|
fun auditionOff(midi: Int) = engine.auditionSlotOff(midi)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ project I/O
|
||||||
|
/** Serialize the current song to `.sng` text. */
|
||||||
|
fun saveProjectText(): String =
|
||||||
|
com.reactorcoremeltdown.sizzletracker.io.ProjectIo.save(project)
|
||||||
|
|
||||||
|
/** Replace the current song's contents from our full internal text (in place). */
|
||||||
|
fun loadProjectText(text: String) {
|
||||||
|
com.reactorcoremeltdown.sizzletracker.io.ProjectIo.loadInto(project, text)
|
||||||
|
cursorLine = 0; cursorTrack = 0
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export to the reference desktop sizzletracker `.sng` format (musical data). */
|
||||||
|
fun exportSng(): String =
|
||||||
|
com.reactorcoremeltdown.sizzletracker.io.SngFormat.export(project)
|
||||||
|
|
||||||
|
/** Import a reference `.sng` file (in place). */
|
||||||
|
fun importSng(text: String) {
|
||||||
|
com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
|
||||||
|
cursorLine = 0; cursorTrack = 0
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextOf(c: CellColumn, dir: Int): CellColumn {
|
||||||
|
val values = CellColumn.entries
|
||||||
|
return values[(c.ordinal + dir + values.size) % values.size]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays out each distinct (text, style) pair exactly once and reuses the result on
|
||||||
|
* every later frame. The Canvas grids draw the same tiny set of fixed-width strings
|
||||||
|
* — note names, hex velocities, lane/bar numbers — dozens of times per frame, and
|
||||||
|
* re-measuring them every frame was the main cost that made the playhead feel
|
||||||
|
* sluggish. [TextMeasurer]'s own cache is a small LRU (8 entries) that a full grid
|
||||||
|
* blows past immediately, so we keep our own unbounded, allocation-free cache.
|
||||||
|
*
|
||||||
|
* Styles are compared by identity (===): callers pass a fixed handful of remembered
|
||||||
|
* [TextStyle] instances, so a short linear scan avoids hashing a whole TextStyle per
|
||||||
|
* cell. Not thread-safe — only ever touched from the draw phase.
|
||||||
|
*/
|
||||||
|
class GlyphCache(private val measurer: TextMeasurer) {
|
||||||
|
private val styles = ArrayList<TextStyle>(6)
|
||||||
|
private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6)
|
||||||
|
|
||||||
|
fun measure(text: String, style: TextStyle): TextLayoutResult {
|
||||||
|
var idx = styles.indexOfFirst { it === style }
|
||||||
|
if (idx < 0) {
|
||||||
|
styles.add(style)
|
||||||
|
byStyle.add(HashMap())
|
||||||
|
idx = styles.size - 1
|
||||||
|
}
|
||||||
|
return byStyle[idx].getOrPut(text) { measurer.measure(text, style) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tiny library of "pixel" UI building blocks. They are intentionally plain —
|
||||||
|
* hard rectangles, monospace text, 1px borders — so they are cheap to draw
|
||||||
|
* (helping the "lightweight UI" requirement) and match the retro aesthetic.
|
||||||
|
* Reusing these keeps every screen visually consistent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A flat, square-cornered button. [active] paints it in the accent colour. */
|
||||||
|
@Composable
|
||||||
|
fun RetroButton(
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
active: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.border(1.dp, if (active) c.accent else c.grid, RectangleShape)
|
||||||
|
.background(if (active) c.accent.copy(alpha = 0.18f) else c.surface)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (active) c.accent else c.text,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled dropdown selector rendered as `LABEL:value ▾`. */
|
||||||
|
@Composable
|
||||||
|
fun <T> RetroDropdown(
|
||||||
|
label: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
optionLabel: (T) -> String = { it.toString() },
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box(modifier) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.border(1.dp, c.grid, RectangleShape)
|
||||||
|
.background(c.surface)
|
||||||
|
.clickable { open = true }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"$label:${optionLabel(selected)} ▾",
|
||||||
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
options.forEach { opt ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(optionLabel(opt), fontFamily = FontFamily.Monospace) },
|
||||||
|
onClick = { onSelect(opt); open = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A dim, spaced section header, e.g. "── TRANSPORT ──". */
|
||||||
|
@Composable
|
||||||
|
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text.uppercase(),
|
||||||
|
modifier = modifier.padding(vertical = 4.dp),
|
||||||
|
color = LocalRetro.current.textDim,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A grid of pixel "cells" used as a lightweight icon (e.g. play/stop glyphs).
|
||||||
|
* [rows] are strings of '#' (filled) and ' ' (empty). */
|
||||||
|
@Composable
|
||||||
|
fun PixelGlyph(rows: List<String>, color: Color, cell: Int = 3) {
|
||||||
|
Column {
|
||||||
|
rows.forEach { line ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
line.forEach { ch ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(cell.dp)
|
||||||
|
.background(if (ch == '#') color else Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.mixer
|
||||||
|
|
||||||
|
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.fillMaxHeight
|
||||||
|
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.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.Mixer
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.MixerChannel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.SectionLabel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second tab: a four-channel mixer, one strip per tracker track. Each strip
|
||||||
|
* chooses an instrument + up to four effects (all drawn from the toolbox), sets a
|
||||||
|
* MIDI channel, and has a volume fader plus mute/solo. Everything writes straight
|
||||||
|
* into the shared [Mixer] model that the audio engine reads.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MixerScreen(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||||
|
Row(Modifier.fillMaxSize().background(c.background).padding(6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
vm.project.mixer.channels.forEach { channel ->
|
||||||
|
ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh
|
||||||
|
val toolbox = vm.project.toolbox
|
||||||
|
|
||||||
|
// Toolbox slots that hold an instrument / an effect, as (index -> label).
|
||||||
|
val instrumentSlots = toolbox.filter { it.type?.kind == ToolboxKind.INSTRUMENT }
|
||||||
|
val effectSlots = toolbox.filter { it.type?.kind == ToolboxKind.EFFECT }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier.background(c.surface).padding(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text("CH ${channel.index + 1}", color = c.accent,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||||
|
|
||||||
|
SectionLabel("Instrument")
|
||||||
|
RetroDropdown(
|
||||||
|
label = "INS",
|
||||||
|
options = listOf(-1) + instrumentSlots.map { it.index },
|
||||||
|
selected = channel.instrumentSlot,
|
||||||
|
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
||||||
|
onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
|
||||||
|
SectionLabel("FX 1-4")
|
||||||
|
for (fx in 0 until Mixer.FX_SLOTS) {
|
||||||
|
RetroDropdown(
|
||||||
|
label = "FX${fx + 1}",
|
||||||
|
options = listOf(-1) + effectSlots.map { it.index },
|
||||||
|
selected = channel.fxSlots[fx],
|
||||||
|
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
||||||
|
onSelect = { channel.fxSlots[fx] = it; vm.rebuildAudioRouting() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionLabel("MIDI Ch")
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(1); vm.bumpForToolbar() }
|
||||||
|
Text(" ${channel.midiChannel} ", color = c.text,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||||
|
RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() }
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionLabel("Volume")
|
||||||
|
// Vertical fader fills the remaining strip height.
|
||||||
|
VerticalFader(
|
||||||
|
value = channel.volume,
|
||||||
|
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
RetroButton("M", active = channel.mute, modifier = Modifier.weight(1f)) {
|
||||||
|
channel.mute = !channel.mute; vm.bumpForToolbar()
|
||||||
|
}
|
||||||
|
RetroButton("S", active = channel.solo, modifier = Modifier.weight(1f)) {
|
||||||
|
channel.solo = !channel.solo; vm.bumpForToolbar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A vertical volume fader: fill grows from the bottom; tap or drag to set. */
|
||||||
|
@Composable
|
||||||
|
private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.border(1.dp, c.grid, RectangleShape)
|
||||||
|
.background(c.background)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { off -> onValueChange((1f - off.y / size.height).coerceIn(0f, 1f)) }
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures { change, _ ->
|
||||||
|
change.consume()
|
||||||
|
onValueChange((1f - change.position.y / size.height).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(value.coerceIn(0.001f, 1f))
|
||||||
|
.background(c.accent.copy(alpha = 0.30f)),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxWidth().height(3.dp).background(c.accent)) // thumb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Piano
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.input.InputSource
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fourth tab, built from STANDARD Material 3 components (Cards, Buttons,
|
||||||
|
* ListItems, OutlinedTextField, FilterChips) rather than the bespoke retro grid,
|
||||||
|
* for familiar and accessible interaction. Functionality is unchanged:
|
||||||
|
* project save/load, theme selection + export, gamepad bindings, and a
|
||||||
|
* searchable / collapsible MIDI binding list.
|
||||||
|
*/
|
||||||
|
private enum class SettingsSection { MAIN, GAMEPAD, MIDI }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(vm: AppViewModel) {
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
var section by remember { mutableStateOf(SettingsSection.MAIN) }
|
||||||
|
|
||||||
|
when (section) {
|
||||||
|
// Top level stays light: the heavy binding lists live in their own
|
||||||
|
// subsections, so they only compose when the user actually opens them.
|
||||||
|
SettingsSection.MAIN -> LazyColumn(
|
||||||
|
Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item { ProjectCard(vm) }
|
||||||
|
item { AudioDevicesCard(vm) }
|
||||||
|
item { ThemeCard(vm) }
|
||||||
|
item {
|
||||||
|
NavCard("Gamepad Bindings", "Rebind buttons", Icons.Filled.SportsEsports) {
|
||||||
|
section = SettingsSection.GAMEPAD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
|
||||||
|
section = SettingsSection.MIDI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsSection.GAMEPAD -> LazyColumn(
|
||||||
|
Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
|
||||||
|
item {
|
||||||
|
Text("Tap Learn, then press a button to bind it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
items(BIND_MODULES.values.flatten()) { action ->
|
||||||
|
BindingRow(vm, action, InputSource.GAMEPAD)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsSection.MIDI -> LazyColumn(
|
||||||
|
Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
item { SubHeader("MIDI Bindings") { section = SettingsSection.MAIN } }
|
||||||
|
item { MidiBindingSections(vm) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header row with a back arrow for a settings subsection. */
|
||||||
|
@Composable
|
||||||
|
private fun SubHeader(title: String, onBack: () -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tappable card that navigates into a settings subsection. */
|
||||||
|
@Composable
|
||||||
|
private fun NavCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
|
||||||
|
ElevatedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||||
|
ListItem(
|
||||||
|
leadingContent = { Icon(icon, null) },
|
||||||
|
headlineContent = { Text(title) },
|
||||||
|
supportingContent = { Text(subtitle) },
|
||||||
|
trailingContent = { Icon(Icons.Filled.ChevronRight, null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- top-level cards
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectCard(vm: AppViewModel) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
SettingsCard("Project", subtitle = "Interchange with desktop sizzletracker (.sng), or full save") {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = { clipboard.setText(AnnotatedString(vm.exportSng())) }) {
|
||||||
|
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||||
|
Text("Export .sng")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::importSng) }) {
|
||||||
|
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
|
||||||
|
Text("Import .sng")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { clipboard.setText(AnnotatedString(vm.saveProjectText())) }) { Text("Save full") }
|
||||||
|
OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::loadProjectText) }) { Text("Load full") }
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
".sng carries notes/blocks/arrangement (desktop-compatible); full save also keeps mixer, FX and instruments.",
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AudioDevicesCard(vm: AppViewModel) {
|
||||||
|
SettingsCard("Audio Devices", subtitle = "Route playback / recording to USB or built-in") {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val am = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
val outputs = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
|
||||||
|
val inputs = am.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
|
||||||
|
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
||||||
|
HorizontalDivider()
|
||||||
|
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
|
||||||
|
HorizontalDivider()
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text("Low-latency engine (Oboe)", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
if (vm.nativeEngineAvailable) "Native AAudio output" else "Native library unavailable",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = vm.nativeEngine,
|
||||||
|
enabled = vm.nativeEngineAvailable,
|
||||||
|
onCheckedChange = { vm.applyNativeEngine(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeCard(vm: AppViewModel) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
SettingsCard("Color Theme", subtitle = "Pick a palette or export it as text") {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
RetroPalette.ALL.forEach { p ->
|
||||||
|
FilterChip(
|
||||||
|
selected = vm.palette.name == p.name,
|
||||||
|
onClick = { vm.palette = p },
|
||||||
|
label = { Text(p.name) },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Palette, null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { clipboard.setText(AnnotatedString(exportTheme(vm.palette))) },
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
) { Text("Export theme") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bindable action: user-facing label + the action id the input handlers use. */
|
||||||
|
private data class BindAction(val label: String, val id: String)
|
||||||
|
|
||||||
|
private val BIND_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"),
|
||||||
|
),
|
||||||
|
"Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One action row with its current binding, a Learn toggle, and a clear button. */
|
||||||
|
@Composable
|
||||||
|
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
|
||||||
|
val listening = vm.learnTarget == action.id
|
||||||
|
val code = if (source == InputSource.MIDI) vm.midiBindingFor(action.id) else vm.gamepadBindingFor(action.id)
|
||||||
|
val label = code?.let { (if (source == InputSource.MIDI) "CC $it" else "key $it") } ?: "unbound"
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(action.label) },
|
||||||
|
supportingContent = { Text(label, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TextButton(onClick = { if (listening) vm.cancelLearn() else vm.beginLearn(action.id, source) }) {
|
||||||
|
Text(if (listening) "Listening…" else "Learn")
|
||||||
|
}
|
||||||
|
if (code != null) {
|
||||||
|
TextButton(onClick = { vm.clearBinding(action.id, source) }) { Text("✕") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A label + dropdown that routes audio to a chosen device (or the default). */
|
||||||
|
@Composable
|
||||||
|
private fun DeviceRow(
|
||||||
|
label: String,
|
||||||
|
devices: List<AudioDeviceInfo>,
|
||||||
|
selectedId: Int?,
|
||||||
|
onSelect: (AudioDeviceInfo?) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val current = devices.firstOrNull { it.id == selectedId }?.let { deviceLabel(it) } ?: "Default"
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Box {
|
||||||
|
OutlinedButton(onClick = { open = true }) { Text(current, maxLines = 1) }
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
DropdownMenuItem(text = { Text("Default") }, onClick = { onSelect(null); open = false })
|
||||||
|
devices.forEach { d ->
|
||||||
|
DropdownMenuItem(text = { Text(deviceLabel(d)) }, onClick = { onSelect(d); open = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deviceLabel(d: AudioDeviceInfo): String {
|
||||||
|
val kind = when (d.type) {
|
||||||
|
AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB"
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Mic"
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BT"
|
||||||
|
else -> "Dev"
|
||||||
|
}
|
||||||
|
return "${d.productName} · $kind"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A titled Material card wrapper used for every settings group. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsCard(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportTheme(p: RetroPalette): String = buildString {
|
||||||
|
appendLine("SIZZLE-THEME 1")
|
||||||
|
appendLine("NAME=${p.name}")
|
||||||
|
fun hex(color: Color) =
|
||||||
|
"#%02X%02X%02X".format((color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt())
|
||||||
|
appendLine("background=${hex(p.background)}")
|
||||||
|
appendLine("surface=${hex(p.surface)}")
|
||||||
|
appendLine("accent=${hex(p.accent)}")
|
||||||
|
appendLine("beat=${hex(p.beat)}")
|
||||||
|
appendLine("bar=${hex(p.bar)}")
|
||||||
|
appendLine("playhead=${hex(p.playhead)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable + collapsible MIDI binding list. Each module is a Card whose header
|
||||||
|
* toggles expansion; its actions are real [BindingRow]s whose Learn button arms
|
||||||
|
* MIDI-learn — the next incoming CC is stored into the live MIDI binding map.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MidiBindingSections(vm: AppViewModel) {
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
var expanded by remember { mutableStateOf<String?>("Transport") }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
singleLine = true,
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||||
|
placeholder = { Text("Search controls…") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BIND_MODULES.forEach { (module, actions) ->
|
||||||
|
val matches = actions.filter { it.label.contains(query, ignoreCase = true) }
|
||||||
|
if (query.isNotEmpty() && matches.isEmpty()) return@forEach
|
||||||
|
val isOpen = expanded == module || query.isNotEmpty()
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = if (expanded == module) null else module }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(module, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Icon(if (isOpen) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null)
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
matches.forEach { action -> BindingRow(vm, action, InputSource.MIDI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The retro look-and-feel. Two ideas drive everything:
|
||||||
|
* 1. ONE monospace font family for the entire UI (that's the "tracker" feel and
|
||||||
|
* it makes the grid columns line up perfectly).
|
||||||
|
* 2. A small, named [RetroPalette] so colour *themes* can be swapped and
|
||||||
|
* imported/exported from the Settings tab without touching any screen code.
|
||||||
|
*
|
||||||
|
* The palette is exposed through a CompositionLocal ([LocalRetro]) so any
|
||||||
|
* composable can read theme colours with `LocalRetro.current`.
|
||||||
|
*/
|
||||||
|
data class RetroPalette(
|
||||||
|
val name: String,
|
||||||
|
val background: Color,
|
||||||
|
val surface: Color, // panel / toolbar background
|
||||||
|
val grid: Color, // faint grid lines
|
||||||
|
val text: Color, // default monospace text
|
||||||
|
val textDim: Color, // secondary text
|
||||||
|
val accent: Color, // cursor / primary highlight (amber)
|
||||||
|
val beat: Color, // every-beat row tint
|
||||||
|
val bar: Color, // every-bar row tint
|
||||||
|
val playhead: Color, // moving playback line
|
||||||
|
val danger: Color, // mute / destructive
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** The built-in default: near-black with amber + green, classic demoscene. */
|
||||||
|
val AMBER = RetroPalette(
|
||||||
|
name = "Amber CRT",
|
||||||
|
background = Color(0xFF0B0D0E),
|
||||||
|
surface = Color(0xFF15181A),
|
||||||
|
grid = Color(0xFF23282B),
|
||||||
|
text = Color(0xFFCFE8D8),
|
||||||
|
textDim = Color(0xFF6E7C74),
|
||||||
|
accent = Color(0xFFF2C14E),
|
||||||
|
beat = Color(0xFF141C1A),
|
||||||
|
bar = Color(0xFF1E2A20),
|
||||||
|
playhead = Color(0xFF7CFC9A),
|
||||||
|
danger = Color(0xFFE05A4B),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A second built-in so the theme picker has something to switch to. */
|
||||||
|
val ICE = RetroPalette(
|
||||||
|
name = "Ice Terminal",
|
||||||
|
background = Color(0xFF07090F),
|
||||||
|
surface = Color(0xFF10151F),
|
||||||
|
grid = Color(0xFF1E2633),
|
||||||
|
text = Color(0xFFCFE0FF),
|
||||||
|
textDim = Color(0xFF63708A),
|
||||||
|
accent = Color(0xFF62B6FF),
|
||||||
|
beat = Color(0xFF121A26),
|
||||||
|
bar = Color(0xFF1B2740),
|
||||||
|
playhead = Color(0xFF8AF0FF),
|
||||||
|
danger = Color(0xFFE05A7A),
|
||||||
|
)
|
||||||
|
|
||||||
|
val ALL = listOf(AMBER, ICE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalRetro = staticCompositionLocalOf { RetroPalette.AMBER }
|
||||||
|
|
||||||
|
/** Monospace typography used everywhere — no other font is loaded. */
|
||||||
|
private val Mono = FontFamily.Monospace
|
||||||
|
private val retroTypography = Typography(
|
||||||
|
bodyLarge = TextStyle(fontFamily = Mono, fontSize = 14.sp, fontWeight = FontWeight.Normal),
|
||||||
|
bodyMedium = TextStyle(fontFamily = Mono, fontSize = 12.sp),
|
||||||
|
bodySmall = TextStyle(fontFamily = Mono, fontSize = 10.sp),
|
||||||
|
labelLarge = TextStyle(fontFamily = Mono, fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||||
|
titleMedium = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SizzleTheme(
|
||||||
|
palette: RetroPalette = RetroPalette.AMBER,
|
||||||
|
@Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), // always dark; kept for API symmetry
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val scheme = darkColorScheme(
|
||||||
|
background = palette.background,
|
||||||
|
surface = palette.surface,
|
||||||
|
primary = palette.accent,
|
||||||
|
onPrimary = palette.background,
|
||||||
|
onBackground = palette.text,
|
||||||
|
onSurface = palette.text,
|
||||||
|
error = palette.danger,
|
||||||
|
)
|
||||||
|
CompositionLocalProvider(LocalRetro provides palette) {
|
||||||
|
MaterialTheme(colorScheme = scheme, typography = retroTypography, content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modulation target: a numeric parameter on another device the LFO can drive.
|
||||||
|
* [slotIndex] < 0 represents "no target". [encoded] is the "slotIndex:paramKey"
|
||||||
|
* form stored in the LFO slot; [label] is what the user sees.
|
||||||
|
*/
|
||||||
|
private data class LfoTarget(val slotIndex: Int, val key: String, val label: String) {
|
||||||
|
val encoded: String get() = if (slotIndex < 0) "" else "$slotIndex:$key"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LFO's target picker (rendered under its generated parameter list). It lists
|
||||||
|
* every numeric parameter of every other occupied slot; choosing one wires the
|
||||||
|
* LFO to modulate it. See [AppViewModel.setLfoTarget].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LfoTargetPicker(vm: AppViewModel, lfo: ToolboxSlot) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
|
||||||
|
val none = LfoTarget(-1, "", "— none —")
|
||||||
|
val targets = buildList {
|
||||||
|
add(none)
|
||||||
|
vm.project.toolbox.forEachIndexed { idx, slot ->
|
||||||
|
val type = slot.type
|
||||||
|
if (idx == lfo.index || type == null) return@forEachIndexed
|
||||||
|
for (spec in type.params) {
|
||||||
|
if (spec.isEnum) continue // only continuous params are modulatable
|
||||||
|
add(LfoTarget(idx, spec.key, "${idx + 1}:${slot.name} · ${spec.label}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val current = lfo.string("target")
|
||||||
|
val selected = targets.firstOrNull { it.encoded == current } ?: none
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
RetroDropdown(
|
||||||
|
label = "TARGET",
|
||||||
|
options = targets,
|
||||||
|
selected = selected,
|
||||||
|
optionLabel = { it.label },
|
||||||
|
onSelect = { vm.setLfoTarget(lfo, it.slotIndex, it.key) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"The LFO takes over its target parameter while assigned.",
|
||||||
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ParamSpec
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One editable parameter row. Enumerated params ([ParamSpec.isEnum]) render as a
|
||||||
|
* dropdown; numeric params render as a labelled slider. Reading and writing goes
|
||||||
|
* through the slot's string-typed value map so it stays serialization-friendly.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ParamControl(vm: AppViewModel, slot: ToolboxSlot, spec: ParamSpec) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so sliders/dropdowns refresh
|
||||||
|
if (spec.isEnum) {
|
||||||
|
RetroDropdown(
|
||||||
|
label = spec.label,
|
||||||
|
options = spec.choices,
|
||||||
|
selected = slot.string(spec.key, spec.choices.first()),
|
||||||
|
onSelect = { slot.set(spec.key, it); vm.bumpForToolbar() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val value = slot.float(spec.key, spec.default)
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Row {
|
||||||
|
Text("${spec.label}: ", color = c.textDim,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
|
Text(formatValue(value), color = c.text,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = value.coerceIn(spec.min, spec.max),
|
||||||
|
valueRange = spec.min..spec.max,
|
||||||
|
onValueChange = { slot.set(spec.key, it); vm.bumpForToolbar() },
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = c.accent, activeTrackColor = c.accent, inactiveTrackColor = c.grid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatValue(v: Float): String =
|
||||||
|
if (v == v.toInt().toFloat()) v.toInt().toString() else String.format("%.2f", v)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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.gestures.detectDragGestures
|
||||||
|
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.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
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.RetroButton
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Sampler's custom editor (replaces the generic parameter list for the
|
||||||
|
* Sampler device). It lets you import a WAV or record from the mic/USB input,
|
||||||
|
* trim the sample with two draggable slice markers on a waveform, set the start
|
||||||
|
* octave + volume, and audition across a two-octave keyboard.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: waveform/markers refresh
|
||||||
|
|
||||||
|
val sampleId = slot.string("samplePath")
|
||||||
|
val sliceStart = slot.float("sliceStart", 0f)
|
||||||
|
val sliceEnd = slot.float("sliceEnd", 1f)
|
||||||
|
val startOctave = slot.float("startOctave", 3f).toInt()
|
||||||
|
val volume = slot.float("volume", 0.8f)
|
||||||
|
|
||||||
|
// System file picker for importing a WAV.
|
||||||
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}.getOrNull()?.let { bytes ->
|
||||||
|
val ok = vm.loadSampleInto(slot, uri.toString(), bytes)
|
||||||
|
if (!ok) { /* unsupported file; a toast could go here */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// ----- Source buttons -----
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
RetroButton("IMPORT WAV") { picker.launch(arrayOf("audio/*")) }
|
||||||
|
if (vm.isRecording) {
|
||||||
|
RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot) }
|
||||||
|
} else {
|
||||||
|
RetroButton("● RECORD") { vm.startRecording() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Waveform + draggable slice markers -----
|
||||||
|
val sample = SampleStore.get(sampleId)
|
||||||
|
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||||
|
var dragging by remember { mutableIntStateOf(-1) }
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(130.dp)
|
||||||
|
.background(c.surface)
|
||||||
|
.pointerInput(sampleId) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { off ->
|
||||||
|
val frac = (off.x / size.width).coerceIn(0f, 1f)
|
||||||
|
dragging = if (abs(frac - sliceStart) <= abs(frac - sliceEnd)) 0 else 1
|
||||||
|
},
|
||||||
|
onDrag = { change, _ ->
|
||||||
|
change.consume()
|
||||||
|
val frac = (change.position.x / size.width).coerceIn(0f, 1f)
|
||||||
|
if (dragging == 0) slot.set("sliceStart", frac) else slot.set("sliceEnd", frac)
|
||||||
|
vm.bumpForToolbar()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val mid = h / 2f
|
||||||
|
if (peaks != null) {
|
||||||
|
val (mins, maxs) = peaks
|
||||||
|
val bw = w / mins.size
|
||||||
|
for (i in mins.indices) {
|
||||||
|
val x = i * bw
|
||||||
|
drawLine(
|
||||||
|
color = c.text,
|
||||||
|
start = Offset(x, mid - maxs[i] * mid),
|
||||||
|
end = Offset(x, mid - mins[i] * mid),
|
||||||
|
strokeWidth = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shade the active slice region and draw the two markers.
|
||||||
|
val sx = sliceStart * w
|
||||||
|
val ex = sliceEnd * w
|
||||||
|
drawRect(color = c.accent.copy(alpha = 0.14f), topLeft = Offset(sx, 0f), size = Size((ex - sx), h))
|
||||||
|
drawLine(c.accent, Offset(sx, 0f), Offset(sx, h), strokeWidth = 3f)
|
||||||
|
drawLine(c.playhead, Offset(ex, 0f), Offset(ex, h), strokeWidth = 3f)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (sample == null) "No sample loaded — import a WAV or record one."
|
||||||
|
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers to trim",
|
||||||
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ----- Start octave + volume -----
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Start oct:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(top = 6.dp))
|
||||||
|
RetroButton("-") { slot.set("startOctave", (startOctave - 1).coerceAtLeast(0).toFloat()); vm.bumpForToolbar() }
|
||||||
|
Text(" $startOctave ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(top = 6.dp))
|
||||||
|
RetroButton("+") { slot.set("startOctave", (startOctave + 1).coerceAtMost(8).toFloat()); vm.bumpForToolbar() }
|
||||||
|
}
|
||||||
|
ParamControl(vm, slot, com.reactorcoremeltdown.sizzletracker.model.ParamSpec("volume", "Volume", volume, 0f, 1f))
|
||||||
|
|
||||||
|
// ----- Two-octave audition keyboard -----
|
||||||
|
Text("Audition (2 octaves):", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
|
val root = ((startOctave + 1) * 12).coerceIn(0, 103)
|
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
for (n in 0 until 24) {
|
||||||
|
val midi = root + n
|
||||||
|
RetroButton(Pitch.name(midi)) { vm.auditionSample(slot, midi) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Down-sample the waveform to [buckets] min/max pairs for cheap drawing. */
|
||||||
|
private fun computePeaks(data: FloatArray, buckets: Int): Pair<FloatArray, FloatArray> {
|
||||||
|
val mins = FloatArray(buckets)
|
||||||
|
val maxs = FloatArray(buckets)
|
||||||
|
if (data.isEmpty()) return mins to maxs
|
||||||
|
val step = (data.size / buckets).coerceAtLeast(1)
|
||||||
|
for (b in 0 until buckets) {
|
||||||
|
var mn = 1f; var mx = -1f
|
||||||
|
val start = b * step
|
||||||
|
val end = minOf(start + step, data.size)
|
||||||
|
if (start >= data.size) { mins[b] = 0f; maxs[b] = 0f; continue }
|
||||||
|
for (i in start until end) {
|
||||||
|
val v = data[i]
|
||||||
|
if (v < mn) mn = v
|
||||||
|
if (v > mx) mx = v
|
||||||
|
}
|
||||||
|
mins[b] = mn.coerceIn(-1f, 1f)
|
||||||
|
maxs[b] = mx.coerceIn(-1f, 1f)
|
||||||
|
}
|
||||||
|
return mins to maxs
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
|
import androidx.compose.foundation.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.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.io.PresetIo
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The third tab. Top half: a 4x4 grid of 16 slots holding instruments or effects.
|
||||||
|
* - SINGLE tap -> select the tile (highlighted).
|
||||||
|
* - DOUBLE tap -> open its edit window (device picker if empty, params if filled).
|
||||||
|
* - LONG press -> empty the cell.
|
||||||
|
*
|
||||||
|
* Bottom half: a stacked two-octave keyboard (one octave per row) that plays the
|
||||||
|
* currently-selected tile IF it is an instrument, for quick testing.
|
||||||
|
*
|
||||||
|
* The parameter editor is generated automatically from the device's [ToolboxType]
|
||||||
|
* parameter list, so new devices need no bespoke UI.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ToolboxScreen(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||||
|
|
||||||
|
var selected by remember { mutableStateOf(-1) }
|
||||||
|
var editing by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var picking by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(4),
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth().padding(6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
items(vm.project.toolbox) { slot ->
|
||||||
|
SlotTile(
|
||||||
|
// The changing revision defeats strong-skipping so tiles refresh.
|
||||||
|
rev = vm.revision,
|
||||||
|
slot = slot,
|
||||||
|
selected = selected == slot.index,
|
||||||
|
onTap = { selected = slot.index },
|
||||||
|
onDoubleTap = { if (slot.isEmpty) picking = slot.index else editing = slot.index },
|
||||||
|
onLongPress = { slot.clearSlot(); vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||||
|
AuditionKeyboard(vm, vm.project.toolbox.getOrNull(selected), Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
picking?.let { index ->
|
||||||
|
DevicePicker(
|
||||||
|
onDismiss = { picking = null },
|
||||||
|
onPick = { type ->
|
||||||
|
vm.project.toolbox[index].fill(type)
|
||||||
|
vm.bumpForToolbar(); picking = null; editing = index
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editing?.let { index ->
|
||||||
|
ParamEditor(vm, vm.project.toolbox[index], onClose = { editing = null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SlotTile(
|
||||||
|
rev: Int,
|
||||||
|
slot: ToolboxSlot,
|
||||||
|
selected: Boolean,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
onDoubleTap: () -> Unit,
|
||||||
|
onLongPress: () -> Unit,
|
||||||
|
) {
|
||||||
|
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val borderColor = when { selected -> c.playhead; !slot.isEmpty -> c.accent; else -> c.grid }
|
||||||
|
val fill = when { selected -> c.accent.copy(alpha = 0.28f); !slot.isEmpty -> c.accent.copy(alpha = 0.12f); else -> c.surface }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.border(if (selected) 2.dp else 1.dp, borderColor, RectangleShape)
|
||||||
|
.background(fill)
|
||||||
|
.pointerInput(slot.index, slot.isEmpty) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { onTap() },
|
||||||
|
onDoubleTap = { onDoubleTap() },
|
||||||
|
onLongPress = { onLongPress() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("${slot.index + 1}".padStart(2, '0'),
|
||||||
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
Text(
|
||||||
|
if (slot.isEmpty) "+" else slot.name,
|
||||||
|
color = if (slot.isEmpty) c.textDim else c.text,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
slot.type?.let {
|
||||||
|
Text(it.kind.name.take(3), color = c.textDim,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 9.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- test keyboard
|
||||||
|
|
||||||
|
private val ACCIDENTALS = setOf(1, 3, 6, 8, 10) // pitch-classes of the "black keys"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two stacked octaves (one per row) 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
|
||||||
|
KeyRow(vm, slotIndex, isInstrument, base + 12, Modifier.weight(1f)) // upper octave (C4..B4) on top
|
||||||
|
KeyRow(vm, slotIndex, isInstrument, base, Modifier.weight(1f)) // lower octave (C3..B3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) {
|
||||||
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
for (i in 0 until 12) {
|
||||||
|
Key(vm, slotIndex, enabled, startMidi + i, Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Key(vm: AppViewModel, slotIndex: Int, enabled: Boolean, midi: Int, modifier: Modifier) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val accidental = (midi % 12) in ACCIDENTALS
|
||||||
|
val keyColor = (if (accidental) c.grid else c.surface).let { if (enabled) it else it.copy(alpha = 0.4f) }
|
||||||
|
// Keep the gesture handler stable across selection changes. Keying pointerInput
|
||||||
|
// on `midi` alone (constant per key) means selecting a different instrument does
|
||||||
|
// NOT tear down and relaunch all 24 keys' gesture coroutines every tap — the
|
||||||
|
// live slot/enabled are read through rememberUpdatedState instead.
|
||||||
|
val liveSlot = rememberUpdatedState(slotIndex)
|
||||||
|
val liveEnabled = rememberUpdatedState(enabled)
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.background(keyColor)
|
||||||
|
.border(1.dp, c.grid, RectangleShape)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
Pitch.name(midi),
|
||||||
|
color = if (accidental) c.textDim else c.text,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A simple overlay list of all available instruments and effects to load. */
|
||||||
|
@Composable
|
||||||
|
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
|
||||||
|
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
|
||||||
|
ToolboxType.entries.forEach { type ->
|
||||||
|
Text(
|
||||||
|
"[${type.kind.name.take(3)}] ${type.displayName}",
|
||||||
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pointerInput(type) { detectTapGestures { onPick(type) } }
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-generated parameter editor plus preset copy/paste to the clipboard. */
|
||||||
|
@Composable
|
||||||
|
private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
|
||||||
|
val type = slot.type ?: return
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
|
||||||
|
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Sampler gets a bespoke editor (waveform + slicing + record);
|
||||||
|
// the LFO adds a modulation-target picker; everything else uses the
|
||||||
|
// auto-generated parameter list.
|
||||||
|
when (type) {
|
||||||
|
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
|
||||||
|
ToolboxType.LFO -> {
|
||||||
|
type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
||||||
|
LfoTargetPicker(vm, slot)
|
||||||
|
}
|
||||||
|
else -> type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Preset import / export (human-readable text via clipboard) -----
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
PresetButton("COPY PRESET") {
|
||||||
|
clipboard.setText(AnnotatedString(PresetIo.export(slot)))
|
||||||
|
}
|
||||||
|
PresetButton("PASTE PRESET") {
|
||||||
|
clipboard.getText()?.text?.let { PresetIo.import(slot, it); vm.bumpForToolbar() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Presets are plain text (KEY=VALUE). Copy to share, paste to load.",
|
||||||
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PresetButton(label: String, onClick: () -> Unit) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
Text(
|
||||||
|
"[$label]",
|
||||||
|
color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
|
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClick() } }.padding(4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Arrangement as ArrModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.LoopRegion
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.GlyphCache
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lower-half arrangement. Layout (spreadsheet-style, like the sizzletracker
|
||||||
|
* CLI): a fixed left column of lane numbers, a bar ruler across the top, and the
|
||||||
|
* scrollable roll body. The ruler and body share one horizontal scroll so they
|
||||||
|
* stay aligned; the roll body is drawn on a single [Canvas] for performance.
|
||||||
|
*
|
||||||
|
* Each lane is tied to its own tracker block (lane i ⟷ block i). Tapping a beat
|
||||||
|
* cell toggles that lane's block on/off at that beat; when the playhead crosses
|
||||||
|
* an enabled cell, the sequencer plays that block. Tap a lane number to edit that
|
||||||
|
* block in the tracker above.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ArrangementRoll(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
// Composition read: structural edits bump revision (playback does not), so the
|
||||||
|
// lane column / ruler recompose on edits but not per beat. The roll Canvas also
|
||||||
|
// reads vm.revision in its draw phase (below) so cell edits redraw it directly.
|
||||||
|
val redraw = vm.revision
|
||||||
|
@Suppress("UNUSED_EXPRESSION") redraw
|
||||||
|
// Held as State and read ONLY in the draw phase + the follow-scroll collector
|
||||||
|
// below, never in composition, so a playhead advance invalidates the roll draw
|
||||||
|
// alone — no recomposition.
|
||||||
|
val transportState = vm.transport.collectAsState()
|
||||||
|
val arr = vm.project.arrangement
|
||||||
|
val sig = vm.project.timeSignature
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||||
|
|
||||||
|
val beatWidth = 20.dp
|
||||||
|
val laneColWidth = 34.dp
|
||||||
|
val rulerHeight = 18.dp
|
||||||
|
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||||
|
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||||
|
|
||||||
|
var selStart by remember { mutableIntStateOf(-1) }
|
||||||
|
var selEnd by remember { mutableIntStateOf(-1) }
|
||||||
|
|
||||||
|
val rulerStyle = remember(c) { TextStyle(color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
|
||||||
|
|
||||||
|
// Follow the playhead by scrolling horizontally. Collects the transport flow
|
||||||
|
// directly rather than keying a LaunchedEffect on a composition read of the beat,
|
||||||
|
// so following the playhead never triggers recomposition. De-duped by beat so we
|
||||||
|
// only animate when the beat actually changes.
|
||||||
|
LaunchedEffect(scroll, beatWidthPx) {
|
||||||
|
var lastBeat = -1
|
||||||
|
vm.transport.collect { t ->
|
||||||
|
if (t.isPlaying && t.currentBeat != lastBeat) {
|
||||||
|
lastBeat = t.currentBeat
|
||||||
|
val target = (t.currentBeat * beatWidthPx - 200f).toInt().coerceAtLeast(0)
|
||||||
|
scroll.animateScrollTo(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
// ---- Fixed left column: corner + lane numbers ----
|
||||||
|
Column(Modifier.width(laneColWidth).fillMaxHeight()) {
|
||||||
|
Box(Modifier.height(rulerHeight).fillMaxWidth().background(c.surface),
|
||||||
|
contentAlignment = Alignment.Center) {
|
||||||
|
Text("BLK", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 8.sp)
|
||||||
|
}
|
||||||
|
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||||
|
val active = vm.activeBlock == lane
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(if (active) c.accent.copy(alpha = 0.25f) else c.surface)
|
||||||
|
.clickable { vm.setActiveBlock(lane) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("${lane + 1}", color = if (active) c.accent else c.textDim,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scrollable ruler + roll (one Canvas) ----
|
||||||
|
Box(Modifier.weight(1f).fillMaxHeight().horizontalScroll(scroll)) {
|
||||||
|
Canvas(
|
||||||
|
Modifier
|
||||||
|
.width(beatWidth * arr.lengthBeats)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.pointerInput(arr, sig) {
|
||||||
|
detectTapGestures { off ->
|
||||||
|
if (off.y < rulerHeightPx) return@detectTapGestures
|
||||||
|
val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT
|
||||||
|
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
||||||
|
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||||
|
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 1)
|
||||||
|
// Lane i is tied to block i: toggle block i at this beat.
|
||||||
|
if (arr.patternAt(lane, beat) != ArrModel.EMPTY) arr.set(lane, beat, ArrModel.EMPTY)
|
||||||
|
else arr.set(lane, beat, lane)
|
||||||
|
if (selStart < 0) selStart = beat else selEnd = beat
|
||||||
|
vm.bumpForToolbar()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// Subscribe the draw phase to edit revisions (live state read,
|
||||||
|
// not the captured `redraw` Int) so toggling a cell redraws the
|
||||||
|
// roll even when no other draw-read state changed.
|
||||||
|
@Suppress("UNUSED_EXPRESSION") vm.revision
|
||||||
|
// Draw-phase read: a playhead advance invalidates this draw
|
||||||
|
// only, never composition.
|
||||||
|
val transport = transportState.value
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val laneH = (h - rulerHeightPx) / ArrModel.LANE_COUNT
|
||||||
|
val beatsPerBar = sig.beatsPerBar
|
||||||
|
|
||||||
|
// Ruler background + per-bar labels/ticks.
|
||||||
|
drawRect(c.surface, Offset(0f, 0f), Size(w, rulerHeightPx))
|
||||||
|
for (beat in 0 until arr.lengthBeats) {
|
||||||
|
val x = beat * beatWidthPx
|
||||||
|
if (beat % beatsPerBar == 0) {
|
||||||
|
drawLine(c.grid, Offset(x, 0f), Offset(x, h), strokeWidth = 1f)
|
||||||
|
val bar = beat / beatsPerBar + 1
|
||||||
|
val layout = glyphs.measure("$bar", rulerStyle)
|
||||||
|
drawText(layout, topLeft = Offset(x + 2f, (rulerHeightPx - layout.size.height) / 2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lane cells.
|
||||||
|
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||||
|
val laneY = rulerHeightPx + lane * laneH
|
||||||
|
for (beat in 0 until arr.lengthBeats) {
|
||||||
|
val x = beat * beatWidthPx
|
||||||
|
val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY
|
||||||
|
val onPlayhead = transport.isPlaying && beat == transport.currentBeat
|
||||||
|
val inSel = selStart in 0..beat && beat < maxOf(selStart, selEnd + 1)
|
||||||
|
val fill = when {
|
||||||
|
filled -> c.accent.copy(alpha = 0.85f)
|
||||||
|
onPlayhead -> c.playhead.copy(alpha = 0.3f)
|
||||||
|
inSel -> c.bar
|
||||||
|
beat % beatsPerBar == 0 -> c.surface
|
||||||
|
else -> c.grid.copy(alpha = 0.35f)
|
||||||
|
}
|
||||||
|
drawRect(fill, Offset(x + 0.5f, laneY + 0.5f), Size(beatWidthPx - 1f, laneH - 1f))
|
||||||
|
}
|
||||||
|
// Lane separator.
|
||||||
|
drawLine(c.background, Offset(0f, laneY), Offset(w, laneY), strokeWidth = 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playhead marker across the whole roll.
|
||||||
|
if (transport.isPlaying) {
|
||||||
|
val px = transport.currentBeat * beatWidthPx
|
||||||
|
drawLine(c.playhead, Offset(px, 0f), Offset(px, h), strokeWidth = 2f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoopToolbar(
|
||||||
|
vm = vm,
|
||||||
|
arr = arr,
|
||||||
|
onAssignA = { assign(arr.regionA, selStart, selEnd); vm.bumpForToolbar() },
|
||||||
|
onAssignB = { assign(arr.regionB, selStart, selEnd); vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoopToolbar(
|
||||||
|
vm: AppViewModel,
|
||||||
|
arr: ArrModel,
|
||||||
|
onAssignA: () -> Unit,
|
||||||
|
onAssignB: () -> Unit,
|
||||||
|
) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().background(c.surface).padding(6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RetroButton("LOOP", active = arr.loopEnabled) {
|
||||||
|
arr.loopEnabled = !arr.loopEnabled; vm.bumpForToolbar()
|
||||||
|
}
|
||||||
|
RegionControl("A", arr.regionA, onAssign = onAssignA, vm = vm)
|
||||||
|
RegionControl("B", arr.regionB, onAssign = onAssignB, vm = vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RegionControl(name: String, region: LoopRegion, onAssign: () -> Unit, vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RetroButton(name, active = region.enabled) {
|
||||||
|
region.enabled = !region.enabled; onAssign()
|
||||||
|
}
|
||||||
|
RetroButton("-") { region.repeats = (region.repeats - 1).coerceAtLeast(1); vm.bumpForToolbar() }
|
||||||
|
Text(" x${region.repeats} ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
|
RetroButton("+") { region.repeats++; vm.bumpForToolbar() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One octave of piano keys + an octave switcher, writing the note on tap. */
|
||||||
|
@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)) }
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
for (pc in 0 until 12) {
|
||||||
|
val midi = ((octave + 1) * 12 + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
|
PianoKey(
|
||||||
|
label = Pitch.name(midi).take(2),
|
||||||
|
accidental = pc in ACCIDENTAL_PCS,
|
||||||
|
selected = cell.isPlayable && cell.note == midi,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { vm.setFocusedNote(midi) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"NOTE: " + when {
|
||||||
|
cell.isPlayable -> Pitch.name(cell.note)
|
||||||
|
cell.isNoteOff -> "==="
|
||||||
|
else -> "---"
|
||||||
|
},
|
||||||
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PianoKey(
|
||||||
|
label: String,
|
||||||
|
accidental: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val bg = when {
|
||||||
|
selected -> c.accent.copy(alpha = 0.45f)
|
||||||
|
accidental -> c.grid
|
||||||
|
else -> c.background
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.height(72.dp)
|
||||||
|
.border(1.dp, if (selected) c.accent else c.grid, RectangleShape)
|
||||||
|
.background(bg)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (accidental) c.textDim else c.text,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 val ACCIDENTAL_PCS = setOf(1, 3, 6, 8, 10)
|
||||||
|
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')
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
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.Pattern
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.GlyphCache
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The demoscene pattern grid, drawn as ONE [Canvas] instead of a tree of ~180
|
||||||
|
* per-cell composables. Collapsing it into a single draw pass (with a cached
|
||||||
|
* [rememberTextMeasurer] so repeated cell strings like "···" are measured once)
|
||||||
|
* removes the composition/layout churn that made the grid feel sluggish as the
|
||||||
|
* playhead moved — the whole grid is now a couple of hundred draw calls per frame.
|
||||||
|
*
|
||||||
|
* Visuals: beat and bar rows are tinted distinctly; the four vertical tracks get
|
||||||
|
* an alternating tint + separator lines; the cursor cell and playhead row are
|
||||||
|
* highlighted. Gestures are unchanged (tap to focus, vertical drag to edit).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
// Read in composition so structural changes (edits, resize, active-block swap —
|
||||||
|
// all of which bump revision) re-fetch `pattern`/`sig` and recompose. Playback
|
||||||
|
// does NOT bump revision, so this never fires per frame during playback.
|
||||||
|
val structureKey = vm.revision
|
||||||
|
@Suppress("UNUSED_EXPRESSION") structureKey
|
||||||
|
// Held as a State and read ONLY inside the draw/gesture lambdas below, never in
|
||||||
|
// composition: a playhead tick then invalidates the draw phase alone, skipping
|
||||||
|
// recomposition + layout entirely.
|
||||||
|
val transportState = vm.transport.collectAsState()
|
||||||
|
val pattern = vm.project.activePattern()
|
||||||
|
val sig = vm.project.timeSignature
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||||
|
// Which column's long-press editor popup is open (null = none).
|
||||||
|
var editorColumn by remember { mutableStateOf<CellColumn?>(null) }
|
||||||
|
|
||||||
|
// Cache the text styles per palette (fontSize is resolved with the ambient density).
|
||||||
|
val mono = FontFamily.Monospace
|
||||||
|
val styleText = remember(c) { TextStyle(color = c.text, fontFamily = mono, fontSize = 12.sp) }
|
||||||
|
val styleDimNote = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
|
||||||
|
val styleVel = remember(c) { TextStyle(color = c.accent, fontFamily = mono, fontSize = 12.sp) }
|
||||||
|
val styleChan = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
|
||||||
|
val styleGutter = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 11.sp) }
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier.fillMaxSize().background(c.background)) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val rowPx = with(density) { 20.dp.toPx() }
|
||||||
|
val gutterPx = with(density) { 30.dp.toPx() }
|
||||||
|
val widthPx = with(density) { maxWidth.toPx() }
|
||||||
|
val heightPx = with(density) { maxHeight.toPx() }
|
||||||
|
|
||||||
|
val trackPx = (widthPx - gutterPx) / Pattern.TRACK_COUNT
|
||||||
|
val notePx = trackPx * 0.5f
|
||||||
|
val velPx = trackPx * 0.25f
|
||||||
|
|
||||||
|
val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
// Which row sits at the top of the viewport. Computed on demand (not stored
|
||||||
|
// in composition) so it can be read from the draw phase during playback and
|
||||||
|
// from gestures at touch time, always against the live transport/cursor.
|
||||||
|
fun firstRow(): Int {
|
||||||
|
val t = transportState.value
|
||||||
|
val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
|
||||||
|
return (focusLine - visibleRows / 2)
|
||||||
|
.coerceIn(0, (pattern.length - visibleRows).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hitTest(x: Float, y: Float): Triple<Int, CellColumn, Int>? {
|
||||||
|
if (x < gutterPx) return null
|
||||||
|
val line = (firstRow() + (y / rowPx).toInt()).coerceIn(0, pattern.length - 1)
|
||||||
|
val tx = x - gutterPx
|
||||||
|
val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||||
|
val within = tx - track * trackPx
|
||||||
|
val col = when {
|
||||||
|
within < notePx -> CellColumn.NOTE
|
||||||
|
within < notePx + velPx -> CellColumn.VELOCITY
|
||||||
|
else -> CellColumn.CHANNEL
|
||||||
|
}
|
||||||
|
return Triple(track, col, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(pattern, widthPx, heightPx) {
|
||||||
|
detectTapGestures(
|
||||||
|
// Tap: just move the cursor to the cell/column.
|
||||||
|
onTap = { off ->
|
||||||
|
hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
|
||||||
|
},
|
||||||
|
// Long-press: focus the cell and open its value editor popup.
|
||||||
|
onLongPress = { off ->
|
||||||
|
hitTest(off.x, off.y)?.let { (t, col, line) ->
|
||||||
|
vm.focusCell(t, line, col)
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
editorColumn = col
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// Subscribe the DRAW PHASE to edit revisions. Model mutations (edit,
|
||||||
|
// clear, DEL/paste) bump revision but touch no other snapshot state the
|
||||||
|
// draw reads, so without this a delete would not invalidate the Canvas
|
||||||
|
// even though composition re-ran (the draw lambda is memoised identical).
|
||||||
|
// Reading it here redraws on every edit, draw-only.
|
||||||
|
@Suppress("UNUSED_EXPRESSION") vm.revision
|
||||||
|
// Draw-phase reads: transport (playhead) and, via firstRow(), the cursor
|
||||||
|
// scroll position. A change to either invalidates THIS DRAW only — no
|
||||||
|
// recomposition, no relayout — which is what keeps the playhead smooth.
|
||||||
|
val transport = transportState.value
|
||||||
|
val top = firstRow()
|
||||||
|
// Selection rectangle (draw-phase reads → redraw as the cursor drags it).
|
||||||
|
val selActive = vm.hasSelection
|
||||||
|
val selTracks = vm.selTrackRange
|
||||||
|
val selLines = vm.selLineRange
|
||||||
|
|
||||||
|
// 1) Alternating vertical track tints + separators (full height).
|
||||||
|
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val x0 = gutterPx + t * trackPx
|
||||||
|
if (t % 2 == 1) {
|
||||||
|
drawRect(c.grid.copy(alpha = 0.35f), Offset(x0, 0f), Size(trackPx, heightPx))
|
||||||
|
}
|
||||||
|
drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Rows: beat/bar/playhead backgrounds + cursor + text.
|
||||||
|
for (screen in 0 until visibleRows) {
|
||||||
|
val line = top + screen
|
||||||
|
if (line >= pattern.length) break
|
||||||
|
val y = screen * rowPx
|
||||||
|
|
||||||
|
val rowColor = when {
|
||||||
|
transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)
|
||||||
|
line % sig.linesPerBar == 0 -> c.bar.copy(alpha = 0.7f)
|
||||||
|
line % sig.linesPerBeat == 0 -> c.beat.copy(alpha = 0.7f)
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
if (rowColor != Color.Transparent) {
|
||||||
|
drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gutter line number (hex).
|
||||||
|
drawCentered(glyphs, line.toString(16).uppercase().padStart(2, '0'),
|
||||||
|
styleGutter, 2f, y, gutterPx, rowPx)
|
||||||
|
|
||||||
|
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val cell = pattern.cell(t, line)
|
||||||
|
val baseX = gutterPx + t * trackPx
|
||||||
|
val focused = vm.cursorTrack == t && vm.cursorLine == line
|
||||||
|
|
||||||
|
// Selection wash (whole cell, under the cursor highlight).
|
||||||
|
if (selActive && t in selTracks && line in selLines) {
|
||||||
|
drawRect(c.accent.copy(alpha = 0.18f), Offset(baseX, y), Size(trackPx, rowPx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor cell highlight.
|
||||||
|
if (focused) {
|
||||||
|
val (cx, cw) = when (vm.cursorColumn) {
|
||||||
|
CellColumn.NOTE -> baseX to notePx
|
||||||
|
CellColumn.VELOCITY -> (baseX + notePx) to velPx
|
||||||
|
CellColumn.CHANNEL -> (baseX + notePx + velPx) to velPx
|
||||||
|
}
|
||||||
|
drawRect(c.accent.copy(alpha = 0.30f), Offset(cx, y), Size(cw, rowPx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE column reads bright even when empty (bright placeholder
|
||||||
|
// dots, per spec). VEL/CHAN use dim dots when empty and their
|
||||||
|
// coloured value when filled, so only the note column is bright.
|
||||||
|
drawCentered(glyphs, noteText(cell), styleText, baseX + 2f, y, notePx, rowPx)
|
||||||
|
drawCentered(glyphs, velText(cell), if (cell.isEmpty) styleDimNote else styleVel,
|
||||||
|
baseX + notePx + 2f, y, velPx, rowPx)
|
||||||
|
drawCentered(glyphs, chanText(cell), if (cell.isEmpty) styleDimNote else styleChan,
|
||||||
|
baseX + notePx + velPx + 2f, y, velPx, rowPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-press value editor, overlaid on the grid area.
|
||||||
|
editorColumn?.let { col ->
|
||||||
|
CellEditorPopup(vm, col) { editorColumn = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw [text] vertically centred in a cell of the given width/height. */
|
||||||
|
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCentered(
|
||||||
|
glyphs: GlyphCache,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
x: Float,
|
||||||
|
y: Float,
|
||||||
|
cellWidth: Float,
|
||||||
|
cellHeight: Float,
|
||||||
|
) {
|
||||||
|
val layout = glyphs.measure(text, style)
|
||||||
|
drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
|
||||||
|
private fun noteText(cell: Cell): String = when {
|
||||||
|
cell.isEmpty -> "···"
|
||||||
|
cell.isNoteOff -> "==="
|
||||||
|
else -> Pitch.name(cell.note)
|
||||||
|
}
|
||||||
|
private fun velText(cell: Cell): String =
|
||||||
|
if (cell.isEmpty) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||||
|
private fun chanText(cell: Cell): String =
|
||||||
|
if (cell.isEmpty) ".." else cell.channel.toString().padStart(2, '0')
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first tab. Two stacked halves:
|
||||||
|
* - upper: the transport toolbar (3 rows) + the demoscene 4-track pattern grid.
|
||||||
|
* - lower: the 8-lane arrangement piano-roll + its loop toolbar.
|
||||||
|
*
|
||||||
|
* The split is weighted toward the top so the pattern grid keeps room for at least
|
||||||
|
* one full 16-tick bar (16 rows) once the three toolbar rows are accounted for; the
|
||||||
|
* arrangement below is squashed to make that room.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TrackerScreen(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
// Upper section: transport + pattern grid. Weighted heavier than the lower
|
||||||
|
// half so >=16 grid rows fit under the (now three-row) toolbar.
|
||||||
|
Box(Modifier.weight(1.7f).fillMaxWidth()) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TransportToolbar(vm)
|
||||||
|
PatternGrid(vm, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.dp)
|
||||||
|
.background(c.accent),
|
||||||
|
)
|
||||||
|
// Lower section: the arrangement roll, squashed to give the grid its rows.
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
ArrangementRoll(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two-row transport toolbar at the top of the upper half. */
|
||||||
|
@Composable
|
||||||
|
private fun TransportToolbar(vm: AppViewModel) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val transport by vm.transport.collectAsState()
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so tempo/len/sig readouts refresh
|
||||||
|
val project = vm.project
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxWidth().background(c.surface).padding(6.dp)) {
|
||||||
|
// Row 1: transport + tempo + time signature + note-off insert.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
RetroButton("⧉ STOP", onClick = vm::stop)
|
||||||
|
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||||
|
active = transport.isPlaying, onClick = vm::playPause)
|
||||||
|
// Tempo nudger: tap -/+ around a readout.
|
||||||
|
Row {
|
||||||
|
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
|
||||||
|
Text(
|
||||||
|
" ${project.tempoBpm.toInt()} BPM ",
|
||||||
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(vertical = 6.dp),
|
||||||
|
)
|
||||||
|
RetroButton("+", onClick = { vm.setTempo(project.tempoBpm + 1) })
|
||||||
|
}
|
||||||
|
RetroDropdown(
|
||||||
|
label = "SIG",
|
||||||
|
options = TimeSignature.entries,
|
||||||
|
selected = project.timeSignature,
|
||||||
|
optionLabel = { it.label },
|
||||||
|
onSelect = vm::setTimeSignature,
|
||||||
|
)
|
||||||
|
// Insert a note-off ("===") at the cursor.
|
||||||
|
RetroButton("OFF ===", onClick = vm::noteOffFocused)
|
||||||
|
}
|
||||||
|
// Row 2: block + length + scale + root.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
RetroDropdown(
|
||||||
|
label = "BLK",
|
||||||
|
options = project.patterns.map { it.id },
|
||||||
|
selected = vm.activeBlock,
|
||||||
|
optionLabel = { "${it + 1}" },
|
||||||
|
onSelect = vm::setActiveBlock,
|
||||||
|
)
|
||||||
|
RetroDropdown(
|
||||||
|
label = "LEN",
|
||||||
|
options = project.timeSignature.lengthOptions,
|
||||||
|
selected = project.activePattern().length,
|
||||||
|
onSelect = vm::setPatternLength,
|
||||||
|
)
|
||||||
|
RetroDropdown(
|
||||||
|
label = "SCALE",
|
||||||
|
options = com.reactorcoremeltdown.sizzletracker.model.Scale.entries,
|
||||||
|
selected = project.scale,
|
||||||
|
optionLabel = { it.label },
|
||||||
|
onSelect = { project.scale = it; vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
RetroDropdown(
|
||||||
|
label = "ROOT",
|
||||||
|
options = (0..11).toList(),
|
||||||
|
selected = project.rootNote,
|
||||||
|
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
|
||||||
|
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Row 3: selection + clipboard edit operations. SEL toggles rectangular
|
||||||
|
// select mode (the cursor then drags out a region); the rest act on that
|
||||||
|
// region, or on the single focused cell when nothing is selected.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
RetroButton("SEL", active = vm.selectMode, onClick = vm::toggleSelectMode)
|
||||||
|
RetroButton("CUT", onClick = vm::cutSelection)
|
||||||
|
RetroButton("COPY", onClick = vm::copySelection)
|
||||||
|
RetroButton("PASTE", active = vm.canPaste, onClick = vm::pasteClipboard)
|
||||||
|
RetroButton("DEL", onClick = vm::deleteSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Pixel-art launcher glyph: a chunky 1/8th note drawn on the 108x108 adaptive
|
||||||
|
canvas (the safe zone is the centre 66x66). Colour is the retro amber that
|
||||||
|
the app's default theme uses. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<!-- Note stem (a vertical run of "pixels") -->
|
||||||
|
<path android:fillColor="#F2C14E"
|
||||||
|
android:pathData="M60,30 h12 v42 h-12 z" />
|
||||||
|
<!-- Note flag -->
|
||||||
|
<path android:fillColor="#F2C14E"
|
||||||
|
android:pathData="M72,30 h12 v12 h-12 z M72,42 h6 v12 h-6 z" />
|
||||||
|
<!-- Note head (a fat pixel square) -->
|
||||||
|
<path android:fillColor="#F2C14E"
|
||||||
|
android:pathData="M36,60 h24 v24 h-24 z" />
|
||||||
|
<!-- Two grid ticks to read as "tracker" -->
|
||||||
|
<path android:fillColor="#2E7D5B"
|
||||||
|
android:pathData="M24,30 h6 v6 h-6 z M24,48 h6 v6 h-6 z" />
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Only colors the Android framework needs before Compose starts. The full
|
||||||
|
theming system lives in ui/theme/Theme.kt. -->
|
||||||
|
<color name="window_background">#0B0D0E</color>
|
||||||
|
<color name="ic_launcher_background">#0B0D0E</color>
|
||||||
|
</resources>
|
||||||
12
app/src/main/res/values/strings.xml
Normal file
12
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Sizzletracker</string>
|
||||||
|
|
||||||
|
<!-- Tab titles -->
|
||||||
|
<string name="tab_tracker">TRACKER</string>
|
||||||
|
<string name="tab_mixer">MIX</string>
|
||||||
|
<string name="tab_toolbox">TOOLBOX</string>
|
||||||
|
<string name="tab_settings">SETUP</string>
|
||||||
|
|
||||||
|
<!-- Media notification channel -->
|
||||||
|
<string name="channel_playback">Playback</string>
|
||||||
|
</resources>
|
||||||
12
app/src/main/res/values/themes.xml
Normal file
12
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- The Android-XML theme only matters before Compose takes over (splash /
|
||||||
|
window background). All real UI styling lives in Kotlin (ui/theme/*.kt).
|
||||||
|
We inherit from a Material3 no-action-bar base and paint the window the
|
||||||
|
same near-black the Compose theme uses, so there is no flash on launch. -->
|
||||||
|
<style name="Theme.Sizzletracker" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
|
<item name="android:statusBarColor">@color/window_background</item>
|
||||||
|
<item name="android:navigationBarColor">@color/window_background</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
7
app/src/main/res/xml/usb_device_filter.xml
Normal file
7
app/src/main/res/xml/usb_device_filter.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Match any USB device that exposes an Audio class interface (class 0x01,
|
||||||
|
which covers USB-MIDI streaming and USB audio). The system uses this to
|
||||||
|
offer our app when such a device is attached. -->
|
||||||
|
<resources>
|
||||||
|
<usb-device class="1" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.reactorcoremeltdown.sizzletracker.io
|
||||||
|
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||||
|
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Verifies the `.sng` reader/writer against the reference desktop format. */
|
||||||
|
class SngFormatTest {
|
||||||
|
|
||||||
|
private val reference = """
|
||||||
|
version 1
|
||||||
|
bpm 120
|
||||||
|
sig 4 4
|
||||||
|
|
||||||
|
block A 16 4
|
||||||
|
roll ####
|
||||||
|
track T1 0
|
||||||
|
0 C-4 64 01
|
||||||
|
4 E-4 .. ..
|
||||||
|
8 OFF .. ..
|
||||||
|
endblock
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesReferenceExample() {
|
||||||
|
val p = SngFormat.import(reference)
|
||||||
|
|
||||||
|
assertEquals(120f, p.tempoBpm, 0.001f)
|
||||||
|
assertEquals(TimeSignature.FOUR_FOUR, p.timeSignature)
|
||||||
|
|
||||||
|
val block = p.patterns[0]
|
||||||
|
assertEquals("A", block.name)
|
||||||
|
assertEquals(16, block.length)
|
||||||
|
|
||||||
|
val c0 = block.cell(0, 0)
|
||||||
|
assertEquals(Pitch.parse("C-4"), c0.note)
|
||||||
|
assertEquals(0x64, c0.velocity) // velocity is hexadecimal
|
||||||
|
assertEquals(1, c0.channel)
|
||||||
|
|
||||||
|
val c4 = block.cell(0, 4)
|
||||||
|
assertEquals(Pitch.parse("E-4"), c4.note)
|
||||||
|
assertEquals(0x7F, c4.velocity) // ".." -> default (full MIDI velocity)
|
||||||
|
assertEquals(1, c4.channel) // ".." -> inherit track channel (0 -> 1)
|
||||||
|
|
||||||
|
assertTrue(block.cell(0, 8).isNoteOff)
|
||||||
|
|
||||||
|
// roll "####" enables block 0 on beats 0..3 of its own lane.
|
||||||
|
for (beat in 0..3) assertNotEquals(Arrangement.EMPTY, p.arrangement.patternAt(0, beat))
|
||||||
|
assertEquals(Arrangement.EMPTY, p.arrangement.patternAt(0, 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripsNotesAndRoll() {
|
||||||
|
val p = Project()
|
||||||
|
p.tempoBpm = 140f
|
||||||
|
p.timeSignature = TimeSignature.FIVE_FOUR
|
||||||
|
p.patterns[0].cell(0, 0).apply { note = 60; velocity = 0x40; channel = 3 }
|
||||||
|
p.patterns[0].cell(1, 5).apply { note = 67; velocity = 0x7F; channel = 2 }
|
||||||
|
p.arrangement.set(0, 0, 0)
|
||||||
|
p.arrangement.set(0, 1, 0)
|
||||||
|
|
||||||
|
val restored = SngFormat.import(SngFormat.export(p))
|
||||||
|
|
||||||
|
assertEquals(140f, restored.tempoBpm, 0.001f)
|
||||||
|
assertEquals(TimeSignature.FIVE_FOUR, restored.timeSignature)
|
||||||
|
restored.patterns[0].cell(0, 0).let {
|
||||||
|
assertEquals(60, it.note); assertEquals(0x40, it.velocity); assertEquals(3, it.channel)
|
||||||
|
}
|
||||||
|
restored.patterns[0].cell(1, 5).let {
|
||||||
|
assertEquals(67, it.note); assertEquals(0x7F, it.velocity); assertEquals(2, it.channel)
|
||||||
|
}
|
||||||
|
assertNotEquals(Arrangement.EMPTY, restored.arrangement.patternAt(0, 0))
|
||||||
|
assertNotEquals(Arrangement.EMPTY, restored.arrangement.patternAt(0, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
9
build.gradle.kts
Normal file
9
build.gradle.kts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Root build file. It declares the plugins used *somewhere* in the project but
|
||||||
|
// applies none of them here (apply false). Each module opts in to the ones it
|
||||||
|
// needs. Keeping this file thin is intentional.
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
342
docs/DEVELOPER_HANDOVER.md
Normal file
342
docs/DEVELOPER_HANDOVER.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# Sizzletracker (Android) — Developer Handover
|
||||||
|
|
||||||
|
This document is written for a developer who is **new to Android** and needs to
|
||||||
|
understand, build, and extend this codebase. Read it top to bottom once; after
|
||||||
|
that it works as a reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this app is
|
||||||
|
|
||||||
|
A retro, grid-based **music tracker** for Android. The visual language is a
|
||||||
|
monospace font + pixel blocks laid out on a strict grid (demoscene tracker
|
||||||
|
style). It is playable with **four equally-capable input methods**: touchscreen,
|
||||||
|
physical keyboard, gamepad, and MIDI (USB or Bluetooth).
|
||||||
|
|
||||||
|
The UI has four tabs:
|
||||||
|
|
||||||
|
| Tab | Package | What it does |
|
||||||
|
|-----|---------|--------------|
|
||||||
|
| **Tracker** | `ui/tracker` | Pattern grid (4 tracks) + arrangement piano-roll (8 lanes) |
|
||||||
|
| **Mix** | `ui/mixer` | 4-channel mixer: instrument + 4 FX, MIDI ch, volume, mute/solo |
|
||||||
|
| **Toolbox** | `ui/toolbox` | 16 slots of instruments/effects with a generated parameter editor |
|
||||||
|
| **Setup** | `ui/settings` | Project save/load, themes, gamepad & MIDI bindings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How to build & run
|
||||||
|
|
||||||
|
Requirements: **Android Studio** (Koala / 2024.1 or newer) which bundles a
|
||||||
|
JDK 17 and the Android SDK.
|
||||||
|
|
||||||
|
1. Open the project root in Android Studio. It will detect the Gradle build.
|
||||||
|
2. If prompted, let it **create the Gradle wrapper** (or run
|
||||||
|
`gradle wrapper --gradle-version 8.9` from a shell that has Gradle).
|
||||||
|
3. Let it download the SDK for `compileSdk 34`.
|
||||||
|
4. Pick a device/emulator running **Android 8.0 (API 26)** or newer and press
|
||||||
|
**Run**.
|
||||||
|
|
||||||
|
> The command line here (`./gradlew assembleDebug`) needs the wrapper JAR, which
|
||||||
|
> is generated by step 2. The wrapper JAR is intentionally not committed as a
|
||||||
|
> binary; generating it is a one-time step.
|
||||||
|
|
||||||
|
Key versions live in **one file**: `gradle/libs.versions.toml` (the "version
|
||||||
|
catalog"). Change a number there and the whole project follows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The big picture (architecture)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── Input sources ────────────┐
|
||||||
|
Touch ───▶│ (Compose gestures, call ViewModel) │
|
||||||
|
Keyboard ─▶ KeyboardInput ─┐ │
|
||||||
|
Gamepad ──▶ GamepadInput ──┤─▶ InputRouter (flow) ──┼──▶ AppViewModel
|
||||||
|
MIDI ─────▶ MidiInput ─────┘ │ │
|
||||||
|
└───────────────────────────────────────┘ │ reads/writes
|
||||||
|
▼
|
||||||
|
Project (the song)
|
||||||
|
▲
|
||||||
|
reads on audio thread │
|
||||||
|
AudioEngine ─────────┘ ──▶ AudioTrack ──▶ speakers
|
||||||
|
(own thread)
|
||||||
|
```
|
||||||
|
|
||||||
|
Three ideas hold it together:
|
||||||
|
|
||||||
|
1. **One neutral action type.** Every non-touch device is translated into an
|
||||||
|
`InputAction` (see `input/InputAction.kt`) and pushed through `InputRouter`.
|
||||||
|
The ViewModel reacts to those actions in exactly one place
|
||||||
|
(`AppViewModel.onAction`). Touch gestures call the same ViewModel methods
|
||||||
|
directly. That is *why* all four inputs are equally capable — features are
|
||||||
|
implemented once.
|
||||||
|
|
||||||
|
2. **One song object.** `model/Project.kt` holds the entire song in memory. The
|
||||||
|
UI edits it; the audio engine reads it; the file format serializes it.
|
||||||
|
|
||||||
|
3. **Two threads.** The UI runs on the main thread (Jetpack Compose). The sound
|
||||||
|
runs on a dedicated high-priority thread inside `AudioEngine`. They share the
|
||||||
|
`Project` object. Because the audio thread only ever *reads* simple `Int`/
|
||||||
|
`Float` fields, we accept the occasional benign race instead of locking (locks
|
||||||
|
would risk audio glitches). See §7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Package/file map
|
||||||
|
|
||||||
|
```
|
||||||
|
com.reactorcoremeltdown.sizzletracker
|
||||||
|
├── SizzleApp.kt Application = tiny DI container (Project, InputRouter, AudioEngine)
|
||||||
|
├── MainActivity.kt Hosts Compose UI; forwards HW key/motion events; starts MIDI + service
|
||||||
|
│
|
||||||
|
├── model/ Pure data, no Android imports (unit-test friendly)
|
||||||
|
│ ├── Music.kt Pitch, TimeSignature, Scale (incl. scale-step logic)
|
||||||
|
│ ├── Pattern.kt Cell, CellColumn, Pattern (4 tracks x N lines)
|
||||||
|
│ ├── Arrangement.kt Arrangement (8 lanes x 256 beats), LoopRegion A/B
|
||||||
|
│ ├── Mixer.kt Mixer, MixerChannel (4 channels)
|
||||||
|
│ ├── Toolbox.kt ToolboxType (all instruments+effects), ParamSpec, ToolboxSlot
|
||||||
|
│ └── Project.kt The whole song
|
||||||
|
│
|
||||||
|
├── input/ The unified input layer
|
||||||
|
│ ├── InputAction.kt The neutral action vocabulary
|
||||||
|
│ ├── InputRouter.kt Flow-based message bus
|
||||||
|
│ ├── KeyboardInput.kt KeyEvent -> InputAction (+ a 2-octave typing piano)
|
||||||
|
│ ├── GamepadInput.kt KeyEvent/MotionEvent -> InputAction (rebindable)
|
||||||
|
│ └── MidiInput.kt MIDI bytes -> InputAction (USB & Bluetooth via MidiManager)
|
||||||
|
│
|
||||||
|
├── audio/ The real-time sound engine (own thread)
|
||||||
|
│ ├── Transport.kt TransportState snapshot published to the UI
|
||||||
|
│ ├── SynthVoice.kt One NES-style voice: pulse/triangle/noise + ADSR
|
||||||
|
│ └── AudioEngine.kt AudioTrack + sample-accurate sequencer + voice mixing
|
||||||
|
│
|
||||||
|
├── io/ Human-readable text persistence
|
||||||
|
│ ├── PresetIo.kt Instrument/effect preset <-> KEY=VALUE text
|
||||||
|
│ └── ProjectIo.kt Whole song <-> .sng text
|
||||||
|
│
|
||||||
|
├── playback/
|
||||||
|
│ └── PlaybackService.kt Foreground service: background playback + media notification
|
||||||
|
│
|
||||||
|
└── ui/
|
||||||
|
├── AppViewModel.kt Screen state + THE single InputAction handler
|
||||||
|
├── App.kt Tab shell
|
||||||
|
├── theme/Theme.kt RetroPalette + monospace typography + SizzleTheme
|
||||||
|
├── components/Widgets.kt RetroButton, RetroDropdown, SectionLabel, PixelGlyph
|
||||||
|
├── tracker/ TrackerScreen, PatternGrid, ArrangementRoll
|
||||||
|
├── mixer/MixerScreen.kt
|
||||||
|
├── toolbox/ ToolboxScreen, ParamControl
|
||||||
|
└── settings/SettingsScreen.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Key concepts you must understand
|
||||||
|
|
||||||
|
### 5.1 Ticks, beats, bars
|
||||||
|
A tracker **line** (row) is one tick. The number of lines per beat is set by the
|
||||||
|
**time signature** (`model/Music.kt`):
|
||||||
|
|
||||||
|
- 3/4 → 3 lines per beat, lengths 12/24/48
|
||||||
|
- 4/4 → 4 lines per beat, lengths 16/32/64
|
||||||
|
- 5/4 → 5 lines per beat, lengths 20/40/80
|
||||||
|
|
||||||
|
`PatternGrid` tints every beat row and every bar row differently using
|
||||||
|
`line % linesPerBeat` and `line % linesPerBar`.
|
||||||
|
|
||||||
|
### 5.2 The "revision" redraw trick (and the strong-skipping rule)
|
||||||
|
The model classes are **plain** (not Compose-observable) so the audio thread can
|
||||||
|
read them cheaply. After any edit the ViewModel does `revision++`
|
||||||
|
(`AppViewModel.touched()`, exposed as `bumpForToolbar()` for direct model edits).
|
||||||
|
|
||||||
|
**The rule that makes this actually work:** the Compose compiler we use (2.0.20)
|
||||||
|
has **strong skipping** enabled by default. It will *skip* a composable whose
|
||||||
|
parameters are referentially unchanged — even "unstable" ones like `vm` — so a
|
||||||
|
`revision++` in a *parent* is NOT enough; a child that reads plain model data
|
||||||
|
would be skipped and only refresh on a tab switch (which recreates the subtree).
|
||||||
|
|
||||||
|
So: **every composable that reads plain, mutable model data must itself read
|
||||||
|
`vm.revision`** (e.g. `val rev = vm.revision`). Reading a snapshot state directly
|
||||||
|
subscribes that composable's restart scope, so `revision++` invalidates it
|
||||||
|
regardless of skipping. Where a composable has no `vm` (e.g. `SlotTile`), pass
|
||||||
|
`rev = vm.revision` as a parameter instead — a changed `Int` param defeats
|
||||||
|
skipping too. Observable state (cursor position, `transport`, `palette`) already
|
||||||
|
works without this because it is real Compose state.
|
||||||
|
|
||||||
|
If you edit the model and the screen doesn't update: (a) did you call a
|
||||||
|
`touched()`/`bumpForToolbar()` path, and (b) does the leaf that renders that data
|
||||||
|
read `vm.revision`? This was the cause of an early "UI only redraws on tab
|
||||||
|
switch" bug.
|
||||||
|
|
||||||
|
### 5.3 The note-entry gesture
|
||||||
|
Tap a cell → cursor moves there (`focusCell`). Drag up/down → `editFocused(dir)`:
|
||||||
|
- On the NOTE column, `Scale.step()` moves to the next in-scale pitch.
|
||||||
|
- On VELOCITY/CHANNEL it steps the number.
|
||||||
|
Every step fires a haptic tick. The same `editFocused` runs from keyboard PageUp/
|
||||||
|
Down, gamepad right-stick, and MIDI CC — one implementation, four inputs.
|
||||||
|
|
||||||
|
### 5.4 Toolbox devices are data, not classes
|
||||||
|
Instead of a class per instrument/effect, every device is one `ToolboxType`
|
||||||
|
enum entry that lists its `ParamSpec`s. A `ToolboxSlot` stores the chosen type +
|
||||||
|
a `name -> value` string map. The parameter editor UI and the preset text format
|
||||||
|
are both **generated** from that list. See §6 to add a new device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. How to extend
|
||||||
|
|
||||||
|
### Add a new instrument or effect
|
||||||
|
1. Add an entry to `ToolboxType` (`model/Toolbox.kt`) with its `ParamSpec` list.
|
||||||
|
2. Teach `AudioEngine` how to render/process it (today only `NES_SYNTH` is
|
||||||
|
rendered; see `synthParamsForTrack`). Add a branch there.
|
||||||
|
That's it — the Toolbox picker, parameter editor, and preset save/load all work
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
### Add a new input gesture/control
|
||||||
|
1. Add a case to `InputAction`.
|
||||||
|
2. Emit it from each source (`KeyboardInput`, `GamepadInput`, `MidiInput`) and/or
|
||||||
|
a Compose gesture.
|
||||||
|
3. Handle it once in `AppViewModel.onAction`.
|
||||||
|
|
||||||
|
### Add a new color theme
|
||||||
|
Add a `RetroPalette` to `RetroPalette.ALL` in `ui/theme/Theme.kt`. It appears in
|
||||||
|
the Settings theme picker immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Threading & performance notes
|
||||||
|
|
||||||
|
- The audio render loop is in `AudioEngine.renderLoop()` on a `MAX_PRIORITY`
|
||||||
|
thread. It **must never allocate or block**. Keep it that way.
|
||||||
|
- Timing is derived from *counting audio samples* (`samplesPerLine`), not from
|
||||||
|
timers, so playback never drifts.
|
||||||
|
- UI ⇄ audio sharing is lock-free by design (audio reads plain fields). This can
|
||||||
|
produce a one-frame-stale read during a simultaneous edit — harmless for a
|
||||||
|
tracker. If you ever add compound state that must be read atomically, publish it
|
||||||
|
as an immutable snapshot (like `TransportState`) rather than adding locks.
|
||||||
|
- Block size is `BLOCK_FRAMES = 192` (~4 ms @ 48 kHz). Lower = tighter latency,
|
||||||
|
higher = safer against underruns.
|
||||||
|
- **UI rendering**: the pattern grid (`PatternGrid`) and arrangement roll
|
||||||
|
(`ArrangementRoll`) are each drawn as ONE `Canvas` with a cached
|
||||||
|
`rememberTextMeasurer`, not a tree of per-cell composables. This is what keeps
|
||||||
|
the grid smooth while the playhead moves — a single draw pass instead of
|
||||||
|
recomposing hundreds of `Text` nodes each frame. If you add grid features, draw
|
||||||
|
them in the Canvas rather than adding child composables.
|
||||||
|
- **Arrangement model**: each of the 8 lanes is tied to its own block (lane i ⟷
|
||||||
|
block i; `Project.patterns` holds 8 blocks). Toggling a beat cell on lane i
|
||||||
|
enables block i at that beat; the sequencer plays it when the playhead crosses.
|
||||||
|
Tap a lane number (left column) to edit that block in the tracker above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. File formats
|
||||||
|
|
||||||
|
### Preset (`PresetIo`)
|
||||||
|
```
|
||||||
|
SIZZLE-PRESET 1
|
||||||
|
TYPE=NES_SYNTH
|
||||||
|
NAME=Lead
|
||||||
|
wave=Pulse25
|
||||||
|
attack=0.01
|
||||||
|
...
|
||||||
|
```
|
||||||
|
Copy/paste from the Toolbox parameter editor uses this exact text.
|
||||||
|
|
||||||
|
### Project `.sng` (`ProjectIo`)
|
||||||
|
Line-oriented, sectioned (`[PATTERN ...]`, `[ARRANGEMENT ...]`, `[MIXER]`,
|
||||||
|
`[TOOLBOX]`). Only non-empty cells are written. It is fully round-trip safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Current status — implemented vs. TODO
|
||||||
|
|
||||||
|
This is an honest map so you know where the edges are. The **architecture and
|
||||||
|
all four tabs are in place and interactive**; several deep features are scaffolded
|
||||||
|
with a clear seam to finish them.
|
||||||
|
|
||||||
|
### Implemented and working
|
||||||
|
- Full 4-tab UI, retro monospace theme (+ theme switching), tab navigation.
|
||||||
|
- Tracker pattern grid: beat/bar coloring, cursor, tap-to-focus, drag-to-edit
|
||||||
|
with haptics, scale-aware note entry, transport toolbar, length/scale/root.
|
||||||
|
- Arrangement roll: 8 lanes, place/clear blocks, playhead follow, loop toolbar
|
||||||
|
with A/B enable + repeat counts.
|
||||||
|
- Mixer: 4 strips, instrument/FX routing to toolbox, MIDI ch, volume, mute/solo.
|
||||||
|
- Toolbox: 16 slots, device picker, **auto-generated** parameter editor, preset
|
||||||
|
copy/paste (text).
|
||||||
|
- Audio engine: dedicated thread, sample-accurate sequencer, NES-style synth
|
||||||
|
(pulse/tri/noise + ADSR), per-track mute/solo/volume, live note audition.
|
||||||
|
- **Arrangement-driven playback** (milestone 1 ✓): the sequencer walks a
|
||||||
|
pre-expanded beat playlist (with A/B loop repeats) across all 8 lanes; each
|
||||||
|
lane keeps its own within-pattern cursor so multi-beat blocks advance through
|
||||||
|
the pattern. Falls back to looping the active pattern when the arrangement is
|
||||||
|
empty. Voices are a lane×track matrix so lanes layer polyphonically.
|
||||||
|
- **Per-channel effect DSP** (milestone 3 ✓): each mixer channel runs an insert
|
||||||
|
chain built from its FX slots — Tape Delay, Digital Reverb, Filter (LP/HP/BP),
|
||||||
|
Bitcrusher, and 10-band EQ are real processors in `audio/Effects.kt`. Effect
|
||||||
|
parameters update live per audio block; add/remove rebuilds the chain.
|
||||||
|
- **Sampler instrument** ✓: WAV import (SAF file picker) + ad-hoc mic/USB
|
||||||
|
recorder (`audio/SampleRecorder.kt`), decoded to a mono cache
|
||||||
|
(`audio/SampleStore.kt`), played pitched + sliced by `audio/SampleVoice.kt`.
|
||||||
|
Its editor (`ui/toolbox/SamplerEditor.kt`) shows a waveform with two draggable
|
||||||
|
slice markers, start-octave + volume, and a two-octave audition keyboard. Each
|
||||||
|
(lane, track) has both a synth and a sample voice; the channel instrument type
|
||||||
|
selects which one a note triggers.
|
||||||
|
- Input: keyboard (nav + typing-piano + transport), gamepad (buttons + sticks +
|
||||||
|
hat), MIDI in (note + CC, USB & BT), all via the unified router.
|
||||||
|
- **MIDI-learn & gamepad rebind** ✓: the Settings tab lists every bindable action
|
||||||
|
with its current binding and a Learn button; arming it sets the router's
|
||||||
|
`learnMode`, and the next unbound CC / gamepad button is written into
|
||||||
|
`MidiInput.ccBindings` / `GamepadInput.bindings` (the handlers are now app
|
||||||
|
singletons shared with Settings). Bindings are clearable per action.
|
||||||
|
- **MIDI effects (Transposer / Arpeggiator / LFO)** ✓: Transposer shifts notes by
|
||||||
|
its semitone amount; the Arpeggiator octave-cycles a captured note
|
||||||
|
(Up/Down/UpDown/Random, 1–4 octaves) at a tempo-synced division
|
||||||
|
(`AudioEngine.advanceArps`); the LFO (`applyLfos`) modulates a chosen target
|
||||||
|
parameter of another device around a captured centre (Sine/Tri/Saw/Square/S&H),
|
||||||
|
with a target picker in its editor (`ui/toolbox/LfoEditor.kt`).
|
||||||
|
- **USB / audio device selection** ✓: Settings → Audio Devices lists the system's
|
||||||
|
output and input devices; choosing one routes the engine's `AudioTrack` and the
|
||||||
|
recorder's `AudioRecord` via `setPreferredDevice` (API 28+), so a USB DAC / USB
|
||||||
|
audio interface can be used for playback and recording.
|
||||||
|
- **Full MediaSession** ✓: `playback/PlaybackService` is now a media3
|
||||||
|
`MediaSessionService` hosting a session that wraps the engine via
|
||||||
|
`EnginePlayer` (`SimpleBasePlayer`). This gives lock-screen controls,
|
||||||
|
Bluetooth/headset media buttons, and the media notification for free, mirroring
|
||||||
|
the on-screen transport.
|
||||||
|
- Persistence: preset text I/O, a full-fidelity internal project text format
|
||||||
|
(`ProjectIo`, keeps mixer/FX/instruments), AND **desktop-compatible `.sng`**
|
||||||
|
read/write (`io/SngFormat.kt`) matching the reference sizzletracker line format
|
||||||
|
(`version`/`bpm`/`sig`, `block`…`endblock` with `roll`/`track`/steps) — covered
|
||||||
|
by passing JVM unit tests in `app/src/test`.
|
||||||
|
- Background playback foreground service with Play/Pause/Stop notification.
|
||||||
|
- **Oboe / AAudio native output** ✓ (opt-in): `src/main/cpp/native_audio.cpp`
|
||||||
|
opens a low-latency Oboe stream whose real-time callback pulls audio via JNI
|
||||||
|
(`audio/NativeAudioBridge.kt`) from the SAME `AudioEngine.fillBlock` the
|
||||||
|
AudioTrack loop uses — so both backends produce identical sound, only the
|
||||||
|
delivery path differs. Toggle it in Settings → Audio Devices ("Low-latency
|
||||||
|
engine (Oboe)"); it falls back to AudioTrack if the native lib is absent.
|
||||||
|
Built via **NDK r27** (`ndkVersion` in `app/build.gradle.kts`) + CMake + the
|
||||||
|
`com.google.oboe:oboe:1.10.0` prefab AAR. All packaged `.so` files are **16 KB
|
||||||
|
page-aligned** (Android 15 / Google Play requirement): NDK r27 aligns ELF load
|
||||||
|
segments to 16 KB and ships a 16 KB-aligned `libc++_shared.so`, Oboe 1.10.0's
|
||||||
|
lib is aligned, our `CMakeLists.txt` also passes `-Wl,-z,max-page-size=16384`,
|
||||||
|
and AGP stores each `.so` at a 16 KB-aligned offset in the APK. (Verify with
|
||||||
|
`llvm-readelf -l <lib>.so` → LOAD `Align 0x4000`.)
|
||||||
|
|
||||||
|
### Scaffolded — remaining
|
||||||
|
1. **SF2 / XI loader instrument.** The Sampler (WAV) is done; the SoundFont/XI
|
||||||
|
loader still falls back to the synth. Parse `.sf2`/`.xi`, feed the extracted
|
||||||
|
PCM through the existing `SampleStore`/`SampleVoice` path. (This was not in the
|
||||||
|
original milestone list; noted for completeness.)
|
||||||
|
|
||||||
|
All spec milestones are implemented. Next steps are polish/hardening: routing the
|
||||||
|
Oboe backend through the chosen output device (Oboe `setDeviceId`), a full C++
|
||||||
|
port of the synth/mixer to remove JVM/GC from the native audio callback, and the
|
||||||
|
SF2/XI loader above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing suggestions
|
||||||
|
- Pure `model/` classes (`Scale.step`, `TimeSignature`, `Pattern.resize`,
|
||||||
|
`ProjectIo` round-trip, `PresetIo` round-trip) are plain Kotlin — cover them
|
||||||
|
with JVM unit tests first; they need no device.
|
||||||
|
- For the engine, render a few blocks with a known pattern and assert the voice
|
||||||
|
triggers on the expected sample offsets.
|
||||||
15
gradle.properties
Normal file
15
gradle.properties
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# JVM options for the Gradle daemon. 2 GB heap is plenty for this project.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
|
||||||
|
# Run independent tasks in parallel and cache outputs — faster incremental builds.
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
|
||||||
|
# AndroidX is the modern support-library namespace. Required for Compose.
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Kotlin code style used by the IDE formatter.
|
||||||
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
# Only generate the R (resources) class per-module that actually needs it.
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
45
gradle/libs.versions.toml
Normal file
45
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Gradle "version catalog". This single file is the ONE place where library
|
||||||
|
# versions live. Instead of scattering version numbers across build files, code
|
||||||
|
# refers to them by name, e.g. libs.androidx.core.ktx. Update a number here and
|
||||||
|
# the whole project picks it up.
|
||||||
|
|
||||||
|
[versions]
|
||||||
|
agp = "8.5.2" # Android Gradle Plugin
|
||||||
|
kotlin = "2.0.20" # Kotlin language + compiler
|
||||||
|
composeCompiler = "2.0.20" # Compose compiler is now bundled with Kotlin plugin
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
lifecycle = "2.8.6"
|
||||||
|
activityCompose = "1.9.2"
|
||||||
|
composeBom = "2024.09.03" # Bill Of Materials: pins all Compose libs together
|
||||||
|
media3 = "1.4.1" # MediaSession / background playback
|
||||||
|
datastore = "1.1.1" # Persisting settings/preferences
|
||||||
|
coroutines = "1.9.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||||
|
|
||||||
|
# Compose — versions come from the BOM below, so no version.ref here.
|
||||||
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
|
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
|
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||||
|
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
|
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
|
||||||
|
# Background playback + media notification controls
|
||||||
|
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
||||||
|
androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
|
||||||
|
|
||||||
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
||||||
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "composeCompiler" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
252
gradlew
vendored
Executable file
252
gradlew
vendored
Executable file
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
28
samples/demo.sng
Normal file
28
samples/demo.sng
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
SIZZLETRACKER-SNG 1
|
||||||
|
NAME=demo
|
||||||
|
TEMPO=120.0
|
||||||
|
SIG=FOUR_FOUR
|
||||||
|
SCALE=MINOR
|
||||||
|
ROOT=9
|
||||||
|
ACTIVE=0
|
||||||
|
[PATTERN id=0 name=LEAD len=16]
|
||||||
|
0,0,57,128,1
|
||||||
|
0,4,60,128,1
|
||||||
|
0,8,64,110,1
|
||||||
|
0,12,60,120,1
|
||||||
|
1,0,45,90,2
|
||||||
|
1,8,45,90,2
|
||||||
|
[ARRANGEMENT len=16 loop=0]
|
||||||
|
0,0,0
|
||||||
|
0,1,0
|
||||||
|
0,2,0
|
||||||
|
0,3,0
|
||||||
|
[REGION A en=0 s=0 e=0 r=2]
|
||||||
|
[REGION B en=0 s=0 e=0 r=2]
|
||||||
|
[MIXER]
|
||||||
|
0,0,-1,-1,-1,-1,1,0.8,0,0
|
||||||
|
1,-1,-1,-1,-1,-1,2,0.8,0,0
|
||||||
|
2,-1,-1,-1,-1,-1,3,0.8,0,0
|
||||||
|
3,-1,-1,-1,-1,-1,4,0.8,0,0
|
||||||
|
[TOOLBOX]
|
||||||
|
0=NES_SYNTH;Lead;wave=Pulse25,attack=0.01,decay=0.15,sustain=0.6,release=0.1
|
||||||
24
settings.gradle.kts
Normal file
24
settings.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Top-level Gradle settings: declares where plugins/dependencies are fetched from
|
||||||
|
// and which sub-projects (modules) make up this build. We keep everything in a
|
||||||
|
// single ":app" module to stay approachable for newcomers.
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
// Fail the build if a module tries to declare its own repositories — all
|
||||||
|
// dependencies must come from the two repos below. This keeps things simple.
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Sizzletracker"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user