commit 3cef1b4e853d370bf9fa41da15090a4fcda9adf7 Author: Reactorcoremeltdown Date: Mon Jul 13 22:15:13 2026 +0200 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 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 0000000..2c35211 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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")