Initial commit: Sizzletracker Android music tracker

A retro, grid-based music tracker for Android (Kotlin + Jetpack Compose,
single Activity) with four equally-capable input methods (touch, keyboard,
gamepad, MIDI) and four tabs: tracker, mixer, toolbox, settings.

Highlights:
- Tracker: Canvas-drawn 4-track pattern grid over an 8-lane arrangement roll,
  with a glyph cache and draw-phase state reads so the playhead and edits
  redraw without per-frame recomposition.
- Audio: sample-accurate sequencer feeding a shared AudioEngine, driven by
  either a Kotlin AudioTrack loop or native Oboe/AAudio via JNI (16 KB-aligned
  native libs). media3 MediaSession for lock-screen/headset transport.
- Toolbox: 16 instrument/effect slots with a 2-octave audition keyboard;
  single-tap select, double-tap edit, long-press clear.
- Note entry: long-press cell popups (piano keyboard / value steppers) plus
  keyboard/gamepad stepping that resumes from the last note/channel entered.
  Velocity capped at 0x7F, channel at 16.
- Selection/clipboard (cut/copy/paste/delete) and .sng import/export
  compatible with the reference desktop tool.
- A `profile` build type (non-debuggable, debug-signed) for realistic
  on-device performance testing.
- Developer handover documentation under docs/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-13 22:15:13 +02:00
commit 3cef1b4e85
66 changed files with 7062 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Android / Gradle / IDE build artifacts
.gradle/
build/
/app/build/
local.properties
captures/
.externalNativeBuild/
.cxx/
# Android Studio / IntelliJ
.idea/
*.iml
.DS_Store
# NOTE: gradle/wrapper/gradle-wrapper.jar IS committed (Gradle's recommended
# practice) so ./gradlew works on a fresh clone with no extra setup.

60
README.md Normal file
View File

@@ -0,0 +1,60 @@
# Sizzletracker (Android)
A retro, grid-based **music tracker** for Android — monospace UI, pixel icons,
demoscene-style pattern editing — playable equally with **touch, keyboard,
gamepad, and MIDI** (USB or Bluetooth).
<p>
<img alt="platform" src="https://img.shields.io/badge/platform-Android%208%2B-0b0d0e">
<img alt="language" src="https://img.shields.io/badge/kotlin-2.0-7cfc9a">
<img alt="ui" src="https://img.shields.io/badge/UI-Jetpack%20Compose-f2c14e">
</p>
## Features at a glance
- **Tracker tab** — 4-track pattern grid (note / velocity-hex / MIDI-channel per
track), beat & bar coloring, tap-to-focus + drag-to-edit with haptics and
scale-aware note entry; plus an 8-lane arrangement piano-roll (up to 256 beats)
with A/B loop regions.
- **Mix tab** — 4 channels, each with an instrument + 4 FX slots, MIDI channel,
volume, mute/solo.
- **Toolbox tab** — 16 slots for instruments (NES synth, sampler, SF2/XI) and
effects (arp, transposer, LFO, tape delay, reverb, filters, 10-band EQ,
bitcrusher). Every device has an auto-generated editor and text presets.
- **Setup tab** — `.sng` project save/load, color themes (with import/export),
gamepad bindings, and searchable/collapsible MIDI bindings.
- **Background playback** with a media notification (Play/Pause/Stop).
- **Two-thread design** — Compose UI on the main thread, a sample-accurate sound
engine on its own high-priority thread.
## Build & run
Open the project in **Android Studio** (2024.1+), let it create the Gradle
wrapper if prompted, and Run on an **API 26+** device or emulator.
CLI (after the wrapper exists — `gradle wrapper --gradle-version 8.9`):
```
./gradlew assembleDebug
```
## Where to start reading
See **[docs/DEVELOPER_HANDOVER.md](docs/DEVELOPER_HANDOVER.md)** — a from-scratch
tour written for a developer new to Android: architecture, the file map, the
"one action / one song / two threads" model, how to add an instrument or input,
and an honest **implemented-vs-TODO** list.
Fastest orientation in code:
`model/Project.kt``input/InputAction.kt``ui/AppViewModel.kt`
`audio/AudioEngine.kt`.
## Project format
Songs use a human-readable `.sng` text format (see `io/ProjectIo.kt`), aligned in
spirit with the desktop
[Sizzletracker](https://github.com/reactorcoremeltdown/sizzletracker); a byte-exact
compatibility shim is a listed TODO in the handover doc.
## License
TBD by the project owner.

121
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,121 @@
// Build configuration for the single application module. This is where we turn
// on Compose, set the min/target Android versions, and list dependencies.
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.reactorcoremeltdown.sizzletracker"
compileSdk = 34
// Native (Oboe) low-latency output backend; see src/main/cpp.
// NDK r27+ builds native libs 16 KB-page-aligned by default and ships a
// 16 KB-aligned libc++_shared.so (required for Android 15 / Google Play).
ndkVersion = "27.2.12479018"
defaultConfig {
applicationId = "com.reactorcoremeltdown.sizzletracker"
// minSdk 26 (Android 8.0) is the floor for the native MIDI API
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake { arguments += "-DANDROID_STL=c++_shared" }
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
buildTypes {
release {
// Shrinking is off by default so newcomers get readable stack traces.
// Flip to true once the app stabilises.
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
// A realistic-performance variant for testing UI responsiveness on-device.
// The `debug` build is `debuggable = true`, which makes ART run the app
// un-optimised and makes Compose several times slower — so measuring UI
// "snappiness" on a debug build is misleading. This variant flips
// debuggability off (the single biggest perf lever) while reusing the debug
// signing key, so it still installs without a release keystore. R8/shrinking
// stays off to avoid needing JNI/Compose keep-rules for now.
create("profile") {
initWith(getByName("debug"))
isDebuggable = false
isJniDebuggable = false
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += "debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
prefab = true // exposes the Oboe AAR's native headers/lib to CMake
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Compose UI toolkit — the BOM keeps every Compose artifact on one version.
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling)
// Background playback + media-style notification transport controls.
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.common)
// Persistence for settings & themes.
implementation(libs.androidx.datastore.preferences)
// Coroutines for the audio/sequencer background work.
implementation(libs.kotlinx.coroutines.android)
// Oboe: low-latency native audio (AAudio) — the native output backend.
// 1.9.3+ ships 16 KB-page-aligned .so files (required by Android 15 / Play).
implementation("com.google.oboe:oboe:1.10.0")
// JVM unit tests for the pure model/io logic (no device needed).
testImplementation("junit:junit:4.13.2")
}

3
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
# ProGuard/R8 rules for release builds. Minification is currently disabled in
# build.gradle.kts (isMinifyEnabled = false), so this file is intentionally empty.
# Add keep-rules here if/when you enable shrinking and hit reflection issues.

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- ===== Permissions =====
RECORD_AUDIO : ad-hoc sample recorder (mic / USB audio in).
FOREGROUND_SERVICE : keep the sequencer alive while the app is backgrounded.
FOREGROUND_SERVICE_MEDIA_PLAYBACK : Android 14 requires a typed FGS.
POST_NOTIFICATIONS : show the media transport notification (Android 13+).
BLUETOOTH_CONNECT : Bluetooth MIDI + gamepad on Android 12+. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Hardware we can *use* but do not *require* (so the app still installs on
devices without them). Code must always feature-detect at runtime. -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<uses-feature android:name="android.software.midi" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application
android:name=".SizzleApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="false"
android:theme="@style/Theme.Sizzletracker">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|navigation|keyboard"
android:launchMode="singleTop"
android:theme="@style/Theme.Sizzletracker">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Announce that we can be a USB-MIDI aware app; the system routes
attach events here so we can auto-connect controllers. -->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/usb_device_filter" />
</activity>
<!-- Foreground service that owns the audio engine so playback survives
when the UI is not visible, and drives the media notification. -->
<service
android:name=".playback.PlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,20 @@
# Native build for the Oboe (AAudio) low-latency output backend.
cmake_minimum_required(VERSION 3.22.1)
project(sizzle_native)
# Oboe ships as a prefab package inside the com.google.oboe:oboe AAR; AGP exposes
# it here when `buildFeatures { prefab = true }` is set in build.gradle.kts.
find_package(oboe REQUIRED CONFIG)
add_library(sizzle_native SHARED native_audio.cpp)
target_link_libraries(sizzle_native
oboe::oboe
log)
# Align ELF load segments to 16 KB so the library works on Android 15+ devices
# using 16 KB memory pages (and passes Google Play's requirement). NDK r27+ does
# this by default; on r26 we set it explicitly. -Bsymbolic is unrelated; the key
# flag is max-page-size.
target_link_options(sizzle_native PRIVATE
"-Wl,-z,max-page-size=16384")

View File

@@ -0,0 +1,111 @@
// Native Oboe (AAudio) output backend for Sizzletracker.
//
// Opens a low-latency mono float Oboe output stream. On each real-time data
// callback it calls back into Kotlin (NativeAudioBridge.renderAudio) to fill a
// reused Java float[] from the shared AudioEngine.fillBlock, then copies that
// into Oboe's output buffer. This keeps ONE implementation of the synth/mixer
// (in Kotlin) while delivering audio through the lower-latency native path.
#include <jni.h>
#include <android/log.h>
#include <oboe/Oboe.h>
#include <memory>
#define LOG_TAG "SizzleNative"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
namespace {
JavaVM *g_vm = nullptr;
jobject g_callback = nullptr; // global ref to the NativeAudioBridge
jmethodID g_renderMethod = nullptr;
jfloatArray g_buffer = nullptr; // reused Java float[] of g_frames length
int g_frames = 0;
// Fetch a JNIEnv for the (persistent) audio callback thread, attaching once.
JNIEnv *callbackEnv() {
JNIEnv *env = nullptr;
if (g_vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) == JNI_EDETACHED) {
g_vm->AttachCurrentThread(&env, nullptr);
}
return env;
}
class SizzleCallback : public oboe::AudioStreamDataCallback {
public:
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData,
int32_t numFrames) override {
auto *out = static_cast<float *>(audioData);
JNIEnv *env = callbackEnv();
if (!env || !g_callback || !g_renderMethod || !g_buffer) {
for (int i = 0; i < numFrames; ++i) out[i] = 0.0f;
return oboe::DataCallbackResult::Continue;
}
const int n = numFrames < g_frames ? numFrames : g_frames;
// Ask Kotlin to render n frames into the reused Java buffer ...
env->CallVoidMethod(g_callback, g_renderMethod, g_buffer, n);
// ... then copy them out to Oboe (and pad any remainder with silence).
env->GetFloatArrayRegion(g_buffer, 0, n, out);
for (int i = n; i < numFrames; ++i) out[i] = 0.0f;
return oboe::DataCallbackResult::Continue;
}
};
SizzleCallback g_dataCallback;
std::shared_ptr<oboe::AudioStream> g_stream;
} // namespace
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
g_vm = vm;
return JNI_VERSION_1_6;
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStart(
JNIEnv *env, jobject /*thiz*/, jobject callback, jint sampleRate, jint framesPerCallback) {
g_frames = framesPerCallback;
g_callback = env->NewGlobalRef(callback);
jclass cls = env->GetObjectClass(callback);
g_renderMethod = env->GetMethodID(cls, "renderAudio", "([FI)V");
g_buffer = static_cast<jfloatArray>(env->NewGlobalRef(env->NewFloatArray(framesPerCallback)));
oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Shared)
->setFormat(oboe::AudioFormat::Float)
->setChannelCount(oboe::ChannelCount::Mono)
->setSampleRate(sampleRate)
->setFramesPerDataCallback(framesPerCallback)
->setDataCallback(&g_dataCallback);
oboe::Result result = builder.openStream(g_stream);
if (result != oboe::Result::OK) {
LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
return JNI_FALSE;
}
result = g_stream->requestStart();
if (result != oboe::Result::OK) {
LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
g_stream->close();
g_stream.reset();
return JNI_FALSE;
}
LOGI("Oboe stream started (%d Hz, %d frames/callback)", sampleRate, framesPerCallback);
return JNI_TRUE;
}
extern "C" JNIEXPORT void JNICALL
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStop(
JNIEnv *env, jobject /*thiz*/) {
if (g_stream) {
g_stream->stop();
g_stream->close();
g_stream.reset();
}
if (g_buffer) { env->DeleteGlobalRef(g_buffer); g_buffer = nullptr; }
if (g_callback) { env->DeleteGlobalRef(g_callback); g_callback = nullptr; }
g_renderMethod = nullptr;
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,697 @@
package com.reactorcoremeltdown.sizzletracker.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
import com.reactorcoremeltdown.sizzletracker.model.Pattern
import com.reactorcoremeltdown.sizzletracker.model.Project
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.thread
import kotlin.math.abs
import kotlin.math.sin
/**
* The real-time sound engine. It owns one [AudioTrack] and a dedicated render
* thread that is completely separate from the UI thread — this is what keeps
* playback smooth while the interface is being touched.
*
* The render loop is a classic tracker "sample-accurate sequencer": it counts
* audio samples and, exactly when enough samples have elapsed for one tracker
* line, it advances the playhead and triggers that line's notes. Because timing
* is derived from the audio clock (not `Handler.postDelayed`), there is no drift.
*
* PLAYBACK MODES
* --------------
* - ARRANGEMENT mode (default when the arrangement has any blocks): the
* sequencer walks a pre-expanded playlist of beats (with A/B loop repeats),
* and at each beat every filled lane plays one beat of its pattern. A lane
* keeps its own cursor so a block spanning several consecutive beats advances
* through the pattern beat-by-beat.
* - PATTERN-LOOP mode (when the arrangement is empty): the currently-edited
* pattern loops, so the Tracker tab is always audible while you edit.
*
* Voices form a [LANE_COUNT] x [TRACK_COUNT] matrix so lanes layer polyphonically
* while each (lane, track) stays monophonic (classic tracker channel behaviour).
* A voice's mixer channel is its track index, so mute/solo/volume apply per track.
*
* INSTRUMENTS: NES synth and Sampler are rendered here; the SoundFont loader
* still falls back to the synth. Each (lane, track) has both a [SynthVoice] and a
* [SampleVoice]; the channel's instrument type decides which one a note triggers.
* Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
* Kotlin/AudioTrack loop for an Oboe (C++/AAudio) engine is a later milestone.
*/
class AudioEngine(
private val sampleRate: Int = 48_000,
) {
// Sequencer voices: one synth + one sample voice per (lane, track).
private val seqVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SynthVoice(sampleRate) }
private val sampleVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SampleVoice(sampleRate) }
// ... plus a small pool for live auditioning from keyboard / MIDI / pads.
private val liveVoices = Array(LIVE_POLYPHONY) { SynthVoice(sampleRate) }
// Sample voices for previewing sampler instruments (toolbox keyboard / editor).
private val auditionSampleVoices = Array(AUDITION_POLYPHONY) { SampleVoice(sampleRate) }
// Per-(lane,track) arpeggiator state, driven by advanceArps.
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
private fun seqVoice(lane: Int, track: Int) = seqVoices[lane * Pattern.TRACK_COUNT + track]
private fun sampleVoice(lane: Int, track: Int) = sampleVoices[lane * Pattern.TRACK_COUNT + track]
/** One insert-effect instance bound to the toolbox slot that configures it. */
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect)
/** Per-channel effect chains (mixer FX slots resolved to real processors).
* Rebuilt when routing changes; parameters refresh live every block. */
private val channelChains = Array(Pattern.TRACK_COUNT) { mutableListOf<FxUnit>() }
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
/** The song being played. Swapped atomically; read on the audio thread. */
@Volatile private var project: Project? = null
@Volatile private var playing = false
@Volatile private var running = false
// ---- Shared position state (audio-thread only) ----
private var samplesIntoLine = 0.0
private var pendingTrigger = true
// ---- Pattern-loop mode ----
private var currentLine = 0
// ---- Arrangement mode ----
private var arrangementMode = false
/** Beat indices to play, in order, with A/B repeats already expanded. */
private var playlist: IntArray = IntArray(0)
private var playlistIndex = 0
private var lineInBeat = 0
private val laneCursor = IntArray(Arrangement.LANE_COUNT) // which beat of its pattern each lane is on
private val lanePrevPattern = IntArray(Arrangement.LANE_COUNT) // pattern played on the previous beat (-2 = none)
private val _transport = MutableStateFlow(TransportState())
val transport: StateFlow<TransportState> = _transport
private var renderThread: Thread? = null
private var track: AudioTrack? = null
fun setProject(p: Project) {
project = p
rebuildArrangement()
rebuildFxChains()
}
/**
* Resolve each mixer channel's four FX slots into real effect processors.
* Call from the main thread (from [play], [setProject], or after the user
* changes a channel's FX routing). Effect *parameters* update live every
* block, so only add/remove of an effect needs a rebuild.
*/
fun rebuildFxChains() {
val proj = project ?: return
for (ch in 0 until Pattern.TRACK_COUNT) {
val chain = channelChains[ch]
chain.clear()
for (slotIndex in proj.mixer.channels[ch].fxSlots) {
if (slotIndex < 0) continue
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
val type = slot.type ?: continue
AudioEffect.create(type, sampleRate)?.let { chain.add(FxUnit(slot, it)) }
}
}
}
/**
* Recomputes the arrangement playlist and resets sequencer position. Call
* from the main thread (never the audio thread) — from [play] and whenever
* the arrangement or loop settings change.
*/
fun rebuildArrangement() {
val proj = project ?: return
val arr = proj.arrangement
arrangementMode = arr.slots.any { lane -> lane.any { it != Arrangement.EMPTY } }
playlist = buildPlaylist(arr)
playlistIndex = 0
lineInBeat = 0
laneCursor.fill(0)
lanePrevPattern.fill(-2)
}
/** Expand the arrangement (respecting loop + A/B regions) into a flat beat list. */
private fun buildPlaylist(arr: Arrangement): IntArray {
if (!arr.loopEnabled) return IntArray(arr.lengthBeats) { it } // play once, front to back
val a = arr.regionA
val b = arr.regionB
if (!a.isValid && !b.isValid) return IntArray(arr.lengthBeats) { it } // loop the whole song
val out = ArrayList<Int>()
if (a.isValid) repeat(a.repeats) { for (beat in a.start until a.end) out.add(beat) }
if (b.isValid) repeat(b.repeats) { for (beat in b.start until b.end) out.add(beat) }
return out.toIntArray()
}
// ---------------------------------------------------------------- lifecycle
/** True while the low-latency native (Oboe) output backend is selected. */
@Volatile var useNativeOutput = false; private set
private var nativeBridge: NativeAudioBridge? = null
fun start() {
if (running) return
running = true
// Prefer the native Oboe backend when enabled and its library loaded;
// otherwise (or if it fails to open) use the Kotlin AudioTrack loop.
if (useNativeOutput && NativeAudioBridge.isAvailable()) {
val bridge = NativeAudioBridge(this)
if (bridge.start(sampleRate, BLOCK_FRAMES)) { nativeBridge = bridge; return }
}
startAudioTrack()
}
private fun startAudioTrack() {
val minBuf = AudioTrack.getMinBufferSize(
sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT,
).coerceAtLeast(BLOCK_FRAMES * 4)
track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
)
.setBufferSizeInBytes(minBuf)
.setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build()
.also { it.play() }
applyPreferredOutput() // route to the user-chosen USB/audio device, if any
renderThread = thread(name = "sizzle-audio", priority = Thread.MAX_PRIORITY) {
audioTrackLoop()
}
}
/** Switch output backend between Oboe (native) and AudioTrack, restarting. */
fun setNativeOutput(enabled: Boolean) {
if (useNativeOutput == enabled) return
val wasRunning = running
if (wasRunning) release()
useNativeOutput = enabled
if (wasRunning) start()
}
// ---- USB / audio output device routing ----
@Volatile private var preferredOutput: android.media.AudioDeviceInfo? = null
/** Route playback to a specific output device (e.g. a USB DAC), or null for
* the system default. Applied immediately and re-applied on engine restart. */
fun setPreferredOutput(device: android.media.AudioDeviceInfo?) {
preferredOutput = device
applyPreferredOutput()
}
private fun applyPreferredOutput() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
track?.setPreferredDevice(preferredOutput)
}
}
/** Stop the engine entirely (called from the PlaybackService on shutdown). */
fun release() {
running = false
nativeBridge?.stop()
nativeBridge = null
renderThread?.join(500)
renderThread = null
track?.run { stop(); release() }
track = null
}
// --------------------------------------------------------------- transport
fun play() {
rebuildArrangement()
rebuildFxChains()
pendingTrigger = true
playing = true
publish()
}
fun pause() { playing = false; allSeqVoicesOff(); publish() }
/** Stop playback. If already stopped, rewind to the very beginning. */
fun stop() {
if (!playing && currentLine == 0 && playlistIndex == 0 && lineInBeat == 0) return
val wasStopped = !playing
playing = false
allSeqVoicesOff()
if (wasStopped) {
currentLine = 0; samplesIntoLine = 0.0
playlistIndex = 0; lineInBeat = 0
laneCursor.fill(0); lanePrevPattern.fill(-2)
}
publish()
}
// ------------------------------------------------------ live note audition
fun liveNoteOn(pitch: Int, velocity: Int) {
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
v.noteOn(pitch, velocity, SynthVoice.Wave.PULSE_50, 0.005, 0.1, 0.7, 0.15)
}
fun liveNoteOff(pitch: Int) {
liveVoices.firstOrNull { it.isActive && it.pitch == pitch }?.noteOff()
}
// ------------------------------------------------------------- render loop
/** The Kotlin AudioTrack fallback loop: fill a block and push it. */
private fun audioTrackLoop() {
val block = FloatArray(BLOCK_FRAMES)
while (running) {
val t = track ?: break
fillBlock(block, BLOCK_FRAMES)
t.write(block, 0, BLOCK_FRAMES, AudioTrack.WRITE_BLOCKING)
}
}
/**
* Render [frames] mono samples of the whole mix into [out]. This is the single
* source of truth for audio: it is called by the Kotlin AudioTrack loop AND by
* the native Oboe callback (via [NativeAudioBridge]), so both output backends
* produce identical sound. Runs on an audio thread — must not allocate.
*/
fun fillBlock(out: FloatArray, frames: Int) {
val proj = project
// Per-block param housekeeping: LFOs modulate targets first, then effects
// read their (possibly modulated) parameters.
if (proj != null) {
applyLfos(proj)
updateFxParams(proj)
}
val anySolo = proj?.mixer?.anySolo() ?: false
for (i in 0 until frames) {
if (playing && proj != null) advanceSequencer(proj)
var mix = 0f
if (proj != null) {
channelSum.fill(0f)
for (vi in seqVoices.indices) {
channelSum[vi % Pattern.TRACK_COUNT] += seqVoices[vi].render() + sampleVoices[vi].render()
}
for (ch in 0 until Pattern.TRACK_COUNT) {
val mc = proj.mixer.channels[ch]
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
val chain = channelChains[ch]
for (u in chain) s = u.effect.process(s)
mix += s
}
}
for (lv in liveVoices) mix += lv.render() * LIVE_GAIN
for (av in auditionSampleVoices) mix += av.render() * LIVE_GAIN
out[i] = softClip(mix * MASTER_GAIN)
}
}
/** Push current slot parameters (+ tempo) into every live effect processor. */
private fun updateFxParams(proj: Project) {
val tempo = proj.tempoBpm
for (ch in 0 until Pattern.TRACK_COUNT) {
val chain = channelChains[ch]
for (u in chain) u.effect.update(u.slot, tempo)
}
}
// ---- MIDI LFO modulation ----
private val lfoPhase = DoubleArray(Project.TOOLBOX_SLOTS)
private val lfoSampleHold = DoubleArray(Project.TOOLBOX_SLOTS) { Math.random() * 2 - 1 }
/**
* Apply every LFO slot's modulation to its target parameter, once per block.
* The target is stored as "slotIndex:paramKey"; the LFO oscillates the value
* around a captured centre by ±depth·(half the param's range). The LFO thus
* "takes over" its target parameter while active.
*/
private fun applyLfos(proj: Project) {
val secondsPerBeat = 60.0 / proj.tempoBpm
val blockSeconds = BLOCK_FRAMES.toDouble() / sampleRate
for (i in proj.toolbox.indices) {
val lfo = proj.toolbox[i]
if (lfo.type != ToolboxType.LFO) continue
val target = lfo.string("target")
val sep = target.indexOf(':')
if (sep <= 0) continue
val tIdx = target.substring(0, sep).toIntOrNull() ?: continue
if (tIdx == i) continue
val key = target.substring(sep + 1)
val tSlot = proj.toolbox.getOrNull(tIdx) ?: continue
val spec = tSlot.type?.params?.firstOrNull { it.key == key } ?: continue
val div = TimeDivision.fromIndex(lfo.float("division", 3f).toInt())
val freq = 1.0 / (div.beatFraction * secondsPerBeat).coerceAtLeast(1e-6)
val advanced = lfoPhase[i] + freq * blockSeconds
if (advanced >= 1.0) lfoSampleHold[i] = Math.random() * 2 - 1
lfoPhase[i] = advanced % 1.0
val phase = (lfoPhase[i] + lfo.float("phase", 0f)) % 1.0
val value = when (lfo.string("wave", "Sine")) {
"Triangle" -> 1.0 - 4.0 * abs(phase - 0.5)
"Saw" -> 2.0 * phase - 1.0
"Square" -> if (phase < 0.5) 1.0 else -1.0
"SampleHold" -> lfoSampleHold[i]
else -> sin(2 * Math.PI * phase)
}
val center = lfo.float("center", spec.default).toDouble()
val half = (spec.max - spec.min) / 2.0
val modulated = (center + lfo.float("depth", 0.5f) * value * half)
.coerceIn(spec.min.toDouble(), spec.max.toDouble())
tSlot.set(key, modulated.toFloat())
}
}
/** Dispatch one sample of sequencer time to the active playback mode. */
private fun advanceSequencer(proj: Project) {
if (arrangementMode) advanceArrangement(proj) else advancePatternLoop(proj)
advanceArps(proj) // arpeggiators retrigger between note events
}
// ----- Pattern-loop mode (Tracker tab audition) -----
private fun advancePatternLoop(proj: Project) {
val pattern = proj.activePattern()
val samplesPerLine = samplesPerLine(proj)
if (pendingTrigger) {
triggerPatternLine(proj, pattern, currentLine)
pendingTrigger = false
}
samplesIntoLine += 1.0
if (samplesIntoLine >= samplesPerLine) {
samplesIntoLine -= samplesPerLine
currentLine++
if (currentLine >= pattern.length) currentLine = 0
pendingTrigger = true
publish()
}
}
private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
// Pattern-loop plays on lane 0's voices (tracks 0..3 == channels 0..3).
for (t in 0 until Pattern.TRACK_COUNT) {
triggerVoice(proj, lane = 0, track = t, cell = pattern.cell(t, line))
}
}
/**
* Trigger a cell on (lane, track), applying the channel's MIDI effects:
* Transposer shifts the pitch, Arpeggiator (if present) captures the note and
* hands ongoing retriggering to [advanceArps] instead of sounding it once.
*/
private fun triggerVoice(proj: Project, lane: Int, track: Int, cell: com.reactorcoremeltdown.sizzletracker.model.Cell) {
val arp = arps[lane * Pattern.TRACK_COUNT + track]
when {
cell.isPlayable -> {
val velocity = cell.velocity.coerceIn(0, 127)
val note = (cell.note + channelTranspose(proj, track)).coerceIn(0, 127)
val arpSlot = channelArpSlot(proj, track)
if (arpSlot != null) {
val octaves = arpSlot.float("octaves", 1f).toInt()
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples)
playNote(proj, lane, track, arp.currentNote(), velocity)
} else {
arp.stop()
playNote(proj, lane, track, note, velocity)
}
}
cell.isNoteOff -> {
arp.stop()
seqVoice(lane, track).noteOff(); sampleVoice(lane, track).noteOff()
}
}
}
/**
* Actually sound a note on (lane, track): a [SampleVoice] when the channel's
* instrument is a Sampler with a loaded sample, otherwise the NES synth. The
* unused voice of the pair is killed so they never double up. Shared by the
* sequencer and the arpeggiator.
*/
private fun playNote(proj: Project, lane: Int, track: Int, note: Int, velocity: Int) {
val synth = seqVoice(lane, track)
val samp = sampleVoice(lane, track)
val slot = proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot)
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
if (sample != null && slot != null) {
synth.kill()
val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
samp.noteOn(
sample, note, velocity, root,
slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f),
)
} else {
samp.kill()
val (wave, a, d, s, r) = synthParamsForTrack(proj, track)
synth.noteOn(note, velocity, wave, a, d, s, r)
}
}
/** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
for (idx in proj.mixer.channels[track].fxSlots) {
if (idx < 0) continue
val slot = proj.toolbox.getOrNull(idx) ?: continue
if (slot.type == ToolboxType.ARPEGGIATOR) return slot
}
return null
}
/** Advance every active arpeggiator by one sample; retrigger on step boundaries. */
private fun advanceArps(proj: Project) {
for (i in arps.indices) {
val a = arps[i]
if (!a.active) continue
a.samplesLeft -= 1.0
if (a.samplesLeft <= 0.0) {
a.advance()
playNote(proj, i / Pattern.TRACK_COUNT, i % Pattern.TRACK_COUNT, a.currentNote(), a.velocity)
a.samplesLeft += a.stepSamples
}
}
}
/** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */
private class ChannelArp {
var active = false; var base = -1; var velocity = 100
private var octaves = 1; private var mode = 0
var stepSamples = 0.0; var samplesLeft = 0.0
private var step = 0; private var dir = 1
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double) {
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
mode = when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
}
fun stop() { active = false; base = -1 }
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
fun advance() {
when (mode) {
1 -> step = (step - 1 + octaves) % octaves
2 -> if (octaves > 1) {
step += dir
if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
else if (step <= 0) { step = 0; dir = 1 }
}
3 -> step = if (octaves > 1) (Math.random() * octaves).toInt() else 0
else -> step = (step + 1) % octaves
}
}
}
/** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
private fun channelTranspose(proj: Project, track: Int): Int {
var semis = 0
for (idx in proj.mixer.channels[track].fxSlots) {
if (idx < 0) continue
val slot = proj.toolbox.getOrNull(idx) ?: continue
if (slot.type == ToolboxType.TRANSPOSER) semis += slot.float("semitones", 0f).toInt()
}
return semis
}
/** Start auditioning a note on the instrument in toolbox [slotIndex] (used by
* the Toolbox test keyboard and the Sampler editor). Polyphonic. */
fun auditionSlotOn(slotIndex: Int, midi: Int) {
val proj = project ?: return
val slot = proj.toolbox.getOrNull(slotIndex)
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
if (sample != null && slot != null) {
val v = auditionSampleVoices.firstOrNull { !it.isActive } ?: auditionSampleVoices[0].also { it.kill() }
val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
v.noteOn(sample, midi, 110, root, slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f))
} else {
val (wave, a, d, s, r) = synthParamsForSlot(slot)
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
v.noteOn(midi, 110, wave, a, d, s, r)
}
}
/** Release an auditioned note (matches both synth and sample audition pools). */
fun auditionSlotOff(midi: Int) {
liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
}
// ----- Arrangement mode -----
private fun advanceArrangement(proj: Project) {
val arr = proj.arrangement
if (playlist.isEmpty()) { playing = false; publish(); return }
val linesPerBeat = proj.timeSignature.linesPerBeat
val samplesPerLine = samplesPerLine(proj)
val beat = playlist[playlistIndex.coerceIn(0, playlist.size - 1)]
if (pendingTrigger) {
if (lineInBeat == 0) advanceLaneCursors(proj, arr, beat, linesPerBeat)
triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
pendingTrigger = false
}
samplesIntoLine += 1.0
if (samplesIntoLine >= samplesPerLine) {
samplesIntoLine -= samplesPerLine
lineInBeat++
if (lineInBeat >= linesPerBeat) {
lineInBeat = 0
playlistIndex++
if (playlistIndex >= playlist.size) {
if (arr.loopEnabled) {
playlistIndex = 0
lanePrevPattern.fill(-2) // fresh cycle: blocks restart from beat 0
} else {
playing = false; allSeqVoicesOff(); publish(); return
}
}
}
pendingTrigger = true
publish()
}
}
/** At each new beat, update every lane's within-pattern cursor. */
private fun advanceLaneCursors(proj: Project, arr: Arrangement, beat: Int, linesPerBeat: Int) {
for (lane in 0 until Arrangement.LANE_COUNT) {
val p = arr.patternAt(lane, beat)
if (p == Arrangement.EMPTY) {
lanePrevPattern[lane] = Arrangement.EMPTY
continue
}
if (p == lanePrevPattern[lane]) {
val beats = ((proj.pattern(p)?.length ?: linesPerBeat) / linesPerBeat).coerceAtLeast(1)
laneCursor[lane] = (laneCursor[lane] + 1) % beats
} else {
laneCursor[lane] = 0
}
lanePrevPattern[lane] = p
}
}
private fun triggerArrangementLine(
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
) {
for (lane in 0 until Arrangement.LANE_COUNT) {
val p = arr.patternAt(lane, beat)
if (p == Arrangement.EMPTY) continue
val pat = proj.pattern(p) ?: continue
val patLine = laneCursor[lane] * linesPerBeat + lineInBeat
if (patLine >= pat.length) continue
for (t in 0 until Pattern.TRACK_COUNT) {
triggerVoice(proj, lane, t, pat.cell(t, patLine))
}
}
}
/** Read the NES-synth settings for a track from its mixer->toolbox routing. */
private fun synthParamsForTrack(proj: Project, track: Int): SynthParams =
synthParamsForSlot(proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot))
/** Read a slot's NES-synth settings (default synth if it isn't an NES synth). */
private fun synthParamsForSlot(slot: ToolboxSlot?): SynthParams {
if (slot == null || slot.type != ToolboxType.NES_SYNTH) return DEFAULT_SYNTH
val wave = when (slot.string("wave", "Pulse50")) {
"Pulse12" -> SynthVoice.Wave.PULSE_12
"Pulse25" -> SynthVoice.Wave.PULSE_25
"Triangle" -> SynthVoice.Wave.TRIANGLE
"Noise" -> SynthVoice.Wave.NOISE
else -> SynthVoice.Wave.PULSE_50
}
return SynthParams(
wave,
slot.float("attack", 0.01f).toDouble(),
slot.float("decay", 0.15f).toDouble(),
slot.float("sustain", 0.6f).toDouble(),
slot.float("release", 0.1f).toDouble(),
)
}
private fun samplesPerLine(proj: Project): Double =
sampleRate * (60.0 / proj.tempoBpm) / proj.timeSignature.linesPerBeat
private fun allSeqVoicesOff() {
seqVoices.forEach { it.noteOff() }
sampleVoices.forEach { it.noteOff() }
arps.forEach { it.stop() }
}
private fun publish() {
val proj = project
val linesPerBeat = proj?.timeSignature?.linesPerBeat ?: 4
if (arrangementMode) {
val beat = if (playlist.isNotEmpty()) playlist[playlistIndex.coerceIn(0, playlist.size - 1)] else 0
_transport.value = TransportState(
isPlaying = playing,
currentLine = laneCursor[0] * linesPerBeat + lineInBeat,
currentBeat = beat,
)
} else {
_transport.value = TransportState(
isPlaying = playing,
currentLine = currentLine,
currentBeat = currentLine / linesPerBeat,
)
}
}
/** Small destructurable holder for a voice's synth settings. */
private data class SynthParams(
val wave: SynthVoice.Wave, val a: Double, val d: Double, val s: Double, val r: Double,
)
companion object {
private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz
private const val LIVE_POLYPHONY = 8
private const val AUDITION_POLYPHONY = 6
private const val MASTER_GAIN = 0.35f
private const val LIVE_GAIN = 0.5f
private val DEFAULT_SYNTH = SynthParams(SynthVoice.Wave.PULSE_50, 0.01, 0.15, 0.6, 0.1)
/** Cheap soft clipper to keep the mix from harsh digital clipping. */
private fun softClip(x: Float): Float = when {
x > 1f -> 1f - 1f / (x + 1f)
x < -1f -> -1f + 1f / (-x + 1f)
else -> x
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,76 @@
package com.reactorcoremeltdown.sizzletracker.audio
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import kotlin.concurrent.thread
/**
* Ad-hoc sample recorder. Captures audio from the microphone (or a connected USB
* audio input, which the platform routes to the default source) into a mono float
* buffer that becomes a [SampleStore.Sample].
*
* Requires the RECORD_AUDIO permission (requested at app launch). Recording runs
* on its own thread so it never blocks the UI or the playback engine.
*/
class SampleRecorder(private val sampleRate: Int = 44_100) {
@Volatile private var recording = false
private var worker: Thread? = null
private val chunks = ArrayList<FloatArray>()
/** Preferred capture device (e.g. a USB audio input), or null for the default. */
var preferredInput: android.media.AudioDeviceInfo? = null
val isRecording: Boolean get() = recording
@SuppressLint("MissingPermission") // caller ensures RECORD_AUDIO is granted
fun start(): Boolean {
if (recording) return true
val minBuf = AudioRecord.getMinBufferSize(
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
)
if (minBuf <= 0) return false
val record = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
minBuf * 2,
)
if (record.state != AudioRecord.STATE_INITIALIZED) { record.release(); return false }
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
record.setPreferredDevice(preferredInput)
}
chunks.clear()
recording = true
record.startRecording()
worker = thread(name = "sizzle-rec") {
val buf = FloatArray(minBuf)
while (recording) {
val n = record.read(buf, 0, buf.size, AudioRecord.READ_BLOCKING)
if (n > 0) chunks.add(buf.copyOf(n))
// Safety cap: stop after ~30 s to avoid unbounded memory.
if (chunks.sumOf { it.size } > sampleRate * 30) recording = false
}
record.stop()
record.release()
}
return true
}
/** Stop and return the recorded audio (null if nothing was captured). */
fun stop(): SampleStore.Sample? {
if (!recording && worker == null) return null
recording = false
worker?.join(1000)
worker = null
val total = chunks.sumOf { it.size }
if (total == 0) return null
val out = FloatArray(total)
var pos = 0
for (c in chunks) { c.copyInto(out, pos); pos += c.size }
chunks.clear()
return SampleStore.Sample(out, sampleRate)
}
}

View File

@@ -0,0 +1,100 @@
package com.reactorcoremeltdown.sizzletracker.audio
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
/**
* A process-wide cache of decoded audio samples used by the Sampler instrument.
*
* Decoding (reading a file, parsing WAV) happens on a normal thread in the UI
* layer, which then [put]s the finished [Sample] here under a string id. The
* audio thread only ever [get]s already-decoded samples — it never touches the
* filesystem — so real-time playback stays allocation- and IO-free.
*
* The id stored in a toolbox slot's `samplePath` parameter is the key here.
*/
object SampleStore {
/** Decoded mono PCM plus the rate it was recorded at. */
class Sample(val data: FloatArray, val sampleRate: Int)
private val samples = ConcurrentHashMap<String, Sample>()
fun put(id: String, sample: Sample) { samples[id] = sample }
fun get(id: String): Sample? = if (id.isEmpty()) null else samples[id]
fun has(id: String): Boolean = samples.containsKey(id)
/**
* Decode a WAV file (the common uncompressed formats) into a mono [Sample].
* Supports 8/16/24-bit PCM and 32-bit float, mono or stereo (stereo is
* down-mixed). Returns null if the bytes are not a WAV we understand.
*/
fun decodeWav(bytes: ByteArray): Sample? {
if (bytes.size < 44) return null
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
if (bytes[0].toInt().toChar() != 'R' || bytes[1].toInt().toChar() != 'I') return null // "RIFF"
bb.position(8)
if (readTag(bb) != "WAVE") return null
var audioFormat = 1
var channels = 1
var sampleRate = 44100
var bitsPerSample = 16
var dataOffset = -1
var dataLength = 0
// Walk the chunks looking for "fmt " and "data".
while (bb.remaining() >= 8) {
val tag = readTag(bb)
val size = bb.int
val next = bb.position() + size
when (tag) {
"fmt " -> {
audioFormat = bb.short.toInt() and 0xFFFF
channels = (bb.short.toInt() and 0xFFFF).coerceAtLeast(1)
sampleRate = bb.int
bb.int // byteRate
bb.short // blockAlign
bitsPerSample = bb.short.toInt() and 0xFFFF
}
"data" -> { dataOffset = bb.position(); dataLength = size }
}
if (dataOffset >= 0 && audioFormat != 0) { /* keep scanning is fine */ }
if (next <= bb.limit()) bb.position(next + (size and 1)) else break // chunks are word-aligned
}
if (dataOffset < 0) return null
val bytesPerSample = bitsPerSample / 8
if (bytesPerSample == 0) return null
val frameCount = dataLength / (bytesPerSample * channels)
val out = FloatArray(frameCount)
val data = ByteBuffer.wrap(bytes, dataOffset, dataLength).order(ByteOrder.LITTLE_ENDIAN)
for (f in 0 until frameCount) {
var acc = 0f
for (c in 0 until channels) {
acc += when {
audioFormat == 3 && bitsPerSample == 32 -> data.float
bitsPerSample == 16 -> data.short / 32768f
bitsPerSample == 8 -> ((data.get().toInt() and 0xFF) - 128) / 128f
bitsPerSample == 24 -> {
val b0 = data.get().toInt() and 0xFF
val b1 = data.get().toInt() and 0xFF
val b2 = data.get().toInt() // signed high byte
((b2 shl 16) or (b1 shl 8) or b0) / 8388608f
}
bitsPerSample == 32 -> data.int / 2147483648f
else -> return null
}
}
out[f] = acc / channels // down-mix to mono
}
return Sample(out, sampleRate)
}
private fun readTag(bb: ByteBuffer): String {
val b = ByteArray(4)
bb.get(b)
return String(b, Charsets.US_ASCII)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,
)

View File

@@ -0,0 +1,102 @@
package com.reactorcoremeltdown.sizzletracker.input
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
/**
* Translates USB / Bluetooth gamepad input into [InputAction]s. Two kinds of
* events arrive:
* - button presses -> [KeyEvent] (handled in [onKeyDown])
* - stick / trigger / D-pad-as-hat movement -> [MotionEvent] (handled in [onMotion])
*
* The button map is user-rebindable: [bindings] maps an Android key-code to an
* action id string, editable from the Settings tab. A sensible default layout is
* provided so the pad works out of the box.
*/
class GamepadInput(
private val router: InputRouter,
var bindings: MutableMap<Int, String> = defaultBindings(),
) {
// Debounce hat/stick navigation so a held stick doesn't fire every frame.
private var lastHatX = 0
private var lastHatY = 0
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD) &&
!event.isFromSource(InputDevice.SOURCE_JOYSTICK)
) return false
val actionId = bindings[keyCode]
if (actionId == null) {
// Unbound button: forward as raw so "learn" mode can capture it.
if (router.learnMode) router.dispatch(InputAction.RawControl(InputSource.GAMEPAD, keyCode, 1f))
return false
}
actionFromId(actionId)?.let { router.dispatch(it); return true }
return false
}
/** Handle analog sticks, triggers, and D-pad hat axes. */
fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
// D-pad reported as HAT axes on many pads.
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X).toSign()
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y).toSign()
if (hatX != lastHatX) {
lastHatX = hatX
when (hatX) { -1 -> router.dispatch(InputAction.NavLeft); 1 -> router.dispatch(InputAction.NavRight) }
}
if (hatY != lastHatY) {
lastHatY = hatY
when (hatY) { -1 -> router.dispatch(InputAction.NavUp); 1 -> router.dispatch(InputAction.NavDown) }
}
// Right stick vertical edits the focused value (up = increment).
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
when {
ry < -EDIT_THRESHOLD -> router.dispatch(InputAction.Increment(1))
ry > EDIT_THRESHOLD -> router.dispatch(InputAction.Decrement(1))
}
return true
}
private fun actionFromId(id: String): InputAction? = when (id) {
"playPause" -> InputAction.PlayPause
"stop" -> InputAction.Stop
"loop" -> InputAction.ToggleLoop
"up" -> InputAction.NavUp
"down" -> InputAction.NavDown
"left" -> InputAction.NavLeft
"right" -> InputAction.NavRight
"increment" -> InputAction.Increment(1)
"decrement" -> InputAction.Decrement(1)
"clear" -> InputAction.ClearCell
"nextTab" -> InputAction.NextTab
"prevTab" -> InputAction.PrevTab
else -> null
}
private fun Float.toSign(): Int = when {
this < -0.5f -> -1; this > 0.5f -> 1; else -> 0
}
companion object {
private const val EDIT_THRESHOLD = 0.6f
/** Default Xbox/standard-layout mapping; every value is an action id. */
fun defaultBindings(): MutableMap<Int, String> = mutableMapOf(
KeyEvent.KEYCODE_BUTTON_A to "playPause",
KeyEvent.KEYCODE_BUTTON_B to "stop",
KeyEvent.KEYCODE_BUTTON_X to "clear",
KeyEvent.KEYCODE_BUTTON_Y to "loop",
KeyEvent.KEYCODE_BUTTON_L1 to "prevTab",
KeyEvent.KEYCODE_BUTTON_R1 to "nextTab",
KeyEvent.KEYCODE_DPAD_UP to "up",
KeyEvent.KEYCODE_DPAD_DOWN to "down",
KeyEvent.KEYCODE_DPAD_LEFT to "left",
KeyEvent.KEYCODE_DPAD_RIGHT to "right",
)
}
}

View File

@@ -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 }

View File

@@ -0,0 +1,29 @@
package com.reactorcoremeltdown.sizzletracker.input
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* A tiny message bus. Every input source calls [dispatch]; the app's ViewModel
* collects [actions] and reacts. Using a flow (rather than a direct callback)
* decouples the sources from the UI and lets us buffer bursts of MIDI events
* without dropping them.
*
* When "MIDI learn" mode is on, controls that aren't bound to anything are still
* forwarded as [InputAction.RawControl] so the settings screen can capture them.
*/
class InputRouter {
// extraBufferCapacity gives us headroom for fast MIDI/gamepad bursts.
private val _actions = MutableSharedFlow<InputAction>(extraBufferCapacity = 128)
val actions: SharedFlow<InputAction> = _actions.asSharedFlow()
/** True while the settings screen is waiting to learn a new binding. */
@Volatile var learnMode: Boolean = false
fun dispatch(action: InputAction) {
// tryEmit never suspends; if the buffer is somehow full we drop the
// oldest by design (a missed nav key is harmless, a stall is not).
_actions.tryEmit(action)
}
}

View File

@@ -0,0 +1,67 @@
package com.reactorcoremeltdown.sizzletracker.input
import android.view.KeyEvent
import com.reactorcoremeltdown.sizzletracker.model.Pitch
/**
* Translates physical (USB / Bluetooth) keyboard events into [InputAction]s.
* [MainActivity] forwards every hardware key here from its
* `onKeyDown` / `onKeyUp` overrides.
*
* The letter rows form a two-octave "tracker piano" in the classic layout so you
* can play and record notes without a MIDI device:
* lower octave : Z S X D C V G B H N J M (C .. B)
* upper octave : Q 2 W 3 E R 5 T 6 Y 7 U (C .. B, one octave up)
*/
class KeyboardInput(private val router: InputRouter) {
/** The base octave the keyboard piano plays in; adjustable with +/- keys. */
var baseOctave: Int = 4
// keyCode -> semitone offset from the base octave's C.
private val pianoMap: Map<Int, Int> = mapOf(
KeyEvent.KEYCODE_Z to 0, KeyEvent.KEYCODE_S to 1, KeyEvent.KEYCODE_X to 2,
KeyEvent.KEYCODE_D to 3, KeyEvent.KEYCODE_C to 4, KeyEvent.KEYCODE_V to 5,
KeyEvent.KEYCODE_G to 6, KeyEvent.KEYCODE_B to 7, KeyEvent.KEYCODE_H to 8,
KeyEvent.KEYCODE_N to 9, KeyEvent.KEYCODE_J to 10, KeyEvent.KEYCODE_M to 11,
KeyEvent.KEYCODE_Q to 12, KeyEvent.KEYCODE_2 to 13, KeyEvent.KEYCODE_W to 14,
KeyEvent.KEYCODE_3 to 15, KeyEvent.KEYCODE_E to 16, KeyEvent.KEYCODE_R to 17,
KeyEvent.KEYCODE_5 to 18, KeyEvent.KEYCODE_T to 19, KeyEvent.KEYCODE_6 to 20,
KeyEvent.KEYCODE_Y to 21, KeyEvent.KEYCODE_7 to 22, KeyEvent.KEYCODE_U to 23,
)
/** @return true if we consumed the event. */
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
// Ignore auto-repeat for one-shot actions but let piano notes retrigger.
val action: InputAction? = when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> InputAction.NavUp
KeyEvent.KEYCODE_DPAD_DOWN -> InputAction.NavDown
KeyEvent.KEYCODE_DPAD_LEFT -> InputAction.NavLeft
KeyEvent.KEYCODE_DPAD_RIGHT -> InputAction.NavRight
KeyEvent.KEYCODE_TAB -> if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn
KeyEvent.KEYCODE_SPACE -> InputAction.PlayPause
KeyEvent.KEYCODE_ENTER -> InputAction.Stop
KeyEvent.KEYCODE_L -> InputAction.ToggleLoop
KeyEvent.KEYCODE_PAGE_UP -> InputAction.Increment(1)
KeyEvent.KEYCODE_PAGE_DOWN -> InputAction.Decrement(1)
KeyEvent.KEYCODE_DEL -> InputAction.ClearCell
KeyEvent.KEYCODE_GRAVE -> InputAction.NoteOffCell
KeyEvent.KEYCODE_MINUS -> { baseOctave = (baseOctave - 1).coerceIn(0, 8); null }
KeyEvent.KEYCODE_EQUALS -> { baseOctave = (baseOctave + 1).coerceIn(0, 8); null }
else -> pianoMap[keyCode]?.let { offset ->
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
InputAction.NoteOn(pitch, velocity = 100)
}
}
return action?.let { router.dispatch(it); true } ?: false
}
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean {
val offset = pianoMap[keyCode] ?: return false
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
router.dispatch(InputAction.NoteOff(pitch))
return true
}
}

View File

@@ -0,0 +1,118 @@
package com.reactorcoremeltdown.sizzletracker.input
import android.content.Context
import android.media.midi.MidiDeviceInfo
import android.media.midi.MidiManager
import android.media.midi.MidiOutputPort
import android.media.midi.MidiReceiver
import android.os.Handler
import android.os.Looper
/**
* Bridges hardware MIDI (USB *and* Bluetooth — Android surfaces both through the
* same [MidiManager]) into the app's [InputAction] stream.
*
* MIDI note-on/off become live notes; MIDI Control Change (CC) messages are
* matched against [ccBindings] so any knob/pad can drive navigation or transport.
* Unmatched CCs are forwarded as [InputAction.RawControl] so the Settings screen
* can "learn" them.
*
* Note: this handles MIDI *input*. Sending MIDI out lives in the audio/midi
* output module; both share the same [MidiManager].
*/
class MidiInput(
context: Context,
private val router: InputRouter,
/** CC number -> action id, editable from Settings (MIDI bindings section). */
var ccBindings: MutableMap<Int, String> = mutableMapOf(),
) {
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
private val handler = Handler(Looper.getMainLooper())
private val openPorts = mutableListOf<MidiOutputPort>()
/** Start listening. Opens every currently-attached input-capable device and
* watches for hot-plug events. Safe to call if the device has no MIDI. */
fun start() {
val mm = midiManager ?: return
mm.devices.forEach(::openDevice)
mm.registerDeviceCallback(object : MidiManager.DeviceCallback() {
override fun onDeviceAdded(device: MidiDeviceInfo) = openDevice(device)
}, handler)
}
fun stop() {
openPorts.forEach { runCatching { it.close() } }
openPorts.clear()
}
private fun openDevice(info: MidiDeviceInfo) {
// We want devices that can SEND to us (they expose output ports).
if (info.outputPortCount == 0) return
midiManager?.openDevice(info, { device ->
val port = device?.openOutputPort(0) ?: return@openDevice
port.connect(parser)
openPorts += port
}, handler)
}
/** Parses the raw MIDI byte stream. MIDI status bytes have the high bit set;
* the low nibble is the channel, the high nibble the message type. */
private val parser = object : MidiReceiver() {
override fun onSend(data: ByteArray, offset: Int, count: Int, timestamp: Long) {
var i = offset
val end = offset + count
while (i < end) {
val status = data[i].toInt() and 0xFF
if (status < 0x80) { i++; continue } // skip stray data bytes
val type = status and 0xF0
when (type) {
0x90 -> { // Note On
val note = data.getOrZero(i + 1)
val vel = data.getOrZero(i + 2)
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
i += 3
}
0x80 -> { // Note Off
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
}
0xB0 -> { // Control Change
val cc = data.getOrZero(i + 1)
val value = data.getOrZero(i + 2)
handleCc(cc, value); i += 3
}
0xC0, 0xD0 -> i += 2 // program change / channel pressure: 1 data byte
else -> i += 3
}
}
}
}
private fun handleCc(cc: Int, value: Int) {
val actionId = ccBindings[cc]
if (actionId == null) {
router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f))
return
}
// Momentary controls fire on the "press" half (value >= 64).
val action = when (actionId) {
"playPause" -> if (value >= 64) InputAction.PlayPause else null
"stop" -> if (value >= 64) InputAction.Stop else null
"loop" -> if (value >= 64) InputAction.ToggleLoop else null
"up" -> if (value >= 64) InputAction.NavUp else null
"down" -> if (value >= 64) InputAction.NavDown else null
"left" -> if (value >= 64) InputAction.NavLeft else null
"right" -> if (value >= 64) InputAction.NavRight else null
"increment" -> InputAction.Increment(1)
"decrement" -> InputAction.Decrement(1)
"clear" -> if (value >= 64) InputAction.ClearCell else null
"nextTab" -> if (value >= 64) InputAction.NextTab else null
"prevTab" -> if (value >= 64) InputAction.PrevTab else null
else -> null
}
action?.let(router::dispatch)
}
private fun ByteArray.getOrZero(index: Int): Int =
if (index < size) this[index].toInt() and 0xFF else 0
}

View File

@@ -0,0 +1,66 @@
package com.reactorcoremeltdown.sizzletracker.io
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
/**
* Reads and writes a single instrument/effect preset as plain, human-readable
* text. The format is deliberately trivial so a person can hand-edit it and a
* machine can parse it with two lines of code:
*
* SIZZLE-PRESET 1
* TYPE=NES_SYNTH
* NAME=Lead
* wave=Pulse25
* attack=0.01
* ...
*
* The same routine is used for the clipboard copy/paste in the Toolbox tab and
* for the on-disk preset library.
*/
object PresetIo {
private const val HEADER = "SIZZLE-PRESET 1"
/** Serialize a slot's device + parameters to text. */
fun export(slot: ToolboxSlot): String {
val type = slot.type ?: return "$HEADER\nTYPE=EMPTY"
return buildString {
appendLine(HEADER)
appendLine("TYPE=${type.name}")
appendLine("NAME=${slot.name}")
// Emit params in the type's declared order for stable, readable files.
type.params.forEach { spec ->
appendLine("${spec.key}=${slot.string(spec.key, spec.default.toString())}")
}
// Emit any extra string params the engine stored (e.g. samplePath).
slot.values.keys
.filter { key -> type.params.none { it.key == key } }
.forEach { key -> appendLine("$key=${slot.values[key]}") }
}
}
/**
* Parse preset [text] into [slot], overwriting its current contents. Unknown
* or malformed lines are ignored so a partially-edited file still loads.
* @return true if a valid device type was found and applied.
*/
fun import(slot: ToolboxSlot, text: String): Boolean {
val lines = text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
val map = LinkedHashMap<String, String>()
var type: ToolboxType? = null
for (line in lines) {
if (line == HEADER || !line.contains('=')) continue
val (key, value) = line.split('=', limit = 2)
when (key) {
"TYPE" -> type = ToolboxType.entries.firstOrNull { it.name == value }
"NAME" -> slot.name = value
else -> map[key] = value
}
}
val t = type ?: return false
slot.fill(t) // reset to defaults for the type ...
slot.name = slot.name.ifBlank { t.displayName }
map.forEach { (k, v) -> slot.set(k, v) } // ... then apply the saved values
return true
}
}

View File

@@ -0,0 +1,213 @@
package com.reactorcoremeltdown.sizzletracker.io
import com.reactorcoremeltdown.sizzletracker.model.Cell
import com.reactorcoremeltdown.sizzletracker.model.Mixer
import com.reactorcoremeltdown.sizzletracker.model.Pattern
import com.reactorcoremeltdown.sizzletracker.model.Project
import com.reactorcoremeltdown.sizzletracker.model.Scale
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
/**
* Saves and loads a whole [Project] to/from the `.sng` text format.
*
* The format is line-oriented and human-readable (sections in [BRACKETS], one
* record per line). It is our own layout, aligned in spirit with the reference
* Sizzletracker project (github.com/reactorcoremeltdown/sizzletracker). A field
* mapping to that project's exact byte layout is the remaining work — see
* DEVELOPER_HANDOVER.md, "Project format". Because this writer/reader is fully
* round-trip safe on its own, the app is usable today and the compatibility
* shim can be added without touching any other code.
*
* Only non-empty tracker cells are written, keeping songs compact.
*/
object ProjectIo {
private const val HEADER = "SIZZLETRACKER-SNG 1"
// ------------------------------------------------------------------- write
fun save(project: Project): String = buildString {
appendLine(HEADER)
appendLine("NAME=${project.name}")
appendLine("TEMPO=${project.tempoBpm}")
appendLine("SIG=${project.timeSignature.name}")
appendLine("SCALE=${project.scale.name}")
appendLine("ROOT=${project.rootNote}")
appendLine("ACTIVE=${project.activePatternId}")
project.patterns.forEach { p ->
appendLine("[PATTERN id=${p.id} name=${p.name} len=${p.length}]")
for (t in 0 until Pattern.TRACK_COUNT) {
for (line in 0 until p.length) {
val cell = p.cell(t, line)
if (!cell.isEmpty) appendLine("$t,$line,${cell.note},${cell.velocity},${cell.channel}")
}
}
}
val arr = project.arrangement
appendLine("[ARRANGEMENT len=${arr.lengthBeats} loop=${arr.loopEnabled.b()}]")
for (lane in arr.slots.indices) {
for (beat in 0 until arr.lengthBeats) {
val pid = arr.slots[lane][beat]
if (pid >= 0) appendLine("$lane,$beat,$pid")
}
}
appendLine("[REGION A en=${arr.regionA.enabled.b()} s=${arr.regionA.start} e=${arr.regionA.end} r=${arr.regionA.repeats}]")
appendLine("[REGION B en=${arr.regionB.enabled.b()} s=${arr.regionB.start} e=${arr.regionB.end} r=${arr.regionB.repeats}]")
appendLine("[MIXER]")
project.mixer.channels.forEach { ch ->
appendLine(
"${ch.index},${ch.instrumentSlot},${ch.fxSlots.joinToString(",")}," +
"${ch.midiChannel},${ch.volume},${ch.mute.b()},${ch.solo.b()}",
)
}
appendLine("[TOOLBOX]")
project.toolbox.forEach { slot ->
if (!slot.isEmpty) {
val params = slot.values.entries.joinToString(",") { "${it.key}=${it.value}" }
appendLine("${slot.index}=${slot.type!!.name};${slot.name};$params")
}
}
}
// -------------------------------------------------------------------- read
/** Parse [text] into a fresh [Project]. Malformed lines are skipped. */
fun load(text: String): Project = loadInto(Project(), text)
/**
* Parse [text] into the *existing* [project], replacing its contents. Used to
* load a song into the app's shared singleton without swapping the object the
* ViewModel and audio engine already hold a reference to.
*/
fun loadInto(project: Project, text: String): Project {
// Reset everything to a clean slate first.
project.patterns.clear()
for (lane in project.arrangement.slots.indices)
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY)
project.toolbox.forEach { it.clearSlot() }
project.mixer.channels.forEach { ch ->
ch.instrumentSlot = -1; ch.fxSlots.fill(-1); ch.midiChannel = 1
ch.volume = 0.8f; ch.mute = false; ch.solo = false
}
var section = ""
var current: Pattern? = null
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
when {
line == HEADER -> {}
line.startsWith("[PATTERN") -> {
val attrs = parseAttrs(line)
current = Pattern(
id = attrs["id"]?.toIntOrNull() ?: project.patterns.size,
name = attrs["name"] ?: "PTN",
length = attrs["len"]?.toIntOrNull() ?: 16,
).also { project.patterns.add(it) }
section = "PATTERN"
}
line.startsWith("[ARRANGEMENT") -> {
val attrs = parseAttrs(line)
project.arrangement.lengthBeats = attrs["len"]?.toIntOrNull() ?: 64
project.arrangement.loopEnabled = attrs["loop"] == "1"
section = "ARRANGEMENT"
}
line.startsWith("[REGION A") -> applyRegion(project.arrangement.regionA, parseAttrs(line))
line.startsWith("[REGION B") -> applyRegion(project.arrangement.regionB, parseAttrs(line))
line.startsWith("[MIXER]") -> section = "MIXER"
line.startsWith("[TOOLBOX]") -> section = "TOOLBOX"
line.contains('=') && !line.startsWith("[") && section == "" ->
applyHeaderField(project, line)
section == "PATTERN" -> applyPatternCell(current, line)
section == "ARRANGEMENT" -> applyArrangementSlot(project, line)
section == "MIXER" -> applyMixerRow(project, line)
section == "TOOLBOX" -> applyToolboxRow(project, line)
}
}
// Guarantee one block per lane (lane i is tied to block i), padding any
// that the file did not define so the arrangement/editor always resolve.
for (id in 0 until com.reactorcoremeltdown.sizzletracker.model.Arrangement.LANE_COUNT) {
if (project.patterns.none { it.id == id }) project.patterns.add(Pattern(id, name = "BLK${id + 1}"))
}
project.patterns.sortBy { it.id }
project.activePatternId = project.activePatternId.coerceIn(0, project.patterns.size - 1)
return project
}
// ----- small helpers -----
private fun Boolean.b() = if (this) "1" else "0"
private fun parseAttrs(line: String): Map<String, String> =
line.trim('[', ']').substringAfter(' ', "").split(' ')
.mapNotNull { tok -> tok.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }
.toMap()
private fun applyHeaderField(p: Project, line: String) {
val (k, v) = line.split('=', limit = 2)
when (k) {
"NAME" -> p.name = v
"TEMPO" -> p.tempoBpm = v.toFloatOrNull() ?: 120f
"SIG" -> p.timeSignature = runCatching { TimeSignature.valueOf(v) }.getOrDefault(TimeSignature.FOUR_FOUR)
"SCALE" -> p.scale = runCatching { Scale.valueOf(v) }.getOrDefault(Scale.CHROMATIC)
"ROOT" -> p.rootNote = v.toIntOrNull() ?: 0
"ACTIVE" -> p.activePatternId = v.toIntOrNull() ?: 0
}
}
private fun applyPatternCell(pattern: Pattern?, line: String) {
val p = pattern ?: return
val f = line.split(',')
if (f.size < 5) return
val t = f[0].toIntOrNull() ?: return
val ln = f[1].toIntOrNull() ?: return
if (t !in 0 until Pattern.TRACK_COUNT || ln !in 0 until p.length) return
p.cell(t, ln).apply {
note = f[2].toIntOrNull() ?: Cell.EMPTY
velocity = (f[3].toIntOrNull() ?: Cell.MAX_VELOCITY).coerceIn(0, Cell.MAX_VELOCITY)
channel = (f[4].toIntOrNull() ?: 1).coerceIn(1, Cell.MAX_CHANNEL)
}
}
private fun applyArrangementSlot(p: Project, line: String) {
val f = line.split(',')
if (f.size < 3) return
p.arrangement.set(f[0].toInt(), f[1].toInt(), f[2].toInt())
}
private fun applyRegion(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, a: Map<String, String>) {
region.enabled = a["en"] == "1"
region.start = a["s"]?.toIntOrNull() ?: 0
region.end = a["e"]?.toIntOrNull() ?: 0
region.repeats = a["r"]?.toIntOrNull() ?: 2
}
private fun applyMixerRow(p: Project, line: String) {
val f = line.split(',')
if (f.size < 10) return
val idx = f[0].toIntOrNull() ?: return
val ch = p.mixer.channels.getOrNull(idx) ?: return
ch.instrumentSlot = f[1].toInt()
for (i in 0 until Mixer.FX_SLOTS) ch.fxSlots[i] = f[2 + i].toInt()
ch.midiChannel = f[6].toInt()
ch.volume = f[7].toFloat()
ch.mute = f[8] == "1"
ch.solo = f[9] == "1"
}
private fun applyToolboxRow(p: Project, line: String) {
val idx = line.substringBefore('=').toIntOrNull() ?: return
val rest = line.substringAfter('=')
val parts = rest.split(';')
if (parts.size < 2) return
val type = ToolboxType.entries.firstOrNull { it.name == parts[0] } ?: return
val slot = p.toolbox.getOrNull(idx) ?: return
slot.fill(type)
slot.name = parts[1]
if (parts.size >= 3 && parts[2].isNotBlank()) {
parts[2].split(',').forEach { kv ->
val e = kv.split('=', limit = 2)
if (e.size == 2) slot.set(e[0], e[1])
}
}
}
}

View File

@@ -0,0 +1,140 @@
package com.reactorcoremeltdown.sizzletracker.io
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
import com.reactorcoremeltdown.sizzletracker.model.Cell
import com.reactorcoremeltdown.sizzletracker.model.Pattern
import com.reactorcoremeltdown.sizzletracker.model.Pitch
import com.reactorcoremeltdown.sizzletracker.model.Project
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
/**
* Reads and writes the reference desktop **sizzletracker `.sng`** format
* (github.com/reactorcoremeltdown/sizzletracker) so songs interchange with the
* CLI version. It is line-oriented text:
*
* ```
* version 1
* bpm 120
* sig 4 4
*
* block A 16 4
* roll ####
* track T1 0
* 0 C-4 64 01
* 4 E-4 .. ..
* 8 OFF .. ..
* endblock
* ```
*
* The per-block `roll` (one char per beat, `#` = plays) maps directly onto our
* lane-per-block arrangement. Velocity is hexadecimal; `..` means "default"; a
* step channel of `..` inherits the track's declared channel. This format carries
* the musical data only — mixer/toolbox settings use our own [ProjectIo].
*/
object SngFormat {
// ------------------------------------------------------------------- write
fun export(project: Project): String = buildString {
appendLine("version 1")
appendLine("bpm ${project.tempoBpm.toInt()}")
appendLine("sig ${project.timeSignature.beatsPerBar} 4")
appendLine()
val arr = project.arrangement
for (block in project.patterns) {
val rollUsed = (0 until arr.lengthBeats).any { arr.patternAt(block.id, it) != Arrangement.EMPTY }
val hasNotes = (0 until Pattern.TRACK_COUNT).any { t ->
(0 until block.length).any { !block.cell(t, it).isEmpty }
}
if (!rollUsed && !hasNotes) continue // skip untouched blocks
appendLine("block ${block.name} ${block.length} ${project.timeSignature.beatsPerBar}")
val roll = buildString {
for (beat in 0 until arr.lengthBeats) {
append(if (arr.patternAt(block.id, beat) != Arrangement.EMPTY) '#' else '.')
}
}.trimEnd('.')
if (roll.isNotEmpty()) appendLine("roll $roll")
for (t in 0 until Pattern.TRACK_COUNT) {
val steps = (0 until block.length).filter { !block.cell(t, it).isEmpty }
if (steps.isEmpty()) continue
val defChannel = (project.mixer.channels[t].midiChannel - 1).coerceAtLeast(0)
appendLine("track T${t + 1} $defChannel")
for (line in steps) {
val cell = block.cell(t, line)
val note = if (cell.isNoteOff) "OFF" else Pitch.name(cell.note)
val vel = cell.velocity.toString(16).uppercase().padStart(2, '0')
val chan = cell.channel.toString().padStart(2, '0')
appendLine("$line $note $vel $chan")
}
}
appendLine("endblock")
appendLine()
}
}
// -------------------------------------------------------------------- read
fun import(text: String): Project = importInto(Project(), text)
/** Parse `.sng` [text] into the existing [project] (resetting its content). */
fun importInto(project: Project, text: String): Project {
// Clear blocks and arrangement (keep the 8 lane-blocks, wipe their cells).
project.patterns.forEach { p -> for (t in 0 until Pattern.TRACK_COUNT) p.tracks[t].forEach { it.clear() } }
for (lane in project.arrangement.slots.indices) project.arrangement.slots[lane].fill(Arrangement.EMPTY)
var blockIndex = 0
var current: Pattern? = null
var track = 0
var trackChannel = 1
var maxRoll = 0
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
val tok = line.split(Regex("\\s+"))
when (tok[0]) {
"version" -> {}
"bpm" -> project.tempoBpm = tok.getOrNull(1)?.toFloatOrNull() ?: 120f
"sig" -> project.timeSignature = when (tok.getOrNull(1)?.toIntOrNull()) {
3 -> TimeSignature.THREE_FOUR
5 -> TimeSignature.FIVE_FOUR
else -> TimeSignature.FOUR_FOUR
}
"block" -> {
current = project.patterns.getOrNull(blockIndex)?.also { blk ->
tok.getOrNull(1)?.let { blk.name = it }
tok.getOrNull(2)?.toIntOrNull()?.let { blk.resize(it) }
}
blockIndex++
track = 0; trackChannel = 1
}
"roll" -> current?.let { blk ->
val roll = tok.getOrNull(1) ?: ""
roll.forEachIndexed { beat, ch -> if (ch == '#') project.arrangement.set(blk.id, beat, blk.id) }
maxRoll = maxOf(maxRoll, roll.length)
}
"track" -> {
track = tok.getOrNull(1)?.filter { it.isDigit() }?.toIntOrNull()?.minus(1)?.coerceIn(0, Pattern.TRACK_COUNT - 1) ?: 0
trackChannel = tok.getOrNull(2)?.toIntOrNull()?.plus(1)?.coerceIn(1, 16) ?: 1
}
"endblock" -> current = null
else -> applyStep(project, current, track, trackChannel, tok)
}
}
if (maxRoll > 0) project.arrangement.lengthBeats = maxOf(project.arrangement.lengthBeats, maxRoll)
project.activePatternId = 0
return project
}
private fun applyStep(project: Project, block: Pattern?, track: Int, trackChannel: Int, tok: List<String>) {
val blk = block ?: return
val tick = tok.getOrNull(0)?.toIntOrNull() ?: return
if (tick !in 0 until blk.length) return
val cell = blk.cell(track, tick)
cell.note = Pitch.parse(tok.getOrElse(1) { "..." })
cell.velocity = (tok.getOrNull(2)?.takeIf { it != ".." }?.toIntOrNull(16) ?: Cell.MAX_VELOCITY)
.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = (tok.getOrNull(3)?.takeIf { it != ".." }?.toIntOrNull() ?: trackChannel)
.coerceIn(1, Cell.MAX_CHANNEL)
}
}

View File

@@ -0,0 +1,52 @@
package com.reactorcoremeltdown.sizzletracker.model
/**
* The lower-half piano-roll arrangement. It has [LANE_COUNT] lanes stacked
* vertically and up to [MAX_BEATS] beats horizontally. Each slot holds the id of
* the [Pattern] to trigger on that beat (or [EMPTY]). Per the spec, one marked
* slot triggers exactly ONE beat of that pattern/block, even if the pattern is
* longer — so the arrangement grid resolution is one-beat-per-column.
*/
class Arrangement {
/** slots[lane][beat] = patternId, or [EMPTY]. */
val slots: Array<IntArray> = Array(LANE_COUNT) { IntArray(MAX_BEATS) { EMPTY } }
/** How many beats are actually in use; the roll can grow up to [MAX_BEATS]. */
var lengthBeats: Int = 64
/** Loop transport state (the toolbar under the roll). */
var loopEnabled: Boolean = false
val regionA: LoopRegion = LoopRegion()
val regionB: LoopRegion = LoopRegion()
fun patternAt(lane: Int, beat: Int): Int =
if (beat in 0 until MAX_BEATS) slots[lane][beat] else EMPTY
fun set(lane: Int, beat: Int, patternId: Int) {
if (lane in 0 until LANE_COUNT && beat in 0 until MAX_BEATS) {
slots[lane][beat] = patternId
}
}
companion object {
const val LANE_COUNT = 8
const val MAX_BEATS = 256
const val EMPTY = -1
}
}
/**
* An A or B loop region on the arrangement roll. [start] and [end] are beat
* indices (inclusive start, exclusive end); [repeats] is how many times the
* region plays before control moves on. A region is only active when it has a
* non-empty span AND [enabled] is set (toggled by the A/B buttons).
*/
class LoopRegion(
var enabled: Boolean = false,
var start: Int = 0,
var end: Int = 0,
var repeats: Int = 2,
) {
val isValid: Boolean get() = enabled && end > start
val spanBeats: Int get() = (end - start).coerceAtLeast(0)
}

View File

@@ -0,0 +1,36 @@
package com.reactorcoremeltdown.sizzletracker.model
/**
* The mixer has exactly [CHANNEL_COUNT] channels, one per tracker track. Each
* channel routes through one instrument and up to four effects, all referenced
* by their slot index in the [Toolbox] (tab 3). A value of -1 means "no device".
*/
class Mixer {
val channels: List<MixerChannel> = List(CHANNEL_COUNT) { MixerChannel(it) }
/** True if ANY channel is soloed — used to mute the non-soloed ones. */
fun anySolo(): Boolean = channels.any { it.solo }
companion object {
const val CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep
const val FX_SLOTS = 4
const val NO_DEVICE = -1
}
}
class MixerChannel(
val index: Int,
/** Toolbox slot index of the instrument feeding this channel, or -1. */
var instrumentSlot: Int = Mixer.NO_DEVICE,
/** Toolbox slot indices of up to four insert effects, -1 where unused. */
val fxSlots: IntArray = IntArray(Mixer.FX_SLOTS) { Mixer.NO_DEVICE },
/** MIDI channel (1..16) this mixer channel listens/sends on. */
var midiChannel: Int = 1,
/** Linear 0..1 fader. */
var volume: Float = 0.8f,
var mute: Boolean = false,
var solo: Boolean = false,
) {
/** Whether this channel should actually produce sound given the solo state. */
fun audible(anySolo: Boolean): Boolean = !mute && (!anySolo || solo)
}

View File

@@ -0,0 +1,109 @@
package com.reactorcoremeltdown.sizzletracker.model
/**
* Musical primitives shared across the whole app: pitches, scales and time
* signatures. Everything here is deliberately small, immutable and free of any
* Android dependency so it can be unit-tested and reasoned about in isolation.
*/
/**
* A MIDI note number, 0..127, where 60 == middle C (C4 in the convention we use).
* We keep notes as plain Ints in the hot paths (patterns) for memory reasons and
* only wrap them when we need naming/formatting helpers.
*/
object Pitch {
const val LOWEST = 0
const val HIGHEST = 127
const val MIDDLE_C = 60
private val NAMES = arrayOf(
"C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-",
)
/** Human/tracker-readable name, e.g. 60 -> "C-4", 61 -> "C#4". */
fun name(midi: Int): String {
if (midi !in LOWEST..HIGHEST) return "..."
val octave = midi / 12 - 1 // MIDI 12 == C0 in this convention
return NAMES[midi % 12] + octave
}
/**
* Inverse of [name]: parse "C-4" / "C#4" / "OFF" / "..." into a note value.
* Returns [Cell.OFF] for a note-off, [Cell.EMPTY] for empty/unparseable input.
* Used by the sizzletracker `.sng` importer.
*/
fun parse(text: String): Int {
val t = text.trim()
if (t == "OFF" || t == "===") return Cell.OFF
if (t.length < 3 || t.startsWith(".")) return Cell.EMPTY
val idx = NAMES.indexOf(t.substring(0, 2))
if (idx < 0) return Cell.EMPTY
val octave = t.substring(2).toIntOrNull() ?: return Cell.EMPTY
return ((octave + 1) * 12 + idx).coerceIn(LOWEST, HIGHEST)
}
}
/**
* The three time signatures the tracker supports. The key idea in this app is
* that a *beat* is made of a fixed number of tracker *lines* (ticks), and that
* number is what changes between signatures:
* - 3/4 -> 3 lines per beat
* - 4/4 -> 4 lines per beat
* - 5/4 -> 5 lines per beat
* A *bar* is always [beatsPerBar] beats long (here, equal to the numerator).
*/
enum class TimeSignature(
val label: String,
val linesPerBeat: Int,
val beatsPerBar: Int,
/** Allowed pattern lengths (in lines) offered in the length dropdown. */
val lengthOptions: List<Int>,
) {
THREE_FOUR("3/4", linesPerBeat = 3, beatsPerBar = 3, lengthOptions = listOf(12, 24, 48)),
FOUR_FOUR("4/4", linesPerBeat = 4, beatsPerBar = 4, lengthOptions = listOf(16, 32, 64)),
FIVE_FOUR("5/4", linesPerBeat = 5, beatsPerBar = 5, lengthOptions = listOf(20, 40, 80));
/** Lines per bar = lines-per-beat * beats-per-bar. */
val linesPerBar: Int get() = linesPerBeat * beatsPerBar
}
/**
* A musical scale expressed as semitone offsets from the root (0..11).
* Used by the tracker's note stepping (keyboard/gamepad +/- and the touch editor)
* so the user can only land on in-key notes when a non-chromatic scale is selected.
*/
enum class Scale(val label: String, val intervals: List<Int>) {
CHROMATIC("Chromatic", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)),
MAJOR("Major", listOf(0, 2, 4, 5, 7, 9, 11)),
MINOR("Natural Minor", listOf(0, 2, 3, 5, 7, 8, 10)),
HARMONIC_MINOR("Harmonic Minor", listOf(0, 2, 3, 5, 7, 8, 11)),
DORIAN("Dorian", listOf(0, 2, 3, 5, 7, 9, 10)),
PHRYGIAN("Phrygian", listOf(0, 1, 3, 5, 7, 8, 10)),
MIXOLYDIAN("Mixolydian", listOf(0, 2, 4, 5, 7, 9, 10)),
PENTATONIC_MAJOR("Pentatonic Major", listOf(0, 2, 4, 7, 9)),
PENTATONIC_MINOR("Pentatonic Minor", listOf(0, 3, 5, 7, 10)),
BLUES("Blues", listOf(0, 3, 5, 6, 7, 10));
/**
* Returns the next in-scale MIDI note above [fromMidi] when [direction] is
* +1, or below when -1, given a scale [root] (0=C .. 11=B). If [fromMidi] is
* off-scale we snap to the nearest in-scale note first. This is the single
* function that powers the tracker's note stepping (keyboard/gamepad + editor).
*/
fun step(fromMidi: Int, root: Int, direction: Int): Int {
if (this == CHROMATIC) {
return (fromMidi + direction).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
}
// Build the set of pitch-classes that are in-key.
val inKey = intervals.map { (it + root) % 12 }.toSortedSet()
var candidate = fromMidi
// Walk one semitone at a time until we hit an in-key pitch-class.
do {
candidate += direction
if (candidate !in Pitch.LOWEST..Pitch.HIGHEST) {
return fromMidi // hit the edge, don't move
}
} while ((candidate % 12) !in inKey)
return candidate
}
}

View File

@@ -0,0 +1,90 @@
package com.reactorcoremeltdown.sizzletracker.model
/**
* One cell in the tracker grid: a single note event on a single line of a single
* track. All three fields map directly to the three on-screen columns:
* - [note] : MIDI note number, or [EMPTY], or [OFF] (note-off).
* - [velocity] : 0..127 (MIDI range), shown to the user as two hex digits (00..7F).
* - [channel] : MIDI channel 1..16, shown as a decimal.
*
* We use plain mutable fields (not a `data class` copy) because the tracker grid
* is edited cell-by-cell in tight gestures and we want to avoid allocating a new
* object on every drag tick.
*/
class Cell(
var note: Int = EMPTY,
var velocity: Int = MAX_VELOCITY, // default to full velocity so new notes are audible
var channel: Int = 1,
) {
val isEmpty: Boolean get() = note == EMPTY
val isNoteOff: Boolean get() = note == OFF
val isPlayable: Boolean get() = note in Pitch.LOWEST..Pitch.HIGHEST
fun clear() {
note = EMPTY
velocity = MAX_VELOCITY
channel = 1
}
/** A detached copy (for the clipboard) — never aliases the source cell. */
fun copyOf(): Cell = Cell(note, velocity, channel)
/** Overwrite this cell's fields from [other] (paste). */
fun setFrom(other: Cell) {
note = other.note
velocity = other.velocity
channel = other.channel
}
companion object {
const val EMPTY = -1
const val OFF = -2
/** MIDI velocity ceiling (0x7F). Values are clamped to 0..[MAX_VELOCITY]. */
const val MAX_VELOCITY = 0x7F
/** MIDI channel ceiling. Channels run 1..[MAX_CHANNEL]. */
const val MAX_CHANNEL = 16
}
}
/** The four editable columns per track. Only NOTE is pitch; the rest are values. */
enum class CellColumn { NOTE, VELOCITY, CHANNEL }
/**
* A pattern (a "block" in arrangement terms): [TRACK_COUNT] parallel tracks, each
* a vertical column of [length] cells. Length is one of the current time
* signature's allowed values (see [TimeSignature.lengthOptions]).
*
* The grid is stored as `tracks[trackIndex][lineIndex]`.
*/
class Pattern(
val id: Int,
var name: String = "PTN",
var length: Int = 16,
) {
/** tracks[t][line]. Always [TRACK_COUNT] tracks; each list has [length] cells. */
val tracks: Array<MutableList<Cell>> =
Array(TRACK_COUNT) { MutableList(length) { Cell() } }
fun cell(track: Int, line: Int): Cell = tracks[track][line]
/**
* Resize the pattern to [newLength], preserving existing cells where possible.
* Called when the length dropdown or the time signature changes.
*/
fun resize(newLength: Int) {
if (newLength == length) return
for (t in 0 until TRACK_COUNT) {
val col = tracks[t]
when {
newLength > col.size -> repeat(newLength - col.size) { col.add(Cell()) }
newLength < col.size -> while (col.size > newLength) col.removeAt(col.size - 1)
}
}
length = newLength
}
companion object {
/** Fixed by the spec: the tracker upper half always has four tracks. */
const val TRACK_COUNT = 4
}
}

View File

@@ -0,0 +1,47 @@
package com.reactorcoremeltdown.sizzletracker.model
/**
* The whole song in memory. This single object is what gets saved to / loaded
* from the `.sng` file format and what the audio engine reads while playing.
* Keeping one root object makes serialization and undo/redo straightforward.
*
* Persistence is done by our own text serializer (see
* [com.reactorcoremeltdown.sizzletracker.io.ProjectIo]), not kotlinx, so the
* model classes carry no serialization annotations.
*/
class Project {
var name: String = "untitled"
// ----- Global transport -----
var tempoBpm: Float = 120f
var timeSignature: TimeSignature = TimeSignature.FOUR_FOUR
var scale: Scale = Scale.CHROMATIC
/** Root note as a pitch-class 0..11 (0 = C). Used with [scale] for entry. */
var rootNote: Int = 0
// ----- Content -----
/**
* One pattern/block per arrangement lane. Lane i is tied to block i (the
* "sizzletracker CLI" model): enabling a cell on arrangement lane i schedules
* block i to play when the playhead reaches that beat. Block id == lane index.
*/
val patterns: MutableList<Pattern> = MutableList(Arrangement.LANE_COUNT) {
// Default to the shortest length for the signature: 16 ticks for 4/4.
Pattern(id = it, name = "BLK${it + 1}", length = timeSignature.lengthOptions[0])
}
val arrangement: Arrangement = Arrangement()
val mixer: Mixer = Mixer()
/** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */
val toolbox: MutableList<ToolboxSlot> = MutableList(TOOLBOX_SLOTS) { ToolboxSlot(it) }
fun pattern(id: Int): Pattern? = patterns.firstOrNull { it.id == id }
/** Which pattern the tracker tab is currently editing. */
var activePatternId: Int = 0
fun activePattern(): Pattern = pattern(activePatternId) ?: patterns.first()
companion object {
const val TOOLBOX_SLOTS = 16
}
}

View File

@@ -0,0 +1,187 @@
package com.reactorcoremeltdown.sizzletracker.model
/**
* The third tab is a toolbox of 16 slots. Every slot can hold one instrument OR
* one effect. Rather than a separate class per device (which would be a lot of
* near-identical code for a newcomer to wade through), we describe each device
* type with [ToolboxType] and store its settings as a simple
* name -> value map. That map is exactly what we serialize to a human-readable
* preset, and it is what the audio engine reads at render time.
*
* When you add a new instrument or effect you only need to:
* 1. add an entry to [ToolboxType] with its default parameters, and
* 2. teach the audio engine how to interpret those parameters.
* The UI (a generic parameter editor) and preset save/load work automatically.
*/
enum class ToolboxKind { INSTRUMENT, EFFECT }
/**
* A single tunable parameter's description: its key, a friendly label, the value
* range and a default. [choices] being non-empty marks an enumerated parameter
* (rendered as a dropdown, e.g. a waveform selector) instead of a slider.
*/
data class ParamSpec(
val key: String,
val label: String,
val default: Float = 0f,
val min: Float = 0f,
val max: Float = 1f,
val choices: List<String> = emptyList(),
) {
val isEnum: Boolean get() = choices.isNotEmpty()
}
enum class ToolboxType(
val kind: ToolboxKind,
val displayName: String,
val params: List<ParamSpec>,
) {
// ---------- Instruments ----------
NES_SYNTH(
ToolboxKind.INSTRUMENT, "NES Synth",
listOf(
ParamSpec("wave", "Wave", choices = listOf("Pulse12", "Pulse25", "Pulse50", "Triangle", "Noise")),
ParamSpec("attack", "Attack", 0.01f, 0f, 1f),
ParamSpec("decay", "Decay", 0.15f, 0f, 1f),
ParamSpec("sustain", "Sustain", 0.6f, 0f, 1f),
ParamSpec("release", "Release", 0.1f, 0f, 1f),
),
),
SAMPLER(
ToolboxKind.INSTRUMENT, "Sampler",
listOf(
ParamSpec("startOctave", "Start Oct", 3f, 0f, 8f),
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
ParamSpec("sliceStart", "Slice Start", 0f, 0f, 1f),
ParamSpec("sliceEnd", "Slice End", 1f, 0f, 1f),
// "samplePath" is stored as a string param at runtime (see ToolboxSlot).
),
),
SOUNDFONT(
ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
listOf(
ParamSpec("program", "Program", 0f, 0f, 127f),
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
),
),
// ---------- Effects ----------
ARPEGGIATOR(
ToolboxKind.EFFECT, "MIDI Arpeggiator",
listOf(
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
ParamSpec("octaves", "Octaves", 1f, 1f, 4f),
ParamSpec("division", "Division", 3f, 0f, 6f), // index into TimeDivision
),
),
TRANSPOSER(
ToolboxKind.EFFECT, "MIDI Transposer",
listOf(ParamSpec("semitones", "Semitones", 0f, -24f, 24f)),
),
LFO(
ToolboxKind.EFFECT, "MIDI LFO",
listOf(
ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
ParamSpec("phase", "Phase", 0f, 0f, 1f),
ParamSpec("depth", "Depth", 0.5f, 0f, 1f),
ParamSpec("division", "Division", 3f, 0f, 6f),
// "target" (which parameter to modulate) is stored as a string param.
),
),
DELAY(
ToolboxKind.EFFECT, "Tape Delay",
listOf(
ParamSpec("division", "Division", 3f, 0f, 6f),
ParamSpec("feedback", "Feedback", 0.4f, 0f, 0.95f),
ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f),
ParamSpec("heads", "Tape Heads", 4f, 1f, 4f),
),
),
REVERB(
ToolboxKind.EFFECT, "Digital Reverb",
listOf(
ParamSpec("amount", "Amount", 0.3f, 0f, 1f),
ParamSpec("tone", "Tone", 0.5f, 0f, 1f),
),
),
FILTER(
ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
listOf(
ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
ParamSpec("resonance", "Resonance", 0.2f, 0f, 1f),
),
),
EQ10(
ToolboxKind.EFFECT, "10-Band EQ",
// 10 gain bands, each -1..+1 mapping to roughly -12..+12 dB.
(0 until 10).map { ParamSpec("band$it", "Band ${it + 1}", 0f, -1f, 1f) },
),
BITCRUSHER(
ToolboxKind.EFFECT, "Bitcrusher",
listOf(
ParamSpec("bits", "Bits", 8f, 1f, 16f),
ParamSpec("downsample", "Downsample", 1f, 1f, 32f),
ParamSpec("drive", "Drive", 0.2f, 0f, 1f),
),
);
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
/** Fresh default parameter map for a new instance of this type. */
fun defaultValues(): MutableMap<String, String> =
params.associate { spec ->
spec.key to if (spec.isEnum) spec.choices[spec.default.toInt().coerceIn(0, spec.choices.lastIndex)]
else spec.default.toString()
}.toMutableMap()
}
/** Tempo-synced note lengths used by arpeggiator / LFO / delay "division" params. */
enum class TimeDivision(val label: String, val beatFraction: Double) {
WHOLE("1/1", 4.0), HALF("1/2", 2.0), QUARTER("1/4", 1.0),
EIGHTH("1/8", 0.5), SIXTEENTH("1/16", 0.25),
THIRTYSECOND("1/32", 0.125), DOTTED_EIGHTH("1/8.", 0.75);
companion object {
fun fromIndex(i: Int): TimeDivision = entries[i.coerceIn(0, entries.lastIndex)]
}
}
/**
* One occupied (or empty) toolbox slot. [type] == null means the slot is empty.
* [values] holds every parameter as a string so the same structure serializes
* cleanly to text and round-trips without loss. String-only params such as a
* sample file path live in the same map under keys the engine knows about
* (e.g. "samplePath", "target").
*
* (Serialized via [com.reactorcoremeltdown.sizzletracker.io.PresetIo], not
* kotlinx, so no @Serializable annotation is needed here.)
*/
class ToolboxSlot(
val index: Int,
var type: ToolboxType? = null,
var name: String = "",
val values: MutableMap<String, String> = mutableMapOf(),
) {
val isEmpty: Boolean get() = type == null
fun fill(newType: ToolboxType) {
type = newType
name = newType.displayName
values.clear()
values.putAll(newType.defaultValues())
}
fun clearSlot() {
type = null
name = ""
values.clear()
}
// Typed accessors used by the audio engine and UI.
fun float(key: String, fallback: Float = 0f): Float = values[key]?.toFloatOrNull() ?: fallback
fun string(key: String, fallback: String = ""): String = values[key] ?: fallback
fun set(key: String, value: Float) { values[key] = value.toString() }
fun set(key: String, value: String) { values[key] = value }
}

View File

@@ -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()
}

View File

@@ -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))
}
}
}

View File

@@ -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,
),
)
}
}
}
}

View File

@@ -0,0 +1,441 @@
package com.reactorcoremeltdown.sizzletracker.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
import com.reactorcoremeltdown.sizzletracker.input.InputAction
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
import com.reactorcoremeltdown.sizzletracker.model.Cell
import com.reactorcoremeltdown.sizzletracker.model.CellColumn
import com.reactorcoremeltdown.sizzletracker.model.Pattern
import com.reactorcoremeltdown.sizzletracker.model.Pitch
import com.reactorcoremeltdown.sizzletracker.model.Project
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
import kotlinx.coroutines.launch
/**
* The single screen-facing state holder. It sits between the UI (Compose) and
* the model/engine, and it is the ONE place that reacts to [InputAction]s. Touch
* gestures call its methods directly; keyboard/gamepad/MIDI reach it through the
* [InputRouter] flow collected in [init]. Either way the same code runs, which is
* how all four input methods stay equally capable.
*
* Recomposition trick: the model classes are plain (non-observable) for audio
* performance, so after we mutate the grid we bump [revision]. Compose reads that
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
*/
class AppViewModel(
val project: Project,
private val engine: AudioEngine,
private val router: InputRouter,
private val gamepadInput: com.reactorcoremeltdown.sizzletracker.input.GamepadInput,
private val midiInput: com.reactorcoremeltdown.sizzletracker.input.MidiInput,
) : ViewModel() {
// ----- Observable UI state -----
var selectedTab by mutableIntStateOf(0); private set
var revision by mutableIntStateOf(0); private set // bump to force grid redraw
/** Active colour theme; read by [MainActivity] to drive SizzleTheme. */
var palette by mutableStateOf(com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.AMBER)
// Tracker cursor position.
var cursorTrack by mutableIntStateOf(0); private set
var cursorLine by mutableIntStateOf(0); private set
var cursorColumn by mutableStateOf(CellColumn.NOTE); private set
// ----- Selection & clipboard (tracker edit ops) -----
/** When on, the cursor drags out a rectangular selection from [selAnchorTrack]/
* [selAnchorLine] to the live cursor; edit ops act on that rectangle. */
var selectMode by mutableStateOf(false); private set
var selAnchorTrack by mutableIntStateOf(-1); private set
var selAnchorLine by mutableIntStateOf(-1); private set
/** Copied cells, indexed [trackOffset][lineOffset]; null until first copy/cut. */
private var clipboard: Array<Array<Cell>>? = null
val hasSelection: Boolean get() = selectMode && selAnchorTrack >= 0
val canPaste: Boolean get() = clipboard != null
// ----- Entry memory -----
/** The last note & channel actually entered (by any input). Keyboard/gamepad
* edits on an empty tick resume from these so entry continues where you left
* off instead of jumping to a fixed default. */
var lastNote by mutableIntStateOf(Pitch.MIDDLE_C); private set
var lastChannel by mutableIntStateOf(1); private set
/** Live transport snapshot from the audio thread (drives the playhead). */
val transport get() = engine.transport
// ----- Binding-learn state (Settings tab) -----
/** The action id currently waiting to be bound, or null. */
var learnTarget by mutableStateOf<String?>(null); private set
private var learnSource: com.reactorcoremeltdown.sizzletracker.input.InputSource? = null
init {
// Collect neutral input actions from every non-touch device.
viewModelScope.launch {
router.actions.collect(::onAction)
}
}
// ------------------------------------------------------------- tab control
fun selectTab(index: Int) { selectedTab = index.coerceIn(0, 3) }
// ----------------------------------------------------------- cursor & edit
fun focusCell(track: Int, line: Int, column: CellColumn) {
val p = project.activePattern()
cursorTrack = track.coerceIn(0, Pattern.TRACK_COUNT - 1)
cursorLine = line.coerceIn(0, p.length - 1)
cursorColumn = column
}
private fun focusedCell(): Cell =
project.activePattern().cell(cursorTrack, cursorLine)
fun moveCursor(dTrack: Int, dLine: Int) {
val p = project.activePattern()
cursorTrack = (cursorTrack + dTrack).coerceIn(0, Pattern.TRACK_COUNT - 1)
cursorLine = (cursorLine + dLine).coerceIn(0, p.length - 1)
}
fun nextColumn() { cursorColumn = nextOf(cursorColumn, +1) }
fun prevColumn() { cursorColumn = nextOf(cursorColumn, -1) }
/**
* The core value-editing operation, shared by drag gestures and +/- input.
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
*/
fun editFocused(direction: Int) {
val cell = focusedCell()
when (cursorColumn) {
CellColumn.NOTE -> {
// Seed an empty tick from the last note entered so filling resumes
// where you left off rather than at a fixed default.
val seed = if (cell.isPlayable) cell.note else lastNote
cell.note = project.scale.step(seed, project.rootNote, direction)
lastNote = cell.note
}
CellColumn.VELOCITY -> cell.velocity = (cell.velocity + direction).coerceIn(0, Cell.MAX_VELOCITY)
CellColumn.CHANNEL -> {
// Likewise seed an empty tick's channel from the last one entered.
val seed = if (cell.isEmpty) lastChannel else cell.channel
cell.channel = (seed + direction).coerceIn(1, Cell.MAX_CHANNEL)
lastChannel = cell.channel
}
}
touched()
}
fun clearFocused() { focusedCell().clear(); touched() }
fun noteOffFocused() { focusedCell().note = Cell.OFF; touched() }
/** The cell under the cursor — for editor popups to read/display live values. */
fun cellUnderCursor(): Cell = project.activePattern().cell(cursorTrack, cursorLine)
/** Set the focused cell's note directly (piano popup / live entry). Remembers
* it as the last note so subsequent nudges resume from here. */
fun setFocusedNote(midi: Int) {
val n = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
focusedCell().note = n
lastNote = n
touched()
}
/** Set the focused cell's channel directly, remembering it. */
fun setFocusedChannel(channel: Int) {
val ch = channel.coerceIn(1, Cell.MAX_CHANNEL)
focusedCell().channel = ch
lastChannel = ch
touched()
}
// ---------------------------------------------------- selection & clipboard
/** The selected track range (inclusive). Anchor..cursor, or just the cursor
* cell when there is no active selection. */
val selTrackRange: IntRange
get() = if (hasSelection) minOf(selAnchorTrack, cursorTrack)..maxOf(selAnchorTrack, cursorTrack)
else cursorTrack..cursorTrack
val selLineRange: IntRange
get() = if (hasSelection) minOf(selAnchorLine, cursorLine)..maxOf(selAnchorLine, cursorLine)
else cursorLine..cursorLine
/** Toggle select mode. Turning it on anchors the selection at the cursor;
* turning it off clears the selection (the clipboard is untouched). */
fun toggleSelectMode() {
selectMode = !selectMode
if (selectMode) { selAnchorTrack = cursorTrack; selAnchorLine = cursorLine }
else { selAnchorTrack = -1; selAnchorLine = -1 }
touched()
}
/** Copy the selection (or, with none, the focused cell) into the clipboard. */
fun copySelection() {
val p = project.activePattern()
val tr = selTrackRange; val lr = selLineRange
clipboard = Array(tr.count()) { ti ->
Array(lr.count()) { li -> p.cell(tr.first + ti, lr.first + li).copyOf() }
}
}
/** Clear the selected cells (or the focused cell when nothing is selected). */
fun deleteSelection() {
val p = project.activePattern()
for (t in selTrackRange) for (l in selLineRange) p.cell(t, l).clear()
touched()
}
/** Copy then clear — a standard cut. */
fun cutSelection() { copySelection(); deleteSelection() }
/** Paste the clipboard with its top-left at the cursor, clipped to the grid. */
fun pasteClipboard() {
val cb = clipboard ?: return
val p = project.activePattern()
for (ti in cb.indices) for (li in cb[ti].indices) {
val t = cursorTrack + ti
val l = cursorLine + li
if (t < Pattern.TRACK_COUNT && l < p.length) p.cell(t, l).setFrom(cb[ti][li])
}
touched()
}
/** Which block (0..7) the tracker is editing. Lane i is tied to block i. */
val activeBlock: Int get() = project.activePatternId
fun setActiveBlock(i: Int) {
project.activePatternId = i.coerceIn(0, project.patterns.size - 1)
cursorLine = cursorLine.coerceIn(0, project.activePattern().length - 1)
touched()
}
// ------------------------------------------------------- transport control
fun playPause() {
val t = transport.value
if (t.isPlaying) engine.pause() else engine.play()
}
fun stop() = engine.stop()
// --------------------------------------------- project-level setting edits
fun setTempo(bpm: Float) { project.tempoBpm = bpm.coerceIn(20f, 300f); touched() }
fun setTimeSignature(sig: TimeSignature) {
project.timeSignature = sig
// Snap pattern length to the shortest option for the new signature.
val p = project.activePattern()
if (p.length !in sig.lengthOptions) p.resize(sig.lengthOptions[0])
cursorLine = cursorLine.coerceIn(0, p.length - 1)
touched()
}
fun setPatternLength(len: Int) {
project.activePattern().resize(len)
cursorLine = cursorLine.coerceIn(0, len - 1)
touched()
}
// ----------------------------------------------------- input action router
/** The single dispatch point for keyboard / gamepad / MIDI actions. */
private fun onAction(action: InputAction) {
when (action) {
InputAction.NavUp -> moveCursor(0, -1)
InputAction.NavDown -> moveCursor(0, +1)
InputAction.NavLeft -> moveCursor(-1, 0)
InputAction.NavRight -> moveCursor(+1, 0)
InputAction.NextColumn -> nextColumn()
InputAction.PrevColumn -> prevColumn()
is InputAction.Increment -> editFocused(action.amount)
is InputAction.Decrement -> editFocused(-action.amount)
InputAction.ClearCell -> clearFocused()
InputAction.NoteOffCell -> noteOffFocused()
is InputAction.NoteOn -> {
engine.liveNoteOn(action.pitch, action.velocity)
// Live entry also writes the note into the focused NOTE cell.
if (cursorColumn == CellColumn.NOTE) {
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
}
}
is InputAction.NoteOff -> engine.liveNoteOff(action.pitch)
InputAction.PlayPause -> playPause()
InputAction.Stop -> stop()
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
is InputAction.SelectTab -> selectTab(action.index)
InputAction.NextTab -> selectTab(selectedTab + 1)
InputAction.PrevTab -> selectTab(selectedTab - 1)
is InputAction.RawControl -> onRawControl(action)
}
}
// ------------------------------------------------------- binding learn
/** Arm MIDI-learn / gamepad-rebind: the next matching control is bound to
* [actionId]. [source] restricts which device kind will be captured. */
fun beginLearn(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
learnTarget = actionId
learnSource = source
router.learnMode = true
}
fun cancelLearn() {
learnTarget = null
learnSource = null
router.learnMode = false
}
/** An unbound control arrived. If we're learning and it matches the armed
* source, store the binding into the right handler's map. */
private fun onRawControl(action: InputAction.RawControl) {
val target = learnTarget ?: return
if (action.source != learnSource) return
when (action.source) {
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
midiInput.ccBindings[action.code] = target
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
gamepadInput.bindings[action.code] = target
else -> {}
}
cancelLearn()
touched()
}
/** Current MIDI CC number bound to [actionId], or null. */
fun midiBindingFor(actionId: String): Int? =
midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key
/** Current gamepad key code bound to [actionId], or null. */
fun gamepadBindingFor(actionId: String): Int? =
gamepadInput.bindings.entries.firstOrNull { it.value == actionId }?.key
fun clearBinding(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
when (source) {
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
midiInput.ccBindings.entries.removeAll { it.value == actionId }
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
gamepadInput.bindings.entries.removeAll { it.value == actionId }
else -> {}
}
touched()
}
private fun touched() { revision++ }
/** Public hook so screens that mutate the model directly (e.g. dropdowns in
* the toolbar, mixer faders) can request a redraw. */
fun bumpForToolbar() = touched()
/** Call after changing mixer FX routing so the audio engine rebuilds the
* per-channel insert chains without waiting for the next Play. */
fun rebuildAudioRouting() {
engine.rebuildFxChains()
touched()
}
// -------------------------------------------------------------- sampler
private val recorder = com.reactorcoremeltdown.sizzletracker.audio.SampleRecorder()
var isRecording by mutableStateOf(false); private set
/** Store already-decoded sample bytes into a slot (from a WAV file import). */
fun loadSampleInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, id: String, bytes: ByteArray): Boolean {
val sample = com.reactorcoremeltdown.sizzletracker.audio.SampleStore.decodeWav(bytes) ?: return false
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
slot.set("samplePath", id)
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
touched()
return true
}
fun startRecording() { if (recorder.start()) isRecording = true }
// ------------------------------------------------------- audio device routing
/** Ids of the chosen output/input devices (for the Settings UI), null = default. */
var outputDeviceId by mutableStateOf<Int?>(null); private set
var inputDeviceId by mutableStateOf<Int?>(null); private set
fun setAudioOutput(device: android.media.AudioDeviceInfo?) {
engine.setPreferredOutput(device)
outputDeviceId = device?.id
}
fun setAudioInput(device: android.media.AudioDeviceInfo?) {
recorder.preferredInput = device
inputDeviceId = device?.id
}
/** Whether the native Oboe output backend is selected. */
var nativeEngine by mutableStateOf(false); private set
val nativeEngineAvailable: Boolean
get() = com.reactorcoremeltdown.sizzletracker.audio.NativeAudioBridge.isAvailable()
fun applyNativeEngine(enabled: Boolean) {
engine.setNativeOutput(enabled)
nativeEngine = engine.useNativeOutput
}
/** Stop recording and load the captured audio into [slot]. */
fun stopRecordingInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot) {
val sample = recorder.stop()
isRecording = false
if (sample != null) {
val id = "rec-" + System.currentTimeMillis()
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
slot.set("samplePath", id)
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
touched()
}
}
/** Point an LFO slot at another device's parameter, capturing that param's
* current value as the modulation centre. Pass targetSlotIndex < 0 to clear. */
fun setLfoTarget(
lfoSlot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot,
targetSlotIndex: Int,
key: String,
) {
if (targetSlotIndex < 0) {
lfoSlot.set("target", "")
} else {
val tSlot = project.toolbox.getOrNull(targetSlotIndex)
val spec = tSlot?.type?.params?.firstOrNull { it.key == key }
lfoSlot.set("target", "$targetSlotIndex:$key")
lfoSlot.set("center", tSlot?.float(key, spec?.default ?: 0f) ?: 0f)
}
touched()
}
/** Preview a note of the Sampler slot's loaded sample (one-shot, editor use). */
fun auditionSample(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, midi: Int) {
engine.auditionSlotOn(slot.index, midi)
}
/** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
fun auditionOn(slotIndex: Int, midi: Int) = engine.auditionSlotOn(slotIndex, midi)
fun auditionOff(midi: Int) = engine.auditionSlotOff(midi)
// ------------------------------------------------------------ project I/O
/** Serialize the current song to `.sng` text. */
fun saveProjectText(): String =
com.reactorcoremeltdown.sizzletracker.io.ProjectIo.save(project)
/** Replace the current song's contents from our full internal text (in place). */
fun loadProjectText(text: String) {
com.reactorcoremeltdown.sizzletracker.io.ProjectIo.loadInto(project, text)
cursorLine = 0; cursorTrack = 0
touched()
}
/** Export to the reference desktop sizzletracker `.sng` format (musical data). */
fun exportSng(): String =
com.reactorcoremeltdown.sizzletracker.io.SngFormat.export(project)
/** Import a reference `.sng` file (in place). */
fun importSng(text: String) {
com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
cursorLine = 0; cursorTrack = 0
touched()
}
private fun nextOf(c: CellColumn, dir: Int): CellColumn {
val values = CellColumn.entries
return values[(c.ordinal + dir + values.size) % values.size]
}
}

View File

@@ -0,0 +1,32 @@
package com.reactorcoremeltdown.sizzletracker.ui.components
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
/**
* Lays out each distinct (text, style) pair exactly once and reuses the result on
* every later frame. The Canvas grids draw the same tiny set of fixed-width strings
* — note names, hex velocities, lane/bar numbers — dozens of times per frame, and
* re-measuring them every frame was the main cost that made the playhead feel
* sluggish. [TextMeasurer]'s own cache is a small LRU (8 entries) that a full grid
* blows past immediately, so we keep our own unbounded, allocation-free cache.
*
* Styles are compared by identity (===): callers pass a fixed handful of remembered
* [TextStyle] instances, so a short linear scan avoids hashing a whole TextStyle per
* cell. Not thread-safe — only ever touched from the draw phase.
*/
class GlyphCache(private val measurer: TextMeasurer) {
private val styles = ArrayList<TextStyle>(6)
private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6)
fun measure(text: String, style: TextStyle): TextLayoutResult {
var idx = styles.indexOfFirst { it === style }
if (idx < 0) {
styles.add(style)
byStyle.add(HashMap())
idx = styles.size - 1
}
return byStyle[idx].getOrPut(text) { measurer.measure(text, style) }
}
}

View File

@@ -0,0 +1,127 @@
package com.reactorcoremeltdown.sizzletracker.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
/**
* A tiny library of "pixel" UI building blocks. They are intentionally plain —
* hard rectangles, monospace text, 1px borders — so they are cheap to draw
* (helping the "lightweight UI" requirement) and match the retro aesthetic.
* Reusing these keeps every screen visually consistent.
*/
/** A flat, square-cornered button. [active] paints it in the accent colour. */
@Composable
fun RetroButton(
label: String,
modifier: Modifier = Modifier,
active: Boolean = false,
onClick: () -> Unit,
) {
val c = LocalRetro.current
Box(
modifier
.border(1.dp, if (active) c.accent else c.grid, RectangleShape)
.background(if (active) c.accent.copy(alpha = 0.18f) else c.surface)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (active) c.accent else c.text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
}
/** A labelled dropdown selector rendered as `LABEL:value ▾`. */
@Composable
fun <T> RetroDropdown(
label: String,
options: List<T>,
selected: T,
modifier: Modifier = Modifier,
optionLabel: (T) -> String = { it.toString() },
onSelect: (T) -> Unit,
) {
val c = LocalRetro.current
var open by remember { mutableStateOf(false) }
Box(modifier) {
Box(
Modifier
.border(1.dp, c.grid, RectangleShape)
.background(c.surface)
.clickable { open = true }
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(
"$label:${optionLabel(selected)}",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
options.forEach { opt ->
DropdownMenuItem(
text = { Text(optionLabel(opt), fontFamily = FontFamily.Monospace) },
onClick = { onSelect(opt); open = false },
)
}
}
}
}
/** A dim, spaced section header, e.g. "── TRANSPORT ──". */
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text.uppercase(),
modifier = modifier.padding(vertical = 4.dp),
color = LocalRetro.current.textDim,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
)
}
/** A grid of pixel "cells" used as a lightweight icon (e.g. play/stop glyphs).
* [rows] are strings of '#' (filled) and ' ' (empty). */
@Composable
fun PixelGlyph(rows: List<String>, color: Color, cell: Int = 3) {
Column {
rows.forEach { line ->
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
line.forEach { ch ->
Box(
Modifier
.size(cell.dp)
.background(if (ch == '#') color else Color.Transparent),
)
}
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,377 @@
package com.reactorcoremeltdown.sizzletracker.ui.settings
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioManager
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Piano
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.reactorcoremeltdown.sizzletracker.input.InputSource
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
/**
* The fourth tab, built from STANDARD Material 3 components (Cards, Buttons,
* ListItems, OutlinedTextField, FilterChips) rather than the bespoke retro grid,
* for familiar and accessible interaction. Functionality is unchanged:
* project save/load, theme selection + export, gamepad bindings, and a
* searchable / collapsible MIDI binding list.
*/
private enum class SettingsSection { MAIN, GAMEPAD, MIDI }
@Composable
fun SettingsScreen(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
var section by remember { mutableStateOf(SettingsSection.MAIN) }
when (section) {
// Top level stays light: the heavy binding lists live in their own
// subsections, so they only compose when the user actually opens them.
SettingsSection.MAIN -> LazyColumn(
Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ProjectCard(vm) }
item { AudioDevicesCard(vm) }
item { ThemeCard(vm) }
item {
NavCard("Gamepad Bindings", "Rebind buttons", Icons.Filled.SportsEsports) {
section = SettingsSection.GAMEPAD
}
}
item {
NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
section = SettingsSection.MIDI
}
}
}
SettingsSection.GAMEPAD -> LazyColumn(
Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
item {
Text("Tap Learn, then press a button to bind it.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
items(BIND_MODULES.values.flatten()) { action ->
BindingRow(vm, action, InputSource.GAMEPAD)
HorizontalDivider()
}
}
SettingsSection.MIDI -> LazyColumn(
Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item { SubHeader("MIDI Bindings") { section = SettingsSection.MAIN } }
item { MidiBindingSections(vm) }
}
}
}
/** Header row with a back arrow for a settings subsection. */
@Composable
private fun SubHeader(title: String, onBack: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
}
}
/** A tappable card that navigates into a settings subsection. */
@Composable
private fun NavCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
ElevatedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
ListItem(
leadingContent = { Icon(icon, null) },
headlineContent = { Text(title) },
supportingContent = { Text(subtitle) },
trailingContent = { Icon(Icons.Filled.ChevronRight, null) },
)
}
}
// ---------------------------------------------------------------- top-level cards
@Composable
private fun ProjectCard(vm: AppViewModel) {
val clipboard = LocalClipboardManager.current
SettingsCard("Project", subtitle = "Interchange with desktop sizzletracker (.sng), or full save") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { clipboard.setText(AnnotatedString(vm.exportSng())) }) {
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
Text("Export .sng")
}
OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::importSng) }) {
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
Text("Import .sng")
}
}
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { clipboard.setText(AnnotatedString(vm.saveProjectText())) }) { Text("Save full") }
OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::loadProjectText) }) { Text("Load full") }
}
Text(
".sng carries notes/blocks/arrangement (desktop-compatible); full save also keeps mixer, FX and instruments.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun AudioDevicesCard(vm: AppViewModel) {
SettingsCard("Audio Devices", subtitle = "Route playback / recording to USB or built-in") {
val context = LocalContext.current
val am = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
val outputs = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
val inputs = am.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
HorizontalDivider()
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
HorizontalDivider()
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Low-latency engine (Oboe)", style = MaterialTheme.typography.bodyMedium)
Text(
if (vm.nativeEngineAvailable) "Native AAudio output" else "Native library unavailable",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = vm.nativeEngine,
enabled = vm.nativeEngineAvailable,
onCheckedChange = { vm.applyNativeEngine(it) },
)
}
}
}
@Composable
private fun ThemeCard(vm: AppViewModel) {
val clipboard = LocalClipboardManager.current
SettingsCard("Color Theme", subtitle = "Pick a palette or export it as text") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RetroPalette.ALL.forEach { p ->
FilterChip(
selected = vm.palette.name == p.name,
onClick = { vm.palette = p },
label = { Text(p.name) },
leadingIcon = { Icon(Icons.Filled.Palette, null) },
)
}
}
OutlinedButton(
onClick = { clipboard.setText(AnnotatedString(exportTheme(vm.palette))) },
modifier = Modifier.padding(top = 8.dp),
) { Text("Export theme") }
}
}
/** A bindable action: user-facing label + the action id the input handlers use. */
private data class BindAction(val label: String, val id: String)
private val BIND_MODULES: Map<String, List<BindAction>> = linkedMapOf(
"Transport" to listOf(
BindAction("Play / Pause", "playPause"), BindAction("Stop", "stop"), BindAction("Toggle Loop", "loop"),
),
"Navigation" to listOf(
BindAction("Cursor Up", "up"), BindAction("Cursor Down", "down"),
BindAction("Cursor Left", "left"), BindAction("Cursor Right", "right"),
),
"Editing" to listOf(
BindAction("Value +", "increment"), BindAction("Value -", "decrement"), BindAction("Clear Cell", "clear"),
),
"Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
)
/** One action row with its current binding, a Learn toggle, and a clear button. */
@Composable
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
val listening = vm.learnTarget == action.id
val code = if (source == InputSource.MIDI) vm.midiBindingFor(action.id) else vm.gamepadBindingFor(action.id)
val label = code?.let { (if (source == InputSource.MIDI) "CC $it" else "key $it") } ?: "unbound"
ListItem(
headlineContent = { Text(action.label) },
supportingContent = { Text(label, style = MaterialTheme.typography.bodySmall) },
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { if (listening) vm.cancelLearn() else vm.beginLearn(action.id, source) }) {
Text(if (listening) "Listening…" else "Learn")
}
if (code != null) {
TextButton(onClick = { vm.clearBinding(action.id, source) }) { Text("") }
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
/** A label + dropdown that routes audio to a chosen device (or the default). */
@Composable
private fun DeviceRow(
label: String,
devices: List<AudioDeviceInfo>,
selectedId: Int?,
onSelect: (AudioDeviceInfo?) -> Unit,
) {
var open by remember { mutableStateOf(false) }
val current = devices.firstOrNull { it.id == selectedId }?.let { deviceLabel(it) } ?: "Default"
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Box {
OutlinedButton(onClick = { open = true }) { Text(current, maxLines = 1) }
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(text = { Text("Default") }, onClick = { onSelect(null); open = false })
devices.forEach { d ->
DropdownMenuItem(text = { Text(deviceLabel(d)) }, onClick = { onSelect(d); open = false })
}
}
}
}
}
private fun deviceLabel(d: AudioDeviceInfo): String {
val kind = when (d.type) {
AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB"
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Mic"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired"
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BT"
else -> "Dev"
}
return "${d.productName} · $kind"
}
/** A titled Material card wrapper used for every settings group. */
@Composable
private fun SettingsCard(
title: String,
subtitle: String? = null,
content: @Composable () -> Unit,
) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
content()
}
}
}
private fun exportTheme(p: RetroPalette): String = buildString {
appendLine("SIZZLE-THEME 1")
appendLine("NAME=${p.name}")
fun hex(color: Color) =
"#%02X%02X%02X".format((color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt())
appendLine("background=${hex(p.background)}")
appendLine("surface=${hex(p.surface)}")
appendLine("accent=${hex(p.accent)}")
appendLine("beat=${hex(p.beat)}")
appendLine("bar=${hex(p.bar)}")
appendLine("playhead=${hex(p.playhead)}")
}
/**
* Searchable + collapsible MIDI binding list. Each module is a Card whose header
* toggles expansion; its actions are real [BindingRow]s whose Learn button arms
* MIDI-learn — the next incoming CC is stored into the live MIDI binding map.
*/
@Composable
private fun MidiBindingSections(vm: AppViewModel) {
var query by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf<String?>("Transport") }
OutlinedTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
leadingIcon = { Icon(Icons.Filled.Search, null) },
placeholder = { Text("Search controls…") },
modifier = Modifier.fillMaxWidth(),
)
BIND_MODULES.forEach { (module, actions) ->
val matches = actions.filter { it.label.contains(query, ignoreCase = true) }
if (query.isNotEmpty() && matches.isEmpty()) return@forEach
val isOpen = expanded == module || query.isNotEmpty()
Card(Modifier.fillMaxWidth().padding(top = 8.dp)) {
Row(
Modifier
.fillMaxWidth()
.clickable { expanded = if (expanded == module) null else module }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(module, style = MaterialTheme.typography.titleSmall)
Icon(if (isOpen) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null)
}
if (isOpen) {
matches.forEach { action -> BindingRow(vm, action, InputSource.MIDI) }
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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)

View File

@@ -0,0 +1,176 @@
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.audio.SampleStore
import com.reactorcoremeltdown.sizzletracker.model.Pitch
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
import kotlin.math.abs
/**
* The Sampler's custom editor (replaces the generic parameter list for the
* Sampler device). It lets you import a WAV or record from the mic/USB input,
* trim the sample with two draggable slice markers on a waveform, set the start
* octave + volume, and audition across a two-octave keyboard.
*/
@Composable
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
val context = LocalContext.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: waveform/markers refresh
val sampleId = slot.string("samplePath")
val sliceStart = slot.float("sliceStart", 0f)
val sliceEnd = slot.float("sliceEnd", 1f)
val startOctave = slot.float("startOctave", 3f).toInt()
val volume = slot.float("volume", 0.8f)
// System file picker for importing a WAV.
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()?.let { bytes ->
val ok = vm.loadSampleInto(slot, uri.toString(), bytes)
if (!ok) { /* unsupported file; a toast could go here */ }
}
}
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// ----- Source buttons -----
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RetroButton("IMPORT WAV") { picker.launch(arrayOf("audio/*")) }
if (vm.isRecording) {
RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot) }
} else {
RetroButton("● RECORD") { vm.startRecording() }
}
}
// ----- Waveform + draggable slice markers -----
val sample = SampleStore.get(sampleId)
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
var dragging by remember { mutableIntStateOf(-1) }
Canvas(
Modifier
.fillMaxWidth()
.height(130.dp)
.background(c.surface)
.pointerInput(sampleId) {
detectDragGestures(
onDragStart = { off ->
val frac = (off.x / size.width).coerceIn(0f, 1f)
dragging = if (abs(frac - sliceStart) <= abs(frac - sliceEnd)) 0 else 1
},
onDrag = { change, _ ->
change.consume()
val frac = (change.position.x / size.width).coerceIn(0f, 1f)
if (dragging == 0) slot.set("sliceStart", frac) else slot.set("sliceEnd", frac)
vm.bumpForToolbar()
},
)
},
) {
val w = size.width
val h = size.height
val mid = h / 2f
if (peaks != null) {
val (mins, maxs) = peaks
val bw = w / mins.size
for (i in mins.indices) {
val x = i * bw
drawLine(
color = c.text,
start = Offset(x, mid - maxs[i] * mid),
end = Offset(x, mid - mins[i] * mid),
strokeWidth = 1f,
)
}
}
// Shade the active slice region and draw the two markers.
val sx = sliceStart * w
val ex = sliceEnd * w
drawRect(color = c.accent.copy(alpha = 0.14f), topLeft = Offset(sx, 0f), size = Size((ex - sx), h))
drawLine(c.accent, Offset(sx, 0f), Offset(sx, h), strokeWidth = 3f)
drawLine(c.playhead, Offset(ex, 0f), Offset(ex, h), strokeWidth = 3f)
}
Text(
if (sample == null) "No sample loaded — import a WAV or record one."
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers to trim",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
)
// ----- Start octave + volume -----
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Start oct:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
modifier = Modifier.padding(top = 6.dp))
RetroButton("-") { slot.set("startOctave", (startOctave - 1).coerceAtLeast(0).toFloat()); vm.bumpForToolbar() }
Text(" $startOctave ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
modifier = Modifier.padding(top = 6.dp))
RetroButton("+") { slot.set("startOctave", (startOctave + 1).coerceAtMost(8).toFloat()); vm.bumpForToolbar() }
}
ParamControl(vm, slot, com.reactorcoremeltdown.sizzletracker.model.ParamSpec("volume", "Volume", volume, 0f, 1f))
// ----- Two-octave audition keyboard -----
Text("Audition (2 octaves):", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
val root = ((startOctave + 1) * 12).coerceIn(0, 103)
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(2.dp)) {
for (n in 0 until 24) {
val midi = root + n
RetroButton(Pitch.name(midi)) { vm.auditionSample(slot, midi) }
}
}
}
}
/** Down-sample the waveform to [buckets] min/max pairs for cheap drawing. */
private fun computePeaks(data: FloatArray, buckets: Int): Pair<FloatArray, FloatArray> {
val mins = FloatArray(buckets)
val maxs = FloatArray(buckets)
if (data.isEmpty()) return mins to maxs
val step = (data.size / buckets).coerceAtLeast(1)
for (b in 0 until buckets) {
var mn = 1f; var mx = -1f
val start = b * step
val end = minOf(start + step, data.size)
if (start >= data.size) { mins[b] = 0f; maxs[b] = 0f; continue }
for (i in start until end) {
val v = data[i]
if (v < mn) mn = v
if (v > mx) mx = v
}
mins[b] = mn.coerceIn(-1f, 1f)
maxs[b] = mx.coerceIn(-1f, 1f)
}
return mins to maxs
}

View File

@@ -0,0 +1,307 @@
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.io.PresetIo
import com.reactorcoremeltdown.sizzletracker.model.Pitch
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
/**
* The third tab. Top half: a 4x4 grid of 16 slots holding instruments or effects.
* - SINGLE tap -> select the tile (highlighted).
* - DOUBLE tap -> open its edit window (device picker if empty, params if filled).
* - LONG press -> empty the cell.
*
* Bottom half: a stacked two-octave keyboard (one octave per row) that plays the
* currently-selected tile IF it is an instrument, for quick testing.
*
* The parameter editor is generated automatically from the device's [ToolboxType]
* parameter list, so new devices need no bespoke UI.
*/
@Composable
fun ToolboxScreen(vm: AppViewModel) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
var selected by remember { mutableStateOf(-1) }
var editing by remember { mutableStateOf<Int?>(null) }
var picking by remember { mutableStateOf<Int?>(null) }
Column(Modifier.fillMaxSize().background(c.background)) {
LazyVerticalGrid(
columns = GridCells.Fixed(4),
modifier = Modifier.weight(1f).fillMaxWidth().padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(vm.project.toolbox) { slot ->
SlotTile(
// The changing revision defeats strong-skipping so tiles refresh.
rev = vm.revision,
slot = slot,
selected = selected == slot.index,
onTap = { selected = slot.index },
onDoubleTap = { if (slot.isEmpty) picking = slot.index else editing = slot.index },
onLongPress = { slot.clearSlot(); vm.bumpForToolbar() },
)
}
}
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
AuditionKeyboard(vm, vm.project.toolbox.getOrNull(selected), Modifier.weight(1f))
}
picking?.let { index ->
DevicePicker(
onDismiss = { picking = null },
onPick = { type ->
vm.project.toolbox[index].fill(type)
vm.bumpForToolbar(); picking = null; editing = index
},
)
}
editing?.let { index ->
ParamEditor(vm, vm.project.toolbox[index], onClose = { editing = null })
}
}
@Composable
private fun SlotTile(
rev: Int,
slot: ToolboxSlot,
selected: Boolean,
onTap: () -> Unit,
onDoubleTap: () -> Unit,
onLongPress: () -> Unit,
) {
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
val c = LocalRetro.current
val borderColor = when { selected -> c.playhead; !slot.isEmpty -> c.accent; else -> c.grid }
val fill = when { selected -> c.accent.copy(alpha = 0.28f); !slot.isEmpty -> c.accent.copy(alpha = 0.12f); else -> c.surface }
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.border(if (selected) 2.dp else 1.dp, borderColor, RectangleShape)
.background(fill)
.pointerInput(slot.index, slot.isEmpty) {
detectTapGestures(
onTap = { onTap() },
onDoubleTap = { onDoubleTap() },
onLongPress = { onLongPress() },
)
},
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("${slot.index + 1}".padStart(2, '0'),
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
Text(
if (slot.isEmpty) "" else slot.name,
color = if (slot.isEmpty) c.textDim else c.text,
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
)
slot.type?.let {
Text(it.kind.name.take(3), color = c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 9.sp)
}
}
}
}
// ---------------------------------------------------------- test keyboard
private val ACCIDENTALS = setOf(1, 3, 6, 8, 10) // pitch-classes of the "black keys"
/**
* Two stacked octaves (one per row) that audition the selected instrument tile.
* Disabled (dimmed) when nothing is selected or the selection is an effect.
*/
@Composable
private fun AuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot?, modifier: Modifier) {
val c = LocalRetro.current
val isInstrument = slot?.type?.kind == ToolboxKind.INSTRUMENT
Column(
modifier.fillMaxWidth().background(c.background).padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
when {
slot == null -> "Tap a tile to select an instrument to test"
!isInstrument -> "${slot.name}: not an instrument"
else -> "Test: ${slot.name}"
},
color = if (isInstrument) c.accent else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
)
val slotIndex = slot?.index ?: -1
val base = 48 // C3
KeyRow(vm, slotIndex, isInstrument, base + 12, Modifier.weight(1f)) // upper octave (C4..B4) on top
KeyRow(vm, slotIndex, isInstrument, base, Modifier.weight(1f)) // lower octave (C3..B3)
}
}
@Composable
private fun KeyRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
for (i in 0 until 12) {
Key(vm, slotIndex, enabled, startMidi + i, Modifier.weight(1f).fillMaxHeight())
}
}
}
@Composable
private fun Key(vm: AppViewModel, slotIndex: Int, enabled: Boolean, midi: Int, modifier: Modifier) {
val c = LocalRetro.current
val accidental = (midi % 12) in ACCIDENTALS
val keyColor = (if (accidental) c.grid else c.surface).let { if (enabled) it else it.copy(alpha = 0.4f) }
// Keep the gesture handler stable across selection changes. Keying pointerInput
// on `midi` alone (constant per key) means selecting a different instrument does
// NOT tear down and relaunch all 24 keys' gesture coroutines every tap — the
// live slot/enabled are read through rememberUpdatedState instead.
val liveSlot = rememberUpdatedState(slotIndex)
val liveEnabled = rememberUpdatedState(enabled)
Box(
modifier
.background(keyColor)
.border(1.dp, c.grid, RectangleShape)
.pointerInput(midi) {
// Press → note on; release/cancel → note off (proper key behaviour).
awaitEachGesture {
awaitFirstDown()
val idx = liveSlot.value
if (liveEnabled.value && idx >= 0) {
vm.auditionOn(idx, midi)
waitForUpOrCancellation()
vm.auditionOff(midi)
} else {
waitForUpOrCancellation()
}
}
},
contentAlignment = Alignment.Center,
) {
Text(
Pitch.name(midi),
color = if (accidental) c.textDim else c.text,
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
)
}
}
/** A simple overlay list of all available instruments and effects to load. */
@Composable
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
val c = LocalRetro.current
Box(
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
contentAlignment = Alignment.Center,
) {
Column(Modifier.padding(16.dp)) {
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
ToolboxType.entries.forEach { type ->
Text(
"[${type.kind.name.take(3)}] ${type.displayName}",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier
.fillMaxWidth()
.pointerInput(type) { detectTapGestures { onPick(type) } }
.padding(vertical = 6.dp),
)
}
}
}
}
/** Auto-generated parameter editor plus preset copy/paste to the clipboard. */
@Composable
private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
val c = LocalRetro.current
val clipboard = LocalClipboardManager.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
val type = slot.type ?: return
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
Column(Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
}
// The Sampler gets a bespoke editor (waveform + slicing + record);
// the LFO adds a modulation-target picker; everything else uses the
// auto-generated parameter list.
when (type) {
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
ToolboxType.LFO -> {
type.params.forEach { spec -> ParamControl(vm, slot, spec) }
LfoTargetPicker(vm, slot)
}
else -> type.params.forEach { spec -> ParamControl(vm, slot, spec) }
}
// ----- Preset import / export (human-readable text via clipboard) -----
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PresetButton("COPY PRESET") {
clipboard.setText(AnnotatedString(PresetIo.export(slot)))
}
PresetButton("PASTE PRESET") {
clipboard.getText()?.text?.let { PresetIo.import(slot, it); vm.bumpForToolbar() }
}
}
Text(
"Presets are plain text (KEY=VALUE). Copy to share, paste to load.",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
)
}
}
}
@Composable
private fun PresetButton(label: String, onClick: () -> Unit) {
val c = LocalRetro.current
Text(
"[$label]",
color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClick() } }.padding(4.dp),
)
}

View File

@@ -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
}

View File

@@ -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')

View File

@@ -0,0 +1,239 @@
package com.reactorcoremeltdown.sizzletracker.ui.tracker
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.model.Cell
import com.reactorcoremeltdown.sizzletracker.model.CellColumn
import com.reactorcoremeltdown.sizzletracker.model.Pattern
import com.reactorcoremeltdown.sizzletracker.model.Pitch
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.GlyphCache
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
/**
* The demoscene pattern grid, drawn as ONE [Canvas] instead of a tree of ~180
* per-cell composables. Collapsing it into a single draw pass (with a cached
* [rememberTextMeasurer] so repeated cell strings like "···" are measured once)
* removes the composition/layout churn that made the grid feel sluggish as the
* playhead moved — the whole grid is now a couple of hundred draw calls per frame.
*
* Visuals: beat and bar rows are tinted distinctly; the four vertical tracks get
* an alternating tint + separator lines; the cursor cell and playhead row are
* highlighted. Gestures are unchanged (tap to focus, vertical drag to edit).
*/
@Composable
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
val c = LocalRetro.current
// Read in composition so structural changes (edits, resize, active-block swap —
// all of which bump revision) re-fetch `pattern`/`sig` and recompose. Playback
// does NOT bump revision, so this never fires per frame during playback.
val structureKey = vm.revision
@Suppress("UNUSED_EXPRESSION") structureKey
// Held as a State and read ONLY inside the draw/gesture lambdas below, never in
// composition: a playhead tick then invalidates the draw phase alone, skipping
// recomposition + layout entirely.
val transportState = vm.transport.collectAsState()
val pattern = vm.project.activePattern()
val sig = vm.project.timeSignature
val haptic = LocalHapticFeedback.current
val measurer = rememberTextMeasurer()
val glyphs = remember(measurer) { GlyphCache(measurer) }
// Which column's long-press editor popup is open (null = none).
var editorColumn by remember { mutableStateOf<CellColumn?>(null) }
// Cache the text styles per palette (fontSize is resolved with the ambient density).
val mono = FontFamily.Monospace
val styleText = remember(c) { TextStyle(color = c.text, fontFamily = mono, fontSize = 12.sp) }
val styleDimNote = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
val styleVel = remember(c) { TextStyle(color = c.accent, fontFamily = mono, fontSize = 12.sp) }
val styleChan = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
val styleGutter = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 11.sp) }
BoxWithConstraints(modifier.fillMaxSize().background(c.background)) {
val density = LocalDensity.current
val rowPx = with(density) { 20.dp.toPx() }
val gutterPx = with(density) { 30.dp.toPx() }
val widthPx = with(density) { maxWidth.toPx() }
val heightPx = with(density) { maxHeight.toPx() }
val trackPx = (widthPx - gutterPx) / Pattern.TRACK_COUNT
val notePx = trackPx * 0.5f
val velPx = trackPx * 0.25f
val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1)
// Which row sits at the top of the viewport. Computed on demand (not stored
// in composition) so it can be read from the draw phase during playback and
// from gestures at touch time, always against the live transport/cursor.
fun firstRow(): Int {
val t = transportState.value
val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
return (focusLine - visibleRows / 2)
.coerceIn(0, (pattern.length - visibleRows).coerceAtLeast(0))
}
fun hitTest(x: Float, y: Float): Triple<Int, CellColumn, Int>? {
if (x < gutterPx) return null
val line = (firstRow() + (y / rowPx).toInt()).coerceIn(0, pattern.length - 1)
val tx = x - gutterPx
val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
val within = tx - track * trackPx
val col = when {
within < notePx -> CellColumn.NOTE
within < notePx + velPx -> CellColumn.VELOCITY
else -> CellColumn.CHANNEL
}
return Triple(track, col, line)
}
Canvas(
Modifier
.fillMaxSize()
.pointerInput(pattern, widthPx, heightPx) {
detectTapGestures(
// Tap: just move the cursor to the cell/column.
onTap = { off ->
hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
},
// Long-press: focus the cell and open its value editor popup.
onLongPress = { off ->
hitTest(off.x, off.y)?.let { (t, col, line) ->
vm.focusCell(t, line, col)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
editorColumn = col
}
},
)
},
) {
// Subscribe the DRAW PHASE to edit revisions. Model mutations (edit,
// clear, DEL/paste) bump revision but touch no other snapshot state the
// draw reads, so without this a delete would not invalidate the Canvas
// even though composition re-ran (the draw lambda is memoised identical).
// Reading it here redraws on every edit, draw-only.
@Suppress("UNUSED_EXPRESSION") vm.revision
// Draw-phase reads: transport (playhead) and, via firstRow(), the cursor
// scroll position. A change to either invalidates THIS DRAW only — no
// recomposition, no relayout — which is what keeps the playhead smooth.
val transport = transportState.value
val top = firstRow()
// Selection rectangle (draw-phase reads → redraw as the cursor drags it).
val selActive = vm.hasSelection
val selTracks = vm.selTrackRange
val selLines = vm.selLineRange
// 1) Alternating vertical track tints + separators (full height).
for (t in 0 until Pattern.TRACK_COUNT) {
val x0 = gutterPx + t * trackPx
if (t % 2 == 1) {
drawRect(c.grid.copy(alpha = 0.35f), Offset(x0, 0f), Size(trackPx, heightPx))
}
drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line
}
// 2) Rows: beat/bar/playhead backgrounds + cursor + text.
for (screen in 0 until visibleRows) {
val line = top + screen
if (line >= pattern.length) break
val y = screen * rowPx
val rowColor = when {
transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)
line % sig.linesPerBar == 0 -> c.bar.copy(alpha = 0.7f)
line % sig.linesPerBeat == 0 -> c.beat.copy(alpha = 0.7f)
else -> Color.Transparent
}
if (rowColor != Color.Transparent) {
drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
}
// Gutter line number (hex).
drawCentered(glyphs, line.toString(16).uppercase().padStart(2, '0'),
styleGutter, 2f, y, gutterPx, rowPx)
for (t in 0 until Pattern.TRACK_COUNT) {
val cell = pattern.cell(t, line)
val baseX = gutterPx + t * trackPx
val focused = vm.cursorTrack == t && vm.cursorLine == line
// Selection wash (whole cell, under the cursor highlight).
if (selActive && t in selTracks && line in selLines) {
drawRect(c.accent.copy(alpha = 0.18f), Offset(baseX, y), Size(trackPx, rowPx))
}
// Cursor cell highlight.
if (focused) {
val (cx, cw) = when (vm.cursorColumn) {
CellColumn.NOTE -> baseX to notePx
CellColumn.VELOCITY -> (baseX + notePx) to velPx
CellColumn.CHANNEL -> (baseX + notePx + velPx) to velPx
}
drawRect(c.accent.copy(alpha = 0.30f), Offset(cx, y), Size(cw, rowPx))
}
// NOTE column reads bright even when empty (bright placeholder
// dots, per spec). VEL/CHAN use dim dots when empty and their
// coloured value when filled, so only the note column is bright.
drawCentered(glyphs, noteText(cell), styleText, baseX + 2f, y, notePx, rowPx)
drawCentered(glyphs, velText(cell), if (cell.isEmpty) styleDimNote else styleVel,
baseX + notePx + 2f, y, velPx, rowPx)
drawCentered(glyphs, chanText(cell), if (cell.isEmpty) styleDimNote else styleChan,
baseX + notePx + velPx + 2f, y, velPx, rowPx)
}
}
}
// Long-press value editor, overlaid on the grid area.
editorColumn?.let { col ->
CellEditorPopup(vm, col) { editorColumn = null }
}
}
}
/** Draw [text] vertically centred in a cell of the given width/height. */
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCentered(
glyphs: GlyphCache,
text: String,
style: TextStyle,
x: Float,
y: Float,
cellWidth: Float,
cellHeight: Float,
) {
val layout = glyphs.measure(text, style)
drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
}
// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
private fun noteText(cell: Cell): String = when {
cell.isEmpty -> "···"
cell.isNoteOff -> "==="
else -> Pitch.name(cell.note)
}
private fun velText(cell: Cell): String =
if (cell.isEmpty) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
private fun chanText(cell: Cell): String =
if (cell.isEmpty) ".." else cell.channel.toString().padStart(2, '0')

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Pixel-art launcher glyph: a chunky 1/8th note drawn on the 108x108 adaptive
canvas (the safe zone is the centre 66x66). Colour is the retro amber that
the app's default theme uses. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Note stem (a vertical run of "pixels") -->
<path android:fillColor="#F2C14E"
android:pathData="M60,30 h12 v42 h-12 z" />
<!-- Note flag -->
<path android:fillColor="#F2C14E"
android:pathData="M72,30 h12 v12 h-12 z M72,42 h6 v12 h-6 z" />
<!-- Note head (a fat pixel square) -->
<path android:fillColor="#F2C14E"
android:pathData="M36,60 h24 v24 h-24 z" />
<!-- Two grid ticks to read as "tracker" -->
<path android:fillColor="#2E7D5B"
android:pathData="M24,30 h6 v6 h-6 z M24,48 h6 v6 h-6 z" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<resources>
<!-- Only colors the Android framework needs before Compose starts. The full
theming system lives in ui/theme/Theme.kt. -->
<color name="window_background">#0B0D0E</color>
<color name="ic_launcher_background">#0B0D0E</color>
</resources>

View File

@@ -0,0 +1,12 @@
<resources>
<string name="app_name">Sizzletracker</string>
<!-- Tab titles -->
<string name="tab_tracker">TRACKER</string>
<string name="tab_mixer">MIX</string>
<string name="tab_toolbox">TOOLBOX</string>
<string name="tab_settings">SETUP</string>
<!-- Media notification channel -->
<string name="channel_playback">Playback</string>
</resources>

View File

@@ -0,0 +1,12 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- The Android-XML theme only matters before Compose takes over (splash /
window background). All real UI styling lives in Kotlin (ui/theme/*.kt).
We inherit from a Material3 no-action-bar base and paint the window the
same near-black the Compose theme uses, so there is no flash on launch. -->
<style name="Theme.Sizzletracker" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@color/window_background</item>
<item name="android:navigationBarColor">@color/window_background</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Match any USB device that exposes an Audio class interface (class 0x01,
which covers USB-MIDI streaming and USB audio). The system uses this to
offer our app when such a device is attached. -->
<resources>
<usb-device class="1" />
</resources>

View File

@@ -0,0 +1,80 @@
package com.reactorcoremeltdown.sizzletracker.io
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
import com.reactorcoremeltdown.sizzletracker.model.Pitch
import com.reactorcoremeltdown.sizzletracker.model.Project
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Verifies the `.sng` reader/writer against the reference desktop format. */
class SngFormatTest {
private val reference = """
version 1
bpm 120
sig 4 4
block A 16 4
roll ####
track T1 0
0 C-4 64 01
4 E-4 .. ..
8 OFF .. ..
endblock
""".trimIndent()
@Test
fun parsesReferenceExample() {
val p = SngFormat.import(reference)
assertEquals(120f, p.tempoBpm, 0.001f)
assertEquals(TimeSignature.FOUR_FOUR, p.timeSignature)
val block = p.patterns[0]
assertEquals("A", block.name)
assertEquals(16, block.length)
val c0 = block.cell(0, 0)
assertEquals(Pitch.parse("C-4"), c0.note)
assertEquals(0x64, c0.velocity) // velocity is hexadecimal
assertEquals(1, c0.channel)
val c4 = block.cell(0, 4)
assertEquals(Pitch.parse("E-4"), c4.note)
assertEquals(0x7F, c4.velocity) // ".." -> default (full MIDI velocity)
assertEquals(1, c4.channel) // ".." -> inherit track channel (0 -> 1)
assertTrue(block.cell(0, 8).isNoteOff)
// roll "####" enables block 0 on beats 0..3 of its own lane.
for (beat in 0..3) assertNotEquals(Arrangement.EMPTY, p.arrangement.patternAt(0, beat))
assertEquals(Arrangement.EMPTY, p.arrangement.patternAt(0, 4))
}
@Test
fun roundTripsNotesAndRoll() {
val p = Project()
p.tempoBpm = 140f
p.timeSignature = TimeSignature.FIVE_FOUR
p.patterns[0].cell(0, 0).apply { note = 60; velocity = 0x40; channel = 3 }
p.patterns[0].cell(1, 5).apply { note = 67; velocity = 0x7F; channel = 2 }
p.arrangement.set(0, 0, 0)
p.arrangement.set(0, 1, 0)
val restored = SngFormat.import(SngFormat.export(p))
assertEquals(140f, restored.tempoBpm, 0.001f)
assertEquals(TimeSignature.FIVE_FOUR, restored.timeSignature)
restored.patterns[0].cell(0, 0).let {
assertEquals(60, it.note); assertEquals(0x40, it.velocity); assertEquals(3, it.channel)
}
restored.patterns[0].cell(1, 5).let {
assertEquals(67, it.note); assertEquals(0x7F, it.velocity); assertEquals(2, it.channel)
}
assertNotEquals(Arrangement.EMPTY, restored.arrangement.patternAt(0, 0))
assertNotEquals(Arrangement.EMPTY, restored.arrangement.patternAt(0, 1))
}
}

9
build.gradle.kts Normal file
View File

@@ -0,0 +1,9 @@
// Root build file. It declares the plugins used *somewhere* in the project but
// applies none of them here (apply false). Each module opts in to the ones it
// needs. Keeping this file thin is intentional.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

342
docs/DEVELOPER_HANDOVER.md Normal file
View File

@@ -0,0 +1,342 @@
# Sizzletracker (Android) — Developer Handover
This document is written for a developer who is **new to Android** and needs to
understand, build, and extend this codebase. Read it top to bottom once; after
that it works as a reference.
---
## 1. What this app is
A retro, grid-based **music tracker** for Android. The visual language is a
monospace font + pixel blocks laid out on a strict grid (demoscene tracker
style). It is playable with **four equally-capable input methods**: touchscreen,
physical keyboard, gamepad, and MIDI (USB or Bluetooth).
The UI has four tabs:
| Tab | Package | What it does |
|-----|---------|--------------|
| **Tracker** | `ui/tracker` | Pattern grid (4 tracks) + arrangement piano-roll (8 lanes) |
| **Mix** | `ui/mixer` | 4-channel mixer: instrument + 4 FX, MIDI ch, volume, mute/solo |
| **Toolbox** | `ui/toolbox` | 16 slots of instruments/effects with a generated parameter editor |
| **Setup** | `ui/settings` | Project save/load, themes, gamepad & MIDI bindings |
---
## 2. How to build & run
Requirements: **Android Studio** (Koala / 2024.1 or newer) which bundles a
JDK 17 and the Android SDK.
1. Open the project root in Android Studio. It will detect the Gradle build.
2. If prompted, let it **create the Gradle wrapper** (or run
`gradle wrapper --gradle-version 8.9` from a shell that has Gradle).
3. Let it download the SDK for `compileSdk 34`.
4. Pick a device/emulator running **Android 8.0 (API 26)** or newer and press
**Run**.
> The command line here (`./gradlew assembleDebug`) needs the wrapper JAR, which
> is generated by step 2. The wrapper JAR is intentionally not committed as a
> binary; generating it is a one-time step.
Key versions live in **one file**: `gradle/libs.versions.toml` (the "version
catalog"). Change a number there and the whole project follows.
---
## 3. The big picture (architecture)
```
┌──────────── Input sources ────────────┐
Touch ───▶│ (Compose gestures, call ViewModel) │
Keyboard ─▶ KeyboardInput ─┐ │
Gamepad ──▶ GamepadInput ──┤─▶ InputRouter (flow) ──┼──▶ AppViewModel
MIDI ─────▶ MidiInput ─────┘ │ │
└───────────────────────────────────────┘ │ reads/writes
Project (the song)
reads on audio thread │
AudioEngine ─────────┘ ──▶ AudioTrack ──▶ speakers
(own thread)
```
Three ideas hold it together:
1. **One neutral action type.** Every non-touch device is translated into an
`InputAction` (see `input/InputAction.kt`) and pushed through `InputRouter`.
The ViewModel reacts to those actions in exactly one place
(`AppViewModel.onAction`). Touch gestures call the same ViewModel methods
directly. That is *why* all four inputs are equally capable — features are
implemented once.
2. **One song object.** `model/Project.kt` holds the entire song in memory. The
UI edits it; the audio engine reads it; the file format serializes it.
3. **Two threads.** The UI runs on the main thread (Jetpack Compose). The sound
runs on a dedicated high-priority thread inside `AudioEngine`. They share the
`Project` object. Because the audio thread only ever *reads* simple `Int`/
`Float` fields, we accept the occasional benign race instead of locking (locks
would risk audio glitches). See §7.
---
## 4. Package/file map
```
com.reactorcoremeltdown.sizzletracker
├── SizzleApp.kt Application = tiny DI container (Project, InputRouter, AudioEngine)
├── MainActivity.kt Hosts Compose UI; forwards HW key/motion events; starts MIDI + service
├── model/ Pure data, no Android imports (unit-test friendly)
│ ├── Music.kt Pitch, TimeSignature, Scale (incl. scale-step logic)
│ ├── Pattern.kt Cell, CellColumn, Pattern (4 tracks x N lines)
│ ├── Arrangement.kt Arrangement (8 lanes x 256 beats), LoopRegion A/B
│ ├── Mixer.kt Mixer, MixerChannel (4 channels)
│ ├── Toolbox.kt ToolboxType (all instruments+effects), ParamSpec, ToolboxSlot
│ └── Project.kt The whole song
├── input/ The unified input layer
│ ├── InputAction.kt The neutral action vocabulary
│ ├── InputRouter.kt Flow-based message bus
│ ├── KeyboardInput.kt KeyEvent -> InputAction (+ a 2-octave typing piano)
│ ├── GamepadInput.kt KeyEvent/MotionEvent -> InputAction (rebindable)
│ └── MidiInput.kt MIDI bytes -> InputAction (USB & Bluetooth via MidiManager)
├── audio/ The real-time sound engine (own thread)
│ ├── Transport.kt TransportState snapshot published to the UI
│ ├── SynthVoice.kt One NES-style voice: pulse/triangle/noise + ADSR
│ └── AudioEngine.kt AudioTrack + sample-accurate sequencer + voice mixing
├── io/ Human-readable text persistence
│ ├── PresetIo.kt Instrument/effect preset <-> KEY=VALUE text
│ └── ProjectIo.kt Whole song <-> .sng text
├── playback/
│ └── PlaybackService.kt Foreground service: background playback + media notification
└── ui/
├── AppViewModel.kt Screen state + THE single InputAction handler
├── App.kt Tab shell
├── theme/Theme.kt RetroPalette + monospace typography + SizzleTheme
├── components/Widgets.kt RetroButton, RetroDropdown, SectionLabel, PixelGlyph
├── tracker/ TrackerScreen, PatternGrid, ArrangementRoll
├── mixer/MixerScreen.kt
├── toolbox/ ToolboxScreen, ParamControl
└── settings/SettingsScreen.kt
```
---
## 5. Key concepts you must understand
### 5.1 Ticks, beats, bars
A tracker **line** (row) is one tick. The number of lines per beat is set by the
**time signature** (`model/Music.kt`):
- 3/4 → 3 lines per beat, lengths 12/24/48
- 4/4 → 4 lines per beat, lengths 16/32/64
- 5/4 → 5 lines per beat, lengths 20/40/80
`PatternGrid` tints every beat row and every bar row differently using
`line % linesPerBeat` and `line % linesPerBar`.
### 5.2 The "revision" redraw trick (and the strong-skipping rule)
The model classes are **plain** (not Compose-observable) so the audio thread can
read them cheaply. After any edit the ViewModel does `revision++`
(`AppViewModel.touched()`, exposed as `bumpForToolbar()` for direct model edits).
**The rule that makes this actually work:** the Compose compiler we use (2.0.20)
has **strong skipping** enabled by default. It will *skip* a composable whose
parameters are referentially unchanged — even "unstable" ones like `vm` — so a
`revision++` in a *parent* is NOT enough; a child that reads plain model data
would be skipped and only refresh on a tab switch (which recreates the subtree).
So: **every composable that reads plain, mutable model data must itself read
`vm.revision`** (e.g. `val rev = vm.revision`). Reading a snapshot state directly
subscribes that composable's restart scope, so `revision++` invalidates it
regardless of skipping. Where a composable has no `vm` (e.g. `SlotTile`), pass
`rev = vm.revision` as a parameter instead — a changed `Int` param defeats
skipping too. Observable state (cursor position, `transport`, `palette`) already
works without this because it is real Compose state.
If you edit the model and the screen doesn't update: (a) did you call a
`touched()`/`bumpForToolbar()` path, and (b) does the leaf that renders that data
read `vm.revision`? This was the cause of an early "UI only redraws on tab
switch" bug.
### 5.3 The note-entry gesture
Tap a cell → cursor moves there (`focusCell`). Drag up/down → `editFocused(dir)`:
- On the NOTE column, `Scale.step()` moves to the next in-scale pitch.
- On VELOCITY/CHANNEL it steps the number.
Every step fires a haptic tick. The same `editFocused` runs from keyboard PageUp/
Down, gamepad right-stick, and MIDI CC — one implementation, four inputs.
### 5.4 Toolbox devices are data, not classes
Instead of a class per instrument/effect, every device is one `ToolboxType`
enum entry that lists its `ParamSpec`s. A `ToolboxSlot` stores the chosen type +
a `name -> value` string map. The parameter editor UI and the preset text format
are both **generated** from that list. See §6 to add a new device.
---
## 6. How to extend
### Add a new instrument or effect
1. Add an entry to `ToolboxType` (`model/Toolbox.kt`) with its `ParamSpec` list.
2. Teach `AudioEngine` how to render/process it (today only `NES_SYNTH` is
rendered; see `synthParamsForTrack`). Add a branch there.
That's it — the Toolbox picker, parameter editor, and preset save/load all work
automatically.
### Add a new input gesture/control
1. Add a case to `InputAction`.
2. Emit it from each source (`KeyboardInput`, `GamepadInput`, `MidiInput`) and/or
a Compose gesture.
3. Handle it once in `AppViewModel.onAction`.
### Add a new color theme
Add a `RetroPalette` to `RetroPalette.ALL` in `ui/theme/Theme.kt`. It appears in
the Settings theme picker immediately.
---
## 7. Threading & performance notes
- The audio render loop is in `AudioEngine.renderLoop()` on a `MAX_PRIORITY`
thread. It **must never allocate or block**. Keep it that way.
- Timing is derived from *counting audio samples* (`samplesPerLine`), not from
timers, so playback never drifts.
- UI ⇄ audio sharing is lock-free by design (audio reads plain fields). This can
produce a one-frame-stale read during a simultaneous edit — harmless for a
tracker. If you ever add compound state that must be read atomically, publish it
as an immutable snapshot (like `TransportState`) rather than adding locks.
- Block size is `BLOCK_FRAMES = 192` (~4 ms @ 48 kHz). Lower = tighter latency,
higher = safer against underruns.
- **UI rendering**: the pattern grid (`PatternGrid`) and arrangement roll
(`ArrangementRoll`) are each drawn as ONE `Canvas` with a cached
`rememberTextMeasurer`, not a tree of per-cell composables. This is what keeps
the grid smooth while the playhead moves — a single draw pass instead of
recomposing hundreds of `Text` nodes each frame. If you add grid features, draw
them in the Canvas rather than adding child composables.
- **Arrangement model**: each of the 8 lanes is tied to its own block (lane i ⟷
block i; `Project.patterns` holds 8 blocks). Toggling a beat cell on lane i
enables block i at that beat; the sequencer plays it when the playhead crosses.
Tap a lane number (left column) to edit that block in the tracker above.
---
## 8. File formats
### Preset (`PresetIo`)
```
SIZZLE-PRESET 1
TYPE=NES_SYNTH
NAME=Lead
wave=Pulse25
attack=0.01
...
```
Copy/paste from the Toolbox parameter editor uses this exact text.
### Project `.sng` (`ProjectIo`)
Line-oriented, sectioned (`[PATTERN ...]`, `[ARRANGEMENT ...]`, `[MIXER]`,
`[TOOLBOX]`). Only non-empty cells are written. It is fully round-trip safe.
---
## 9. Current status — implemented vs. TODO
This is an honest map so you know where the edges are. The **architecture and
all four tabs are in place and interactive**; several deep features are scaffolded
with a clear seam to finish them.
### Implemented and working
- Full 4-tab UI, retro monospace theme (+ theme switching), tab navigation.
- Tracker pattern grid: beat/bar coloring, cursor, tap-to-focus, drag-to-edit
with haptics, scale-aware note entry, transport toolbar, length/scale/root.
- Arrangement roll: 8 lanes, place/clear blocks, playhead follow, loop toolbar
with A/B enable + repeat counts.
- Mixer: 4 strips, instrument/FX routing to toolbox, MIDI ch, volume, mute/solo.
- Toolbox: 16 slots, device picker, **auto-generated** parameter editor, preset
copy/paste (text).
- Audio engine: dedicated thread, sample-accurate sequencer, NES-style synth
(pulse/tri/noise + ADSR), per-track mute/solo/volume, live note audition.
- **Arrangement-driven playback** (milestone 1 ✓): the sequencer walks a
pre-expanded beat playlist (with A/B loop repeats) across all 8 lanes; each
lane keeps its own within-pattern cursor so multi-beat blocks advance through
the pattern. Falls back to looping the active pattern when the arrangement is
empty. Voices are a lane×track matrix so lanes layer polyphonically.
- **Per-channel effect DSP** (milestone 3 ✓): each mixer channel runs an insert
chain built from its FX slots — Tape Delay, Digital Reverb, Filter (LP/HP/BP),
Bitcrusher, and 10-band EQ are real processors in `audio/Effects.kt`. Effect
parameters update live per audio block; add/remove rebuilds the chain.
- **Sampler instrument** ✓: WAV import (SAF file picker) + ad-hoc mic/USB
recorder (`audio/SampleRecorder.kt`), decoded to a mono cache
(`audio/SampleStore.kt`), played pitched + sliced by `audio/SampleVoice.kt`.
Its editor (`ui/toolbox/SamplerEditor.kt`) shows a waveform with two draggable
slice markers, start-octave + volume, and a two-octave audition keyboard. Each
(lane, track) has both a synth and a sample voice; the channel instrument type
selects which one a note triggers.
- Input: keyboard (nav + typing-piano + transport), gamepad (buttons + sticks +
hat), MIDI in (note + CC, USB & BT), all via the unified router.
- **MIDI-learn & gamepad rebind** ✓: the Settings tab lists every bindable action
with its current binding and a Learn button; arming it sets the router's
`learnMode`, and the next unbound CC / gamepad button is written into
`MidiInput.ccBindings` / `GamepadInput.bindings` (the handlers are now app
singletons shared with Settings). Bindings are clearable per action.
- **MIDI effects (Transposer / Arpeggiator / LFO)** ✓: Transposer shifts notes by
its semitone amount; the Arpeggiator octave-cycles a captured note
(Up/Down/UpDown/Random, 14 octaves) at a tempo-synced division
(`AudioEngine.advanceArps`); the LFO (`applyLfos`) modulates a chosen target
parameter of another device around a captured centre (Sine/Tri/Saw/Square/S&H),
with a target picker in its editor (`ui/toolbox/LfoEditor.kt`).
- **USB / audio device selection** ✓: Settings → Audio Devices lists the system's
output and input devices; choosing one routes the engine's `AudioTrack` and the
recorder's `AudioRecord` via `setPreferredDevice` (API 28+), so a USB DAC / USB
audio interface can be used for playback and recording.
- **Full MediaSession** ✓: `playback/PlaybackService` is now a media3
`MediaSessionService` hosting a session that wraps the engine via
`EnginePlayer` (`SimpleBasePlayer`). This gives lock-screen controls,
Bluetooth/headset media buttons, and the media notification for free, mirroring
the on-screen transport.
- Persistence: preset text I/O, a full-fidelity internal project text format
(`ProjectIo`, keeps mixer/FX/instruments), AND **desktop-compatible `.sng`**
read/write (`io/SngFormat.kt`) matching the reference sizzletracker line format
(`version`/`bpm`/`sig`, `block``endblock` with `roll`/`track`/steps) — covered
by passing JVM unit tests in `app/src/test`.
- Background playback foreground service with Play/Pause/Stop notification.
- **Oboe / AAudio native output** ✓ (opt-in): `src/main/cpp/native_audio.cpp`
opens a low-latency Oboe stream whose real-time callback pulls audio via JNI
(`audio/NativeAudioBridge.kt`) from the SAME `AudioEngine.fillBlock` the
AudioTrack loop uses — so both backends produce identical sound, only the
delivery path differs. Toggle it in Settings → Audio Devices ("Low-latency
engine (Oboe)"); it falls back to AudioTrack if the native lib is absent.
Built via **NDK r27** (`ndkVersion` in `app/build.gradle.kts`) + CMake + the
`com.google.oboe:oboe:1.10.0` prefab AAR. All packaged `.so` files are **16 KB
page-aligned** (Android 15 / Google Play requirement): NDK r27 aligns ELF load
segments to 16 KB and ships a 16 KB-aligned `libc++_shared.so`, Oboe 1.10.0's
lib is aligned, our `CMakeLists.txt` also passes `-Wl,-z,max-page-size=16384`,
and AGP stores each `.so` at a 16 KB-aligned offset in the APK. (Verify with
`llvm-readelf -l <lib>.so` → LOAD `Align 0x4000`.)
### Scaffolded — remaining
1. **SF2 / XI loader instrument.** The Sampler (WAV) is done; the SoundFont/XI
loader still falls back to the synth. Parse `.sf2`/`.xi`, feed the extracted
PCM through the existing `SampleStore`/`SampleVoice` path. (This was not in the
original milestone list; noted for completeness.)
All spec milestones are implemented. Next steps are polish/hardening: routing the
Oboe backend through the chosen output device (Oboe `setDeviceId`), a full C++
port of the synth/mixer to remove JVM/GC from the native audio callback, and the
SF2/XI loader above.
---
## 10. Testing suggestions
- Pure `model/` classes (`Scale.step`, `TimeSignature`, `Pattern.resize`,
`ProjectIo` round-trip, `PresetIo` round-trip) are plain Kotlin — cover them
with JVM unit tests first; they need no device.
- For the engine, render a few blocks with a known pattern and assert the voice
triggers on the expected sample offsets.

15
gradle.properties Normal file
View File

@@ -0,0 +1,15 @@
# JVM options for the Gradle daemon. 2 GB heap is plenty for this project.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Run independent tasks in parallel and cache outputs — faster incremental builds.
org.gradle.parallel=true
org.gradle.caching=true
# AndroidX is the modern support-library namespace. Required for Compose.
android.useAndroidX=true
# Kotlin code style used by the IDE formatter.
kotlin.code.style=official
# Only generate the R (resources) class per-module that actually needs it.
android.nonTransitiveRClass=true

45
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,45 @@
# Gradle "version catalog". This single file is the ONE place where library
# versions live. Instead of scattering version numbers across build files, code
# refers to them by name, e.g. libs.androidx.core.ktx. Update a number here and
# the whole project picks it up.
[versions]
agp = "8.5.2" # Android Gradle Plugin
kotlin = "2.0.20" # Kotlin language + compiler
composeCompiler = "2.0.20" # Compose compiler is now bundled with Kotlin plugin
coreKtx = "1.13.1"
lifecycle = "2.8.6"
activityCompose = "1.9.2"
composeBom = "2024.09.03" # Bill Of Materials: pins all Compose libs together
media3 = "1.4.1" # MediaSession / background playback
datastore = "1.1.1" # Persisting settings/preferences
coroutines = "1.9.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
# Compose — versions come from the BOM below, so no version.ref here.
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
# Background playback + media notification controls
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "composeCompiler" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View File

@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

28
samples/demo.sng Normal file
View File

@@ -0,0 +1,28 @@
SIZZLETRACKER-SNG 1
NAME=demo
TEMPO=120.0
SIG=FOUR_FOUR
SCALE=MINOR
ROOT=9
ACTIVE=0
[PATTERN id=0 name=LEAD len=16]
0,0,57,128,1
0,4,60,128,1
0,8,64,110,1
0,12,60,120,1
1,0,45,90,2
1,8,45,90,2
[ARRANGEMENT len=16 loop=0]
0,0,0
0,1,0
0,2,0
0,3,0
[REGION A en=0 s=0 e=0 r=2]
[REGION B en=0 s=0 e=0 r=2]
[MIXER]
0,0,-1,-1,-1,-1,1,0.8,0,0
1,-1,-1,-1,-1,-1,2,0.8,0,0
2,-1,-1,-1,-1,-1,3,0.8,0,0
3,-1,-1,-1,-1,-1,4,0.8,0,0
[TOOLBOX]
0=NES_SYNTH;Lead;wave=Pulse25,attack=0.01,decay=0.15,sustain=0.6,release=0.1

24
settings.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
// Top-level Gradle settings: declares where plugins/dependencies are fetched from
// and which sub-projects (modules) make up this build. We keep everything in a
// single ":app" module to stay approachable for newcomers.
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
// Fail the build if a module tries to declare its own repositories — all
// dependencies must come from the two repos below. This keeps things simple.
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Sizzletracker"
include(":app")