From 3cef1b4e853d370bf9fa41da15090a4fcda9adf7 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Mon, 13 Jul 2026 22:15:13 +0200 Subject: [PATCH] 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 --- .gitignore | 16 + README.md | 60 ++ app/build.gradle.kts | 121 +++ app/proguard-rules.pro | 3 + app/src/main/AndroidManifest.xml | 66 ++ app/src/main/cpp/CMakeLists.txt | 20 + app/src/main/cpp/native_audio.cpp | 111 +++ .../sizzletracker/MainActivity.kt | 122 +++ .../sizzletracker/SizzleApp.kt | 41 ++ .../sizzletracker/audio/AudioEngine.kt | 697 ++++++++++++++++++ .../sizzletracker/audio/Effects.kt | 246 +++++++ .../sizzletracker/audio/NativeAudioBridge.kt | 39 + .../sizzletracker/audio/SampleRecorder.kt | 76 ++ .../sizzletracker/audio/SampleStore.kt | 100 +++ .../sizzletracker/audio/SampleVoice.kt | 76 ++ .../sizzletracker/audio/SynthVoice.kt | 108 +++ .../sizzletracker/audio/Transport.kt | 14 + .../sizzletracker/input/GamepadInput.kt | 102 +++ .../sizzletracker/input/InputAction.kt | 53 ++ .../sizzletracker/input/InputRouter.kt | 29 + .../sizzletracker/input/KeyboardInput.kt | 67 ++ .../sizzletracker/input/MidiInput.kt | 118 +++ .../sizzletracker/io/PresetIo.kt | 66 ++ .../sizzletracker/io/ProjectIo.kt | 213 ++++++ .../sizzletracker/io/SngFormat.kt | 140 ++++ .../sizzletracker/model/Arrangement.kt | 52 ++ .../sizzletracker/model/Mixer.kt | 36 + .../sizzletracker/model/Music.kt | 109 +++ .../sizzletracker/model/Pattern.kt | 90 +++ .../sizzletracker/model/Project.kt | 47 ++ .../sizzletracker/model/Toolbox.kt | 187 +++++ .../sizzletracker/playback/EnginePlayer.kt | 81 ++ .../sizzletracker/playback/PlaybackService.kt | 56 ++ .../sizzletracker/ui/App.kt | 74 ++ .../sizzletracker/ui/AppViewModel.kt | 441 +++++++++++ .../sizzletracker/ui/components/GlyphCache.kt | 32 + .../sizzletracker/ui/components/Widgets.kt | 127 ++++ .../sizzletracker/ui/mixer/MixerScreen.kt | 147 ++++ .../ui/settings/SettingsScreen.kt | 377 ++++++++++ .../sizzletracker/ui/theme/Theme.kt | 104 +++ .../sizzletracker/ui/toolbox/LfoEditor.kt | 66 ++ .../sizzletracker/ui/toolbox/ParamControl.kt | 58 ++ .../sizzletracker/ui/toolbox/SamplerEditor.kt | 176 +++++ .../sizzletracker/ui/toolbox/ToolboxScreen.kt | 307 ++++++++ .../ui/tracker/ArrangementRoll.kt | 255 +++++++ .../ui/tracker/CellEditorPopup.kt | 183 +++++ .../sizzletracker/ui/tracker/PatternGrid.kt | 239 ++++++ .../sizzletracker/ui/tracker/TrackerScreen.kt | 146 ++++ .../res/drawable/ic_launcher_foreground.xml | 23 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 12 + app/src/main/res/values/themes.xml | 12 + app/src/main/res/xml/usb_device_filter.xml | 7 + .../sizzletracker/io/SngFormatTest.kt | 80 ++ build.gradle.kts | 9 + docs/DEVELOPER_HANDOVER.md | 342 +++++++++ gradle.properties | 15 + gradle/libs.versions.toml | 45 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 +++++++ gradlew.bat | 94 +++ samples/demo.sng | 28 + settings.gradle.kts | 24 + 66 files changed, 7062 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/cpp/CMakeLists.txt create mode 100644 app/src/main/cpp/native_audio.cpp create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/NativeAudioBridge.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleRecorder.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/MidiInput.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Music.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/EnginePlayer.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/PlaybackService.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/LfoEditor.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/CellEditorPopup.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt create mode 100644 app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/usb_device_filter.xml create mode 100644 app/src/test/java/com/reactorcoremeltdown/sizzletracker/io/SngFormatTest.kt create mode 100644 build.gradle.kts create mode 100644 docs/DEVELOPER_HANDOVER.md create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 samples/demo.sng create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf43971 --- /dev/null +++ b/.gitignore @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..140d4f6 --- /dev/null +++ b/README.md @@ -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). + +

+ platform + language + ui +

+ +## 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..278f41b --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fa9d3bb --- /dev/null +++ b/app/proguard-rules.pro @@ -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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8ec8f06 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..68bed5c --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -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") diff --git a/app/src/main/cpp/native_audio.cpp b/app/src/main/cpp/native_audio.cpp new file mode 100644 index 0000000..fc61046 --- /dev/null +++ b/app/src/main/cpp/native_audio.cpp @@ -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 +#include +#include +#include + +#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(&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(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 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(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; +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt new file mode 100644 index 0000000..68e332a --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/MainActivity.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt new file mode 100644 index 0000000..5c49435 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/SizzleApp.kt @@ -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) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt new file mode 100644 index 0000000..81a91f9 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/AudioEngine.kt @@ -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() } + + /** 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 = _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() + 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 + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt new file mode 100644 index 0000000..ee03ad1 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Effects.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/NativeAudioBridge.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/NativeAudioBridge.kt new file mode 100644 index 0000000..f38f918 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/NativeAudioBridge.kt @@ -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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleRecorder.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleRecorder.kt new file mode 100644 index 0000000..b5ff748 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleRecorder.kt @@ -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() + + /** 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) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt new file mode 100644 index 0000000..9d9b524 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleStore.kt @@ -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() + + 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) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt new file mode 100644 index 0000000..a3db2b1 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SampleVoice.kt @@ -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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt new file mode 100644 index 0000000..eaa0d17 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/SynthVoice.kt @@ -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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt new file mode 100644 index 0000000..c5af1d9 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/audio/Transport.kt @@ -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, +) diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt new file mode 100644 index 0000000..c359cc6 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/GamepadInput.kt @@ -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 = 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 = 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", + ) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt new file mode 100644 index 0000000..55eb73c --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputAction.kt @@ -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 } diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt new file mode 100644 index 0000000..b6ebe85 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/InputRouter.kt @@ -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(extraBufferCapacity = 128) + val actions: SharedFlow = _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) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt new file mode 100644 index 0000000..b24deaa --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/KeyboardInput.kt @@ -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 = 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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/MidiInput.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/MidiInput.kt new file mode 100644 index 0000000..7ac2adb --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/input/MidiInput.kt @@ -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 = mutableMapOf(), +) { + private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager + private val handler = Handler(Looper.getMainLooper()) + private val openPorts = mutableListOf() + + /** 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 +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt new file mode 100644 index 0000000..80782cd --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/PresetIo.kt @@ -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() + 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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt new file mode 100644 index 0000000..8a988f4 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/ProjectIo.kt @@ -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 = + 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) { + 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]) + } + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt new file mode 100644 index 0000000..9f1b0f5 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/io/SngFormat.kt @@ -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) { + 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) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt new file mode 100644 index 0000000..851fb61 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Arrangement.kt @@ -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 = 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) +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt new file mode 100644 index 0000000..f420d24 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Mixer.kt @@ -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 = 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) +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Music.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Music.kt new file mode 100644 index 0000000..03e4c50 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Music.kt @@ -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, +) { + 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) { + 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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt new file mode 100644 index 0000000..12fdd9a --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Pattern.kt @@ -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> = + 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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt new file mode 100644 index 0000000..4212088 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Project.kt @@ -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 = 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 = 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 + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt new file mode 100644 index 0000000..6a57b67 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/model/Toolbox.kt @@ -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 = emptyList(), +) { + val isEnum: Boolean get() = choices.isNotEmpty() +} + +enum class ToolboxType( + val kind: ToolboxKind, + val displayName: String, + val params: List, +) { + // ---------- 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 = + 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 = 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 } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/EnginePlayer.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/EnginePlayer.kt new file mode 100644 index 0000000..05d257b --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/EnginePlayer.kt @@ -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() +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/PlaybackService.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/PlaybackService.kt new file mode 100644 index 0000000..e8c1b89 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/playback/PlaybackService.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt new file mode 100644 index 0000000..1d8c196 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/App.kt @@ -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, + ), + ) + } + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt new file mode 100644 index 0000000..c9e17be --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/AppViewModel.kt @@ -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>? = 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(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(null); private set + var inputDeviceId by mutableStateOf(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] + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt new file mode 100644 index 0000000..c87ddca --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/GlyphCache.kt @@ -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(6) + private val byStyle = ArrayList>(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) } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt new file mode 100644 index 0000000..a779487 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/components/Widgets.kt @@ -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 RetroDropdown( + label: String, + options: List, + 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, 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), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt new file mode 100644 index 0000000..eb12035 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/mixer/MixerScreen.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..1b6a6bf --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/settings/SettingsScreen.kt @@ -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> = 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, + 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("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) } + } + } + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt new file mode 100644 index 0000000..3899b77 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/theme/Theme.kt @@ -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) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/LfoEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/LfoEditor.kt new file mode 100644 index 0000000..55d8bf9 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/LfoEditor.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt new file mode 100644 index 0000000..bfc1054 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ParamControl.kt @@ -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) diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt new file mode 100644 index 0000000..b3259bb --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/SamplerEditor.kt @@ -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 { + 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 +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt new file mode 100644 index 0000000..f42bfab --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/toolbox/ToolboxScreen.kt @@ -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(null) } + var picking by remember { mutableStateOf(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), + ) +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt new file mode 100644 index 0000000..987542d --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/ArrangementRoll.kt @@ -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 +} diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/CellEditorPopup.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/CellEditorPopup.kt new file mode 100644 index 0000000..1f876b9 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/CellEditorPopup.kt @@ -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') diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt new file mode 100644 index 0000000..ad13907 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/PatternGrid.kt @@ -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(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? { + 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') diff --git a/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt new file mode 100644 index 0000000..3332707 --- /dev/null +++ b/app/src/main/java/com/reactorcoremeltdown/sizzletracker/ui/tracker/TrackerScreen.kt @@ -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) + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..9be1479 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3f4b40a --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #0B0D0E + #0B0D0E + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7588bf8 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + Sizzletracker + + + TRACKER + MIX + TOOLBOX + SETUP + + + Playback + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8e752aa --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/xml/usb_device_filter.xml b/app/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 0000000..1d52f95 --- /dev/null +++ b/app/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/test/java/com/reactorcoremeltdown/sizzletracker/io/SngFormatTest.kt b/app/src/test/java/com/reactorcoremeltdown/sizzletracker/io/SngFormatTest.kt new file mode 100644 index 0000000..273fa6b --- /dev/null +++ b/app/src/test/java/com/reactorcoremeltdown/sizzletracker/io/SngFormatTest.kt @@ -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)) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..eb721cd --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/docs/DEVELOPER_HANDOVER.md b/docs/DEVELOPER_HANDOVER.md new file mode 100644 index 0000000..d7a739a --- /dev/null +++ b/docs/DEVELOPER_HANDOVER.md @@ -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 .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. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..28d614e --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..518ec46 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2c3521197d7c4586c843d1d3e9090525f1898cde GIT binary patch literal 43504 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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 TZiJi*F`$tkWLn) literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/samples/demo.sng b/samples/demo.sng new file mode 100644 index 0000000..3447f69 --- /dev/null +++ b/samples/demo.sng @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e327cc8 --- /dev/null +++ b/settings.gradle.kts @@ -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")