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