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:
66
app/src/main/AndroidManifest.xml
Normal file
66
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- ===== Permissions =====
|
||||
RECORD_AUDIO : ad-hoc sample recorder (mic / USB audio in).
|
||||
FOREGROUND_SERVICE : keep the sequencer alive while the app is backgrounded.
|
||||
FOREGROUND_SERVICE_MEDIA_PLAYBACK : Android 14 requires a typed FGS.
|
||||
POST_NOTIFICATIONS : show the media transport notification (Android 13+).
|
||||
BLUETOOTH_CONNECT : Bluetooth MIDI + gamepad on Android 12+. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- Hardware we can *use* but do not *require* (so the app still installs on
|
||||
devices without them). Code must always feature-detect at runtime. -->
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
<uses-feature android:name="android.software.midi" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".SizzleApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="false"
|
||||
android:theme="@style/Theme.Sizzletracker">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|navigation|keyboard"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.Sizzletracker">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Announce that we can be a USB-MIDI aware app; the system routes
|
||||
attach events here so we can auto-connect controllers. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||
android:resource="@xml/usb_device_filter" />
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service that owns the audio engine so playback survives
|
||||
when the UI is not visible, and drives the media notification. -->
|
||||
<service
|
||||
android:name=".playback.PlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
20
app/src/main/cpp/CMakeLists.txt
Normal file
20
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
# Native build for the Oboe (AAudio) low-latency output backend.
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(sizzle_native)
|
||||
|
||||
# Oboe ships as a prefab package inside the com.google.oboe:oboe AAR; AGP exposes
|
||||
# it here when `buildFeatures { prefab = true }` is set in build.gradle.kts.
|
||||
find_package(oboe REQUIRED CONFIG)
|
||||
|
||||
add_library(sizzle_native SHARED native_audio.cpp)
|
||||
|
||||
target_link_libraries(sizzle_native
|
||||
oboe::oboe
|
||||
log)
|
||||
|
||||
# Align ELF load segments to 16 KB so the library works on Android 15+ devices
|
||||
# using 16 KB memory pages (and passes Google Play's requirement). NDK r27+ does
|
||||
# this by default; on r26 we set it explicitly. -Bsymbolic is unrelated; the key
|
||||
# flag is max-page-size.
|
||||
target_link_options(sizzle_native PRIVATE
|
||||
"-Wl,-z,max-page-size=16384")
|
||||
111
app/src/main/cpp/native_audio.cpp
Normal file
111
app/src/main/cpp/native_audio.cpp
Normal file
@@ -0,0 +1,111 @@
|
||||
// Native Oboe (AAudio) output backend for Sizzletracker.
|
||||
//
|
||||
// Opens a low-latency mono float Oboe output stream. On each real-time data
|
||||
// callback it calls back into Kotlin (NativeAudioBridge.renderAudio) to fill a
|
||||
// reused Java float[] from the shared AudioEngine.fillBlock, then copies that
|
||||
// into Oboe's output buffer. This keeps ONE implementation of the synth/mixer
|
||||
// (in Kotlin) while delivering audio through the lower-latency native path.
|
||||
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
#include <oboe/Oboe.h>
|
||||
#include <memory>
|
||||
|
||||
#define LOG_TAG "SizzleNative"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
namespace {
|
||||
|
||||
JavaVM *g_vm = nullptr;
|
||||
jobject g_callback = nullptr; // global ref to the NativeAudioBridge
|
||||
jmethodID g_renderMethod = nullptr;
|
||||
jfloatArray g_buffer = nullptr; // reused Java float[] of g_frames length
|
||||
int g_frames = 0;
|
||||
|
||||
// Fetch a JNIEnv for the (persistent) audio callback thread, attaching once.
|
||||
JNIEnv *callbackEnv() {
|
||||
JNIEnv *env = nullptr;
|
||||
if (g_vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) == JNI_EDETACHED) {
|
||||
g_vm->AttachCurrentThread(&env, nullptr);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
class SizzleCallback : public oboe::AudioStreamDataCallback {
|
||||
public:
|
||||
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData,
|
||||
int32_t numFrames) override {
|
||||
auto *out = static_cast<float *>(audioData);
|
||||
JNIEnv *env = callbackEnv();
|
||||
if (!env || !g_callback || !g_renderMethod || !g_buffer) {
|
||||
for (int i = 0; i < numFrames; ++i) out[i] = 0.0f;
|
||||
return oboe::DataCallbackResult::Continue;
|
||||
}
|
||||
const int n = numFrames < g_frames ? numFrames : g_frames;
|
||||
// Ask Kotlin to render n frames into the reused Java buffer ...
|
||||
env->CallVoidMethod(g_callback, g_renderMethod, g_buffer, n);
|
||||
// ... then copy them out to Oboe (and pad any remainder with silence).
|
||||
env->GetFloatArrayRegion(g_buffer, 0, n, out);
|
||||
for (int i = n; i < numFrames; ++i) out[i] = 0.0f;
|
||||
return oboe::DataCallbackResult::Continue;
|
||||
}
|
||||
};
|
||||
|
||||
SizzleCallback g_dataCallback;
|
||||
std::shared_ptr<oboe::AudioStream> g_stream;
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
|
||||
g_vm = vm;
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStart(
|
||||
JNIEnv *env, jobject /*thiz*/, jobject callback, jint sampleRate, jint framesPerCallback) {
|
||||
g_frames = framesPerCallback;
|
||||
g_callback = env->NewGlobalRef(callback);
|
||||
jclass cls = env->GetObjectClass(callback);
|
||||
g_renderMethod = env->GetMethodID(cls, "renderAudio", "([FI)V");
|
||||
g_buffer = static_cast<jfloatArray>(env->NewGlobalRef(env->NewFloatArray(framesPerCallback)));
|
||||
|
||||
oboe::AudioStreamBuilder builder;
|
||||
builder.setDirection(oboe::Direction::Output)
|
||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
||||
->setSharingMode(oboe::SharingMode::Shared)
|
||||
->setFormat(oboe::AudioFormat::Float)
|
||||
->setChannelCount(oboe::ChannelCount::Mono)
|
||||
->setSampleRate(sampleRate)
|
||||
->setFramesPerDataCallback(framesPerCallback)
|
||||
->setDataCallback(&g_dataCallback);
|
||||
|
||||
oboe::Result result = builder.openStream(g_stream);
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
|
||||
return JNI_FALSE;
|
||||
}
|
||||
result = g_stream->requestStart();
|
||||
if (result != oboe::Result::OK) {
|
||||
LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
|
||||
g_stream->close();
|
||||
g_stream.reset();
|
||||
return JNI_FALSE;
|
||||
}
|
||||
LOGI("Oboe stream started (%d Hz, %d frames/callback)", sampleRate, framesPerCallback);
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_reactorcoremeltdown_sizzletracker_audio_NativeAudioBridge_nativeStop(
|
||||
JNIEnv *env, jobject /*thiz*/) {
|
||||
if (g_stream) {
|
||||
g_stream->stop();
|
||||
g_stream->close();
|
||||
g_stream.reset();
|
||||
}
|
||||
if (g_buffer) { env->DeleteGlobalRef(g_buffer); g_buffer = nullptr; }
|
||||
if (g_callback) { env->DeleteGlobalRef(g_callback); g_callback = nullptr; }
|
||||
g_renderMethod = nullptr;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.reactorcoremeltdown.sizzletracker
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
|
||||
import com.reactorcoremeltdown.sizzletracker.input.KeyboardInput
|
||||
import com.reactorcoremeltdown.sizzletracker.input.MidiInput
|
||||
import com.reactorcoremeltdown.sizzletracker.playback.PlaybackService
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.SizzleApp as SizzleAppUi
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.SizzleTheme
|
||||
|
||||
/**
|
||||
* The single Activity. Its jobs are narrow and clear:
|
||||
* 1. build the [AppViewModel] wired to the app-wide singletons,
|
||||
* 2. host the Compose UI, themed by the ViewModel's current palette,
|
||||
* 3. forward hardware key / motion events into the keyboard & gamepad handlers,
|
||||
* 4. start MIDI listening and the background playback service.
|
||||
*
|
||||
* All four input methods converge on the same InputRouter -> ViewModel path, so
|
||||
* there is no per-input special-casing beyond the translation done here.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val app get() = application as SizzleApp
|
||||
|
||||
// Handlers are app-wide singletons (shared with the Settings rebind UI).
|
||||
private val keyboard: KeyboardInput get() = app.keyboardInput
|
||||
private val gamepad: GamepadInput get() = app.gamepadInput
|
||||
private val midi: MidiInput get() = app.midiInput
|
||||
|
||||
private val viewModel: AppViewModel by lazy {
|
||||
ViewModelProvider(
|
||||
this,
|
||||
viewModelFactory {
|
||||
initializer {
|
||||
AppViewModel(app.project, app.audioEngine, app.inputRouter, app.gamepadInput, app.midiInput)
|
||||
}
|
||||
},
|
||||
)[AppViewModel::class.java]
|
||||
}
|
||||
|
||||
private val requestPermissions = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { /* result ignored: features degrade gracefully if denied */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
askForPermissions()
|
||||
|
||||
setContent {
|
||||
// viewModel.palette is Compose state, so reading it here recomposes
|
||||
// the whole theme when the user picks a new colour scheme in Settings.
|
||||
SizzleTheme(palette = viewModel.palette) {
|
||||
SizzleAppUi(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
midi.start()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Start the playback service only once we're definitively in the
|
||||
// foreground — a plain startService() from a not-yet-foreground state
|
||||
// throws BackgroundServiceStartNotAllowedException on Android 12+.
|
||||
runCatching { PlaybackService.start(this) }
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
midi.stop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
// ---- Hardware input forwarding ----
|
||||
// Physical keyboards and most gamepad buttons arrive as key events; we give
|
||||
// the gamepad handler first refusal, then the keyboard handler.
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean =
|
||||
gamepad.onKeyDown(keyCode, event) ||
|
||||
keyboard.onKeyDown(keyCode, event) ||
|
||||
super.onKeyDown(keyCode, event)
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean =
|
||||
keyboard.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
|
||||
// Analog sticks / triggers / D-pad hats arrive as generic motion events.
|
||||
override fun onGenericMotionEvent(event: MotionEvent): Boolean =
|
||||
gamepad.onMotion(event) || super.onGenericMotionEvent(event)
|
||||
|
||||
// ---- (helper removed; viewModel.palette is read directly in setContent) ----
|
||||
|
||||
// ---- Permissions ----
|
||||
private fun askForPermissions() {
|
||||
val needed = buildList {
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
}.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (needed.isNotEmpty()) requestPermissions.launch(needed.toTypedArray())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.reactorcoremeltdown.sizzletracker
|
||||
|
||||
import android.app.Application
|
||||
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
||||
import com.reactorcoremeltdown.sizzletracker.input.GamepadInput
|
||||
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
|
||||
import com.reactorcoremeltdown.sizzletracker.input.KeyboardInput
|
||||
import com.reactorcoremeltdown.sizzletracker.input.MidiInput
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||
|
||||
/**
|
||||
* The Application object is our (very small) dependency container. Instead of a
|
||||
* DI framework — overkill for a learning-friendly codebase — the handful of
|
||||
* app-wide singletons live here and are reached via `application as SizzleApp`.
|
||||
*
|
||||
* These objects outlive any single screen so playback and input keep working
|
||||
* across rotation and tab switches.
|
||||
*/
|
||||
class SizzleApp : Application() {
|
||||
|
||||
/** The single in-memory song. Everything edits this one object. */
|
||||
val project: Project by lazy { Project() }
|
||||
|
||||
/** Neutral input bus shared by touch / keyboard / gamepad / MIDI. */
|
||||
val inputRouter: InputRouter by lazy { InputRouter() }
|
||||
|
||||
/** The real-time sound engine (started/stopped by the PlaybackService). */
|
||||
val audioEngine: AudioEngine by lazy { AudioEngine() }
|
||||
|
||||
// Input source handlers are singletons so their (re)bindable maps are shared
|
||||
// between MainActivity (which feeds them hardware events) and the Settings
|
||||
// screen (which edits their bindings via MIDI-learn / gamepad rebind).
|
||||
val keyboardInput: KeyboardInput by lazy { KeyboardInput(inputRouter) }
|
||||
val gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
|
||||
val midiInput: MidiInput by lazy { MidiInput(this, inputRouter) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
audioEngine.setProject(project)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* The real-time sound engine. It owns one [AudioTrack] and a dedicated render
|
||||
* thread that is completely separate from the UI thread — this is what keeps
|
||||
* playback smooth while the interface is being touched.
|
||||
*
|
||||
* The render loop is a classic tracker "sample-accurate sequencer": it counts
|
||||
* audio samples and, exactly when enough samples have elapsed for one tracker
|
||||
* line, it advances the playhead and triggers that line's notes. Because timing
|
||||
* is derived from the audio clock (not `Handler.postDelayed`), there is no drift.
|
||||
*
|
||||
* PLAYBACK MODES
|
||||
* --------------
|
||||
* - ARRANGEMENT mode (default when the arrangement has any blocks): the
|
||||
* sequencer walks a pre-expanded playlist of beats (with A/B loop repeats),
|
||||
* and at each beat every filled lane plays one beat of its pattern. A lane
|
||||
* keeps its own cursor so a block spanning several consecutive beats advances
|
||||
* through the pattern beat-by-beat.
|
||||
* - PATTERN-LOOP mode (when the arrangement is empty): the currently-edited
|
||||
* pattern loops, so the Tracker tab is always audible while you edit.
|
||||
*
|
||||
* Voices form a [LANE_COUNT] x [TRACK_COUNT] matrix so lanes layer polyphonically
|
||||
* while each (lane, track) stays monophonic (classic tracker channel behaviour).
|
||||
* A voice's mixer channel is its track index, so mute/solo/volume apply per track.
|
||||
*
|
||||
* INSTRUMENTS: NES synth and Sampler are rendered here; the SoundFont loader
|
||||
* still falls back to the synth. Each (lane, track) has both a [SynthVoice] and a
|
||||
* [SampleVoice]; the channel's instrument type decides which one a note triggers.
|
||||
* Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
|
||||
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
|
||||
* Kotlin/AudioTrack loop for an Oboe (C++/AAudio) engine is a later milestone.
|
||||
*/
|
||||
class AudioEngine(
|
||||
private val sampleRate: Int = 48_000,
|
||||
) {
|
||||
// Sequencer voices: one synth + one sample voice per (lane, track).
|
||||
private val seqVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SynthVoice(sampleRate) }
|
||||
private val sampleVoices = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { SampleVoice(sampleRate) }
|
||||
// ... plus a small pool for live auditioning from keyboard / MIDI / pads.
|
||||
private val liveVoices = Array(LIVE_POLYPHONY) { SynthVoice(sampleRate) }
|
||||
// Sample voices for previewing sampler instruments (toolbox keyboard / editor).
|
||||
private val auditionSampleVoices = Array(AUDITION_POLYPHONY) { SampleVoice(sampleRate) }
|
||||
// Per-(lane,track) arpeggiator state, driven by advanceArps.
|
||||
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
|
||||
|
||||
private fun seqVoice(lane: Int, track: Int) = seqVoices[lane * Pattern.TRACK_COUNT + track]
|
||||
private fun sampleVoice(lane: Int, track: Int) = sampleVoices[lane * Pattern.TRACK_COUNT + track]
|
||||
|
||||
/** One insert-effect instance bound to the toolbox slot that configures it. */
|
||||
private class FxUnit(val slot: ToolboxSlot, val effect: AudioEffect)
|
||||
|
||||
/** Per-channel effect chains (mixer FX slots resolved to real processors).
|
||||
* Rebuilt when routing changes; parameters refresh live every block. */
|
||||
private val channelChains = Array(Pattern.TRACK_COUNT) { mutableListOf<FxUnit>() }
|
||||
|
||||
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
||||
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
||||
|
||||
/** The song being played. Swapped atomically; read on the audio thread. */
|
||||
@Volatile private var project: Project? = null
|
||||
|
||||
@Volatile private var playing = false
|
||||
@Volatile private var running = false
|
||||
|
||||
// ---- Shared position state (audio-thread only) ----
|
||||
private var samplesIntoLine = 0.0
|
||||
private var pendingTrigger = true
|
||||
|
||||
// ---- Pattern-loop mode ----
|
||||
private var currentLine = 0
|
||||
|
||||
// ---- Arrangement mode ----
|
||||
private var arrangementMode = false
|
||||
/** Beat indices to play, in order, with A/B repeats already expanded. */
|
||||
private var playlist: IntArray = IntArray(0)
|
||||
private var playlistIndex = 0
|
||||
private var lineInBeat = 0
|
||||
private val laneCursor = IntArray(Arrangement.LANE_COUNT) // which beat of its pattern each lane is on
|
||||
private val lanePrevPattern = IntArray(Arrangement.LANE_COUNT) // pattern played on the previous beat (-2 = none)
|
||||
|
||||
private val _transport = MutableStateFlow(TransportState())
|
||||
val transport: StateFlow<TransportState> = _transport
|
||||
|
||||
private var renderThread: Thread? = null
|
||||
private var track: AudioTrack? = null
|
||||
|
||||
fun setProject(p: Project) {
|
||||
project = p
|
||||
rebuildArrangement()
|
||||
rebuildFxChains()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each mixer channel's four FX slots into real effect processors.
|
||||
* Call from the main thread (from [play], [setProject], or after the user
|
||||
* changes a channel's FX routing). Effect *parameters* update live every
|
||||
* block, so only add/remove of an effect needs a rebuild.
|
||||
*/
|
||||
fun rebuildFxChains() {
|
||||
val proj = project ?: return
|
||||
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||
val chain = channelChains[ch]
|
||||
chain.clear()
|
||||
for (slotIndex in proj.mixer.channels[ch].fxSlots) {
|
||||
if (slotIndex < 0) continue
|
||||
val slot = proj.toolbox.getOrNull(slotIndex) ?: continue
|
||||
val type = slot.type ?: continue
|
||||
AudioEffect.create(type, sampleRate)?.let { chain.add(FxUnit(slot, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the arrangement playlist and resets sequencer position. Call
|
||||
* from the main thread (never the audio thread) — from [play] and whenever
|
||||
* the arrangement or loop settings change.
|
||||
*/
|
||||
fun rebuildArrangement() {
|
||||
val proj = project ?: return
|
||||
val arr = proj.arrangement
|
||||
arrangementMode = arr.slots.any { lane -> lane.any { it != Arrangement.EMPTY } }
|
||||
playlist = buildPlaylist(arr)
|
||||
playlistIndex = 0
|
||||
lineInBeat = 0
|
||||
laneCursor.fill(0)
|
||||
lanePrevPattern.fill(-2)
|
||||
}
|
||||
|
||||
/** Expand the arrangement (respecting loop + A/B regions) into a flat beat list. */
|
||||
private fun buildPlaylist(arr: Arrangement): IntArray {
|
||||
if (!arr.loopEnabled) return IntArray(arr.lengthBeats) { it } // play once, front to back
|
||||
val a = arr.regionA
|
||||
val b = arr.regionB
|
||||
if (!a.isValid && !b.isValid) return IntArray(arr.lengthBeats) { it } // loop the whole song
|
||||
val out = ArrayList<Int>()
|
||||
if (a.isValid) repeat(a.repeats) { for (beat in a.start until a.end) out.add(beat) }
|
||||
if (b.isValid) repeat(b.repeats) { for (beat in b.start until b.end) out.add(beat) }
|
||||
return out.toIntArray()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lifecycle
|
||||
/** True while the low-latency native (Oboe) output backend is selected. */
|
||||
@Volatile var useNativeOutput = false; private set
|
||||
private var nativeBridge: NativeAudioBridge? = null
|
||||
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
// Prefer the native Oboe backend when enabled and its library loaded;
|
||||
// otherwise (or if it fails to open) use the Kotlin AudioTrack loop.
|
||||
if (useNativeOutput && NativeAudioBridge.isAvailable()) {
|
||||
val bridge = NativeAudioBridge(this)
|
||||
if (bridge.start(sampleRate, BLOCK_FRAMES)) { nativeBridge = bridge; return }
|
||||
}
|
||||
startAudioTrack()
|
||||
}
|
||||
|
||||
private fun startAudioTrack() {
|
||||
val minBuf = AudioTrack.getMinBufferSize(
|
||||
sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||
).coerceAtLeast(BLOCK_FRAMES * 4)
|
||||
|
||||
track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
)
|
||||
.setBufferSizeInBytes(minBuf)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
.also { it.play() }
|
||||
|
||||
applyPreferredOutput() // route to the user-chosen USB/audio device, if any
|
||||
|
||||
renderThread = thread(name = "sizzle-audio", priority = Thread.MAX_PRIORITY) {
|
||||
audioTrackLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch output backend between Oboe (native) and AudioTrack, restarting. */
|
||||
fun setNativeOutput(enabled: Boolean) {
|
||||
if (useNativeOutput == enabled) return
|
||||
val wasRunning = running
|
||||
if (wasRunning) release()
|
||||
useNativeOutput = enabled
|
||||
if (wasRunning) start()
|
||||
}
|
||||
|
||||
// ---- USB / audio output device routing ----
|
||||
@Volatile private var preferredOutput: android.media.AudioDeviceInfo? = null
|
||||
|
||||
/** Route playback to a specific output device (e.g. a USB DAC), or null for
|
||||
* the system default. Applied immediately and re-applied on engine restart. */
|
||||
fun setPreferredOutput(device: android.media.AudioDeviceInfo?) {
|
||||
preferredOutput = device
|
||||
applyPreferredOutput()
|
||||
}
|
||||
|
||||
private fun applyPreferredOutput() {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||
track?.setPreferredDevice(preferredOutput)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the engine entirely (called from the PlaybackService on shutdown). */
|
||||
fun release() {
|
||||
running = false
|
||||
nativeBridge?.stop()
|
||||
nativeBridge = null
|
||||
renderThread?.join(500)
|
||||
renderThread = null
|
||||
track?.run { stop(); release() }
|
||||
track = null
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- transport
|
||||
fun play() {
|
||||
rebuildArrangement()
|
||||
rebuildFxChains()
|
||||
pendingTrigger = true
|
||||
playing = true
|
||||
publish()
|
||||
}
|
||||
|
||||
fun pause() { playing = false; allSeqVoicesOff(); publish() }
|
||||
|
||||
/** Stop playback. If already stopped, rewind to the very beginning. */
|
||||
fun stop() {
|
||||
if (!playing && currentLine == 0 && playlistIndex == 0 && lineInBeat == 0) return
|
||||
val wasStopped = !playing
|
||||
playing = false
|
||||
allSeqVoicesOff()
|
||||
if (wasStopped) {
|
||||
currentLine = 0; samplesIntoLine = 0.0
|
||||
playlistIndex = 0; lineInBeat = 0
|
||||
laneCursor.fill(0); lanePrevPattern.fill(-2)
|
||||
}
|
||||
publish()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ live note audition
|
||||
fun liveNoteOn(pitch: Int, velocity: Int) {
|
||||
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
|
||||
v.noteOn(pitch, velocity, SynthVoice.Wave.PULSE_50, 0.005, 0.1, 0.7, 0.15)
|
||||
}
|
||||
|
||||
fun liveNoteOff(pitch: Int) {
|
||||
liveVoices.firstOrNull { it.isActive && it.pitch == pitch }?.noteOff()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- render loop
|
||||
/** The Kotlin AudioTrack fallback loop: fill a block and push it. */
|
||||
private fun audioTrackLoop() {
|
||||
val block = FloatArray(BLOCK_FRAMES)
|
||||
while (running) {
|
||||
val t = track ?: break
|
||||
fillBlock(block, BLOCK_FRAMES)
|
||||
t.write(block, 0, BLOCK_FRAMES, AudioTrack.WRITE_BLOCKING)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render [frames] mono samples of the whole mix into [out]. This is the single
|
||||
* source of truth for audio: it is called by the Kotlin AudioTrack loop AND by
|
||||
* the native Oboe callback (via [NativeAudioBridge]), so both output backends
|
||||
* produce identical sound. Runs on an audio thread — must not allocate.
|
||||
*/
|
||||
fun fillBlock(out: FloatArray, frames: Int) {
|
||||
val proj = project
|
||||
// Per-block param housekeeping: LFOs modulate targets first, then effects
|
||||
// read their (possibly modulated) parameters.
|
||||
if (proj != null) {
|
||||
applyLfos(proj)
|
||||
updateFxParams(proj)
|
||||
}
|
||||
val anySolo = proj?.mixer?.anySolo() ?: false
|
||||
|
||||
for (i in 0 until frames) {
|
||||
if (playing && proj != null) advanceSequencer(proj)
|
||||
|
||||
var mix = 0f
|
||||
if (proj != null) {
|
||||
channelSum.fill(0f)
|
||||
for (vi in seqVoices.indices) {
|
||||
channelSum[vi % Pattern.TRACK_COUNT] += seqVoices[vi].render() + sampleVoices[vi].render()
|
||||
}
|
||||
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||
val mc = proj.mixer.channels[ch]
|
||||
var s = if (mc.audible(anySolo)) channelSum[ch] * mc.volume else 0f
|
||||
val chain = channelChains[ch]
|
||||
for (u in chain) s = u.effect.process(s)
|
||||
mix += s
|
||||
}
|
||||
}
|
||||
for (lv in liveVoices) mix += lv.render() * LIVE_GAIN
|
||||
for (av in auditionSampleVoices) mix += av.render() * LIVE_GAIN
|
||||
out[i] = softClip(mix * MASTER_GAIN)
|
||||
}
|
||||
}
|
||||
|
||||
/** Push current slot parameters (+ tempo) into every live effect processor. */
|
||||
private fun updateFxParams(proj: Project) {
|
||||
val tempo = proj.tempoBpm
|
||||
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||
val chain = channelChains[ch]
|
||||
for (u in chain) u.effect.update(u.slot, tempo)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MIDI LFO modulation ----
|
||||
private val lfoPhase = DoubleArray(Project.TOOLBOX_SLOTS)
|
||||
private val lfoSampleHold = DoubleArray(Project.TOOLBOX_SLOTS) { Math.random() * 2 - 1 }
|
||||
|
||||
/**
|
||||
* Apply every LFO slot's modulation to its target parameter, once per block.
|
||||
* The target is stored as "slotIndex:paramKey"; the LFO oscillates the value
|
||||
* around a captured centre by ±depth·(half the param's range). The LFO thus
|
||||
* "takes over" its target parameter while active.
|
||||
*/
|
||||
private fun applyLfos(proj: Project) {
|
||||
val secondsPerBeat = 60.0 / proj.tempoBpm
|
||||
val blockSeconds = BLOCK_FRAMES.toDouble() / sampleRate
|
||||
for (i in proj.toolbox.indices) {
|
||||
val lfo = proj.toolbox[i]
|
||||
if (lfo.type != ToolboxType.LFO) continue
|
||||
val target = lfo.string("target")
|
||||
val sep = target.indexOf(':')
|
||||
if (sep <= 0) continue
|
||||
val tIdx = target.substring(0, sep).toIntOrNull() ?: continue
|
||||
if (tIdx == i) continue
|
||||
val key = target.substring(sep + 1)
|
||||
val tSlot = proj.toolbox.getOrNull(tIdx) ?: continue
|
||||
val spec = tSlot.type?.params?.firstOrNull { it.key == key } ?: continue
|
||||
|
||||
val div = TimeDivision.fromIndex(lfo.float("division", 3f).toInt())
|
||||
val freq = 1.0 / (div.beatFraction * secondsPerBeat).coerceAtLeast(1e-6)
|
||||
val advanced = lfoPhase[i] + freq * blockSeconds
|
||||
if (advanced >= 1.0) lfoSampleHold[i] = Math.random() * 2 - 1
|
||||
lfoPhase[i] = advanced % 1.0
|
||||
val phase = (lfoPhase[i] + lfo.float("phase", 0f)) % 1.0
|
||||
|
||||
val value = when (lfo.string("wave", "Sine")) {
|
||||
"Triangle" -> 1.0 - 4.0 * abs(phase - 0.5)
|
||||
"Saw" -> 2.0 * phase - 1.0
|
||||
"Square" -> if (phase < 0.5) 1.0 else -1.0
|
||||
"SampleHold" -> lfoSampleHold[i]
|
||||
else -> sin(2 * Math.PI * phase)
|
||||
}
|
||||
val center = lfo.float("center", spec.default).toDouble()
|
||||
val half = (spec.max - spec.min) / 2.0
|
||||
val modulated = (center + lfo.float("depth", 0.5f) * value * half)
|
||||
.coerceIn(spec.min.toDouble(), spec.max.toDouble())
|
||||
tSlot.set(key, modulated.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch one sample of sequencer time to the active playback mode. */
|
||||
private fun advanceSequencer(proj: Project) {
|
||||
if (arrangementMode) advanceArrangement(proj) else advancePatternLoop(proj)
|
||||
advanceArps(proj) // arpeggiators retrigger between note events
|
||||
}
|
||||
|
||||
// ----- Pattern-loop mode (Tracker tab audition) -----
|
||||
private fun advancePatternLoop(proj: Project) {
|
||||
val pattern = proj.activePattern()
|
||||
val samplesPerLine = samplesPerLine(proj)
|
||||
|
||||
if (pendingTrigger) {
|
||||
triggerPatternLine(proj, pattern, currentLine)
|
||||
pendingTrigger = false
|
||||
}
|
||||
samplesIntoLine += 1.0
|
||||
if (samplesIntoLine >= samplesPerLine) {
|
||||
samplesIntoLine -= samplesPerLine
|
||||
currentLine++
|
||||
if (currentLine >= pattern.length) currentLine = 0
|
||||
pendingTrigger = true
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
|
||||
// Pattern-loop plays on lane 0's voices (tracks 0..3 == channels 0..3).
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
triggerVoice(proj, lane = 0, track = t, cell = pattern.cell(t, line))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a cell on (lane, track), applying the channel's MIDI effects:
|
||||
* Transposer shifts the pitch, Arpeggiator (if present) captures the note and
|
||||
* hands ongoing retriggering to [advanceArps] instead of sounding it once.
|
||||
*/
|
||||
private fun triggerVoice(proj: Project, lane: Int, track: Int, cell: com.reactorcoremeltdown.sizzletracker.model.Cell) {
|
||||
val arp = arps[lane * Pattern.TRACK_COUNT + track]
|
||||
when {
|
||||
cell.isPlayable -> {
|
||||
val velocity = cell.velocity.coerceIn(0, 127)
|
||||
val note = (cell.note + channelTranspose(proj, track)).coerceIn(0, 127)
|
||||
val arpSlot = channelArpSlot(proj, track)
|
||||
if (arpSlot != null) {
|
||||
val octaves = arpSlot.float("octaves", 1f).toInt()
|
||||
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
|
||||
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||
arp.start(note, velocity, octaves, arpSlot.string("mode", "Up"), stepSamples)
|
||||
playNote(proj, lane, track, arp.currentNote(), velocity)
|
||||
} else {
|
||||
arp.stop()
|
||||
playNote(proj, lane, track, note, velocity)
|
||||
}
|
||||
}
|
||||
cell.isNoteOff -> {
|
||||
arp.stop()
|
||||
seqVoice(lane, track).noteOff(); sampleVoice(lane, track).noteOff()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually sound a note on (lane, track): a [SampleVoice] when the channel's
|
||||
* instrument is a Sampler with a loaded sample, otherwise the NES synth. The
|
||||
* unused voice of the pair is killed so they never double up. Shared by the
|
||||
* sequencer and the arpeggiator.
|
||||
*/
|
||||
private fun playNote(proj: Project, lane: Int, track: Int, note: Int, velocity: Int) {
|
||||
val synth = seqVoice(lane, track)
|
||||
val samp = sampleVoice(lane, track)
|
||||
val slot = proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot)
|
||||
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
|
||||
if (sample != null && slot != null) {
|
||||
synth.kill()
|
||||
val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
|
||||
samp.noteOn(
|
||||
sample, note, velocity, root,
|
||||
slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f),
|
||||
)
|
||||
} else {
|
||||
samp.kill()
|
||||
val (wave, a, d, s, r) = synthParamsForTrack(proj, track)
|
||||
synth.noteOn(note, velocity, wave, a, d, s, r)
|
||||
}
|
||||
}
|
||||
|
||||
/** The channel's Arpeggiator slot (first one found in its FX slots), or null. */
|
||||
private fun channelArpSlot(proj: Project, track: Int): ToolboxSlot? {
|
||||
for (idx in proj.mixer.channels[track].fxSlots) {
|
||||
if (idx < 0) continue
|
||||
val slot = proj.toolbox.getOrNull(idx) ?: continue
|
||||
if (slot.type == ToolboxType.ARPEGGIATOR) return slot
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Advance every active arpeggiator by one sample; retrigger on step boundaries. */
|
||||
private fun advanceArps(proj: Project) {
|
||||
for (i in arps.indices) {
|
||||
val a = arps[i]
|
||||
if (!a.active) continue
|
||||
a.samplesLeft -= 1.0
|
||||
if (a.samplesLeft <= 0.0) {
|
||||
a.advance()
|
||||
playNote(proj, i / Pattern.TRACK_COUNT, i % Pattern.TRACK_COUNT, a.currentNote(), a.velocity)
|
||||
a.samplesLeft += a.stepSamples
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */
|
||||
private class ChannelArp {
|
||||
var active = false; var base = -1; var velocity = 100
|
||||
private var octaves = 1; private var mode = 0
|
||||
var stepSamples = 0.0; var samplesLeft = 0.0
|
||||
private var step = 0; private var dir = 1
|
||||
|
||||
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double) {
|
||||
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
|
||||
mode = when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
|
||||
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
|
||||
}
|
||||
|
||||
fun stop() { active = false; base = -1 }
|
||||
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
|
||||
|
||||
fun advance() {
|
||||
when (mode) {
|
||||
1 -> step = (step - 1 + octaves) % octaves
|
||||
2 -> if (octaves > 1) {
|
||||
step += dir
|
||||
if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
|
||||
else if (step <= 0) { step = 0; dir = 1 }
|
||||
}
|
||||
3 -> step = if (octaves > 1) (Math.random() * octaves).toInt() else 0
|
||||
else -> step = (step + 1) % octaves
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
|
||||
private fun channelTranspose(proj: Project, track: Int): Int {
|
||||
var semis = 0
|
||||
for (idx in proj.mixer.channels[track].fxSlots) {
|
||||
if (idx < 0) continue
|
||||
val slot = proj.toolbox.getOrNull(idx) ?: continue
|
||||
if (slot.type == ToolboxType.TRANSPOSER) semis += slot.float("semitones", 0f).toInt()
|
||||
}
|
||||
return semis
|
||||
}
|
||||
|
||||
/** Start auditioning a note on the instrument in toolbox [slotIndex] (used by
|
||||
* the Toolbox test keyboard and the Sampler editor). Polyphonic. */
|
||||
fun auditionSlotOn(slotIndex: Int, midi: Int) {
|
||||
val proj = project ?: return
|
||||
val slot = proj.toolbox.getOrNull(slotIndex)
|
||||
val sample = if (slot?.type == ToolboxType.SAMPLER) SampleStore.get(slot.string("samplePath")) else null
|
||||
if (sample != null && slot != null) {
|
||||
val v = auditionSampleVoices.firstOrNull { !it.isActive } ?: auditionSampleVoices[0].also { it.kill() }
|
||||
val root = ((slot.float("startOctave", 3f).toInt() + 1) * 12).coerceIn(0, 127)
|
||||
v.noteOn(sample, midi, 110, root, slot.float("sliceStart", 0f), slot.float("sliceEnd", 1f), slot.float("volume", 0.8f))
|
||||
} else {
|
||||
val (wave, a, d, s, r) = synthParamsForSlot(slot)
|
||||
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
|
||||
v.noteOn(midi, 110, wave, a, d, s, r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Release an auditioned note (matches both synth and sample audition pools). */
|
||||
fun auditionSlotOff(midi: Int) {
|
||||
liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
||||
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
||||
}
|
||||
|
||||
// ----- Arrangement mode -----
|
||||
private fun advanceArrangement(proj: Project) {
|
||||
val arr = proj.arrangement
|
||||
if (playlist.isEmpty()) { playing = false; publish(); return }
|
||||
val linesPerBeat = proj.timeSignature.linesPerBeat
|
||||
val samplesPerLine = samplesPerLine(proj)
|
||||
val beat = playlist[playlistIndex.coerceIn(0, playlist.size - 1)]
|
||||
|
||||
if (pendingTrigger) {
|
||||
if (lineInBeat == 0) advanceLaneCursors(proj, arr, beat, linesPerBeat)
|
||||
triggerArrangementLine(proj, arr, beat, lineInBeat, linesPerBeat)
|
||||
pendingTrigger = false
|
||||
}
|
||||
|
||||
samplesIntoLine += 1.0
|
||||
if (samplesIntoLine >= samplesPerLine) {
|
||||
samplesIntoLine -= samplesPerLine
|
||||
lineInBeat++
|
||||
if (lineInBeat >= linesPerBeat) {
|
||||
lineInBeat = 0
|
||||
playlistIndex++
|
||||
if (playlistIndex >= playlist.size) {
|
||||
if (arr.loopEnabled) {
|
||||
playlistIndex = 0
|
||||
lanePrevPattern.fill(-2) // fresh cycle: blocks restart from beat 0
|
||||
} else {
|
||||
playing = false; allSeqVoicesOff(); publish(); return
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingTrigger = true
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
/** At each new beat, update every lane's within-pattern cursor. */
|
||||
private fun advanceLaneCursors(proj: Project, arr: Arrangement, beat: Int, linesPerBeat: Int) {
|
||||
for (lane in 0 until Arrangement.LANE_COUNT) {
|
||||
val p = arr.patternAt(lane, beat)
|
||||
if (p == Arrangement.EMPTY) {
|
||||
lanePrevPattern[lane] = Arrangement.EMPTY
|
||||
continue
|
||||
}
|
||||
if (p == lanePrevPattern[lane]) {
|
||||
val beats = ((proj.pattern(p)?.length ?: linesPerBeat) / linesPerBeat).coerceAtLeast(1)
|
||||
laneCursor[lane] = (laneCursor[lane] + 1) % beats
|
||||
} else {
|
||||
laneCursor[lane] = 0
|
||||
}
|
||||
lanePrevPattern[lane] = p
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerArrangementLine(
|
||||
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
|
||||
) {
|
||||
for (lane in 0 until Arrangement.LANE_COUNT) {
|
||||
val p = arr.patternAt(lane, beat)
|
||||
if (p == Arrangement.EMPTY) continue
|
||||
val pat = proj.pattern(p) ?: continue
|
||||
val patLine = laneCursor[lane] * linesPerBeat + lineInBeat
|
||||
if (patLine >= pat.length) continue
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
triggerVoice(proj, lane, t, pat.cell(t, patLine))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the NES-synth settings for a track from its mixer->toolbox routing. */
|
||||
private fun synthParamsForTrack(proj: Project, track: Int): SynthParams =
|
||||
synthParamsForSlot(proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot))
|
||||
|
||||
/** Read a slot's NES-synth settings (default synth if it isn't an NES synth). */
|
||||
private fun synthParamsForSlot(slot: ToolboxSlot?): SynthParams {
|
||||
if (slot == null || slot.type != ToolboxType.NES_SYNTH) return DEFAULT_SYNTH
|
||||
val wave = when (slot.string("wave", "Pulse50")) {
|
||||
"Pulse12" -> SynthVoice.Wave.PULSE_12
|
||||
"Pulse25" -> SynthVoice.Wave.PULSE_25
|
||||
"Triangle" -> SynthVoice.Wave.TRIANGLE
|
||||
"Noise" -> SynthVoice.Wave.NOISE
|
||||
else -> SynthVoice.Wave.PULSE_50
|
||||
}
|
||||
return SynthParams(
|
||||
wave,
|
||||
slot.float("attack", 0.01f).toDouble(),
|
||||
slot.float("decay", 0.15f).toDouble(),
|
||||
slot.float("sustain", 0.6f).toDouble(),
|
||||
slot.float("release", 0.1f).toDouble(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun samplesPerLine(proj: Project): Double =
|
||||
sampleRate * (60.0 / proj.tempoBpm) / proj.timeSignature.linesPerBeat
|
||||
|
||||
private fun allSeqVoicesOff() {
|
||||
seqVoices.forEach { it.noteOff() }
|
||||
sampleVoices.forEach { it.noteOff() }
|
||||
arps.forEach { it.stop() }
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val proj = project
|
||||
val linesPerBeat = proj?.timeSignature?.linesPerBeat ?: 4
|
||||
if (arrangementMode) {
|
||||
val beat = if (playlist.isNotEmpty()) playlist[playlistIndex.coerceIn(0, playlist.size - 1)] else 0
|
||||
_transport.value = TransportState(
|
||||
isPlaying = playing,
|
||||
currentLine = laneCursor[0] * linesPerBeat + lineInBeat,
|
||||
currentBeat = beat,
|
||||
)
|
||||
} else {
|
||||
_transport.value = TransportState(
|
||||
isPlaying = playing,
|
||||
currentLine = currentLine,
|
||||
currentBeat = currentLine / linesPerBeat,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small destructurable holder for a voice's synth settings. */
|
||||
private data class SynthParams(
|
||||
val wave: SynthVoice.Wave, val a: Double, val d: Double, val s: Double, val r: Double,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz
|
||||
private const val LIVE_POLYPHONY = 8
|
||||
private const val AUDITION_POLYPHONY = 6
|
||||
private const val MASTER_GAIN = 0.35f
|
||||
private const val LIVE_GAIN = 0.5f
|
||||
private val DEFAULT_SYNTH = SynthParams(SynthVoice.Wave.PULSE_50, 0.01, 0.15, 0.6, 0.1)
|
||||
|
||||
/** Cheap soft clipper to keep the mix from harsh digital clipping. */
|
||||
private fun softClip(x: Float): Float = when {
|
||||
x > 1f -> 1f - 1f / (x + 1f)
|
||||
x < -1f -> -1f + 1f / (-x + 1f)
|
||||
else -> x
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeDivision
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
/**
|
||||
* Real-time audio effect processors used by the mixer's per-channel insert
|
||||
* chains. Each effect reads its settings from the owning [ToolboxSlot] once per
|
||||
* audio block (via [update]) and then processes the signal one sample at a time
|
||||
* (via [process]) on the audio thread — so [process] must never allocate.
|
||||
*
|
||||
* The MIDI effects (arpeggiator / transposer / LFO) are NOT here: they alter note
|
||||
* generation rather than the audio signal, and hook into the sequencer instead.
|
||||
* Only the signal-processing effect types return a processor from [create].
|
||||
*/
|
||||
interface AudioEffect {
|
||||
/** Refresh cached parameters from the slot. Called once per block. */
|
||||
fun update(slot: ToolboxSlot, tempoBpm: Float)
|
||||
/** Process one mono sample. */
|
||||
fun process(x: Float): Float
|
||||
|
||||
companion object {
|
||||
fun create(type: ToolboxType, sampleRate: Int): AudioEffect? = when (type) {
|
||||
ToolboxType.DELAY -> TapeDelay(sampleRate)
|
||||
ToolboxType.REVERB -> Reverb(sampleRate)
|
||||
ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
|
||||
ToolboxType.BITCRUSHER -> Bitcrusher()
|
||||
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
||||
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tape delay: a single delay line with feedback, tempo-synced time and dry/wet.
|
||||
// (The "tape heads" count is modelled but rendered as one tap here.)
|
||||
// ---------------------------------------------------------------------------
|
||||
private class TapeDelay(private val sampleRate: Int) : AudioEffect {
|
||||
private val buffer = FloatArray(sampleRate * 2) // up to 2 s of delay
|
||||
private var writeIndex = 0
|
||||
private var delaySamples = sampleRate / 2
|
||||
private var feedback = 0.4f
|
||||
private var dryWet = 0.35f
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
val div = TimeDivision.fromIndex(slot.float("division", 3f).toInt())
|
||||
val seconds = div.beatFraction * (60.0 / tempoBpm)
|
||||
delaySamples = (seconds * sampleRate).toInt().coerceIn(1, buffer.size - 1)
|
||||
feedback = slot.float("feedback", 0.4f).coerceIn(0f, 0.95f)
|
||||
dryWet = slot.float("drywet", 0.35f).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
val readIndex = (writeIndex - delaySamples + buffer.size) % buffer.size
|
||||
val delayed = buffer[readIndex]
|
||||
buffer[writeIndex] = x + delayed * feedback
|
||||
writeIndex = (writeIndex + 1) % buffer.size
|
||||
return x * (1f - dryWet) + delayed * dryWet
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RBJ biquad filter with selectable LP / HP / BP response.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class BiquadFilterEffect(private val sampleRate: Int) : AudioEffect {
|
||||
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
|
||||
private var a1 = 0.0; private var a2 = 0.0
|
||||
private var z1 = 0.0; private var z2 = 0.0 // transposed direct form II state
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
val type = slot.string("type", "LPF")
|
||||
// Map 0..1 to ~20 Hz .. ~18 kHz logarithmically.
|
||||
val cutoff = slot.float("cutoff", 0.7f).coerceIn(0f, 1f)
|
||||
val freq = 20.0 * 900.0.pow(cutoff.toDouble())
|
||||
val q = (0.5 + slot.float("resonance", 0.2f) * 8f).toDouble()
|
||||
computeCoefficients(type, freq.coerceIn(20.0, sampleRate * 0.45), q)
|
||||
}
|
||||
|
||||
private fun computeCoefficients(type: String, freq: Double, q: Double) {
|
||||
val w0 = 2.0 * PI * freq / sampleRate
|
||||
val cosW = cos(w0)
|
||||
val alpha = sin(w0) / (2.0 * q)
|
||||
val a0: Double
|
||||
when (type) {
|
||||
"HPF" -> {
|
||||
b0 = (1 + cosW) / 2; b1 = -(1 + cosW); b2 = (1 + cosW) / 2
|
||||
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||
}
|
||||
"BPF" -> {
|
||||
b0 = alpha; b1 = 0.0; b2 = -alpha
|
||||
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||
}
|
||||
else -> { // LPF
|
||||
b0 = (1 - cosW) / 2; b1 = 1 - cosW; b2 = (1 - cosW) / 2
|
||||
a0 = 1 + alpha; a1 = -2 * cosW; a2 = 1 - alpha
|
||||
}
|
||||
}
|
||||
// Normalise by a0.
|
||||
b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
val input = x.toDouble()
|
||||
val out = b0 * input + z1
|
||||
z1 = b1 * input - a1 * out + z2
|
||||
z2 = b2 * input - a2 * out
|
||||
return out.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bitcrusher: bit-depth reduction + sample-rate reduction + drive.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class Bitcrusher : AudioEffect {
|
||||
private var levels = 256f
|
||||
private var downsample = 1
|
||||
private var drive = 1f
|
||||
private var counter = 0
|
||||
private var held = 0f
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
val bits = slot.float("bits", 8f).coerceIn(1f, 16f)
|
||||
levels = 2f.pow(bits)
|
||||
downsample = slot.float("downsample", 1f).toInt().coerceAtLeast(1)
|
||||
drive = 1f + slot.float("drive", 0.2f) * 4f
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
if (counter <= 0) {
|
||||
counter = downsample
|
||||
// Quantise to the reduced bit depth after applying drive.
|
||||
val driven = tanh(x * drive)
|
||||
held = (driven * levels).roundToInt() / levels
|
||||
}
|
||||
counter--
|
||||
return held
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schroeder/Freeverb-style mono reverb: parallel combs -> series allpasses.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class Reverb(sampleRate: Int) : AudioEffect {
|
||||
private val scale = sampleRate / 44100.0
|
||||
private val combs = intArrayOf(1557, 1617, 1491, 1422).map { Comb((it * scale).toInt()) }
|
||||
private val allpasses = intArrayOf(225, 556).map { Allpass((it * scale).toInt()) }
|
||||
private var amount = 0.3f
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
amount = slot.float("amount", 0.3f).coerceIn(0f, 1f)
|
||||
val damp = slot.float("tone", 0.5f).coerceIn(0f, 1f)
|
||||
combs.forEach { it.damp = damp; it.feedback = 0.84f }
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
var y = 0f
|
||||
for (c in combs) y += c.process(x)
|
||||
y /= combs.size
|
||||
for (a in allpasses) y = a.process(y)
|
||||
return x * (1f - amount) + y * amount
|
||||
}
|
||||
|
||||
private class Comb(size: Int) {
|
||||
private val buf = FloatArray(size.coerceAtLeast(1))
|
||||
private var idx = 0
|
||||
private var last = 0f
|
||||
var feedback = 0.84f
|
||||
var damp = 0.5f
|
||||
fun process(x: Float): Float {
|
||||
val out = buf[idx]
|
||||
last = out * (1f - damp) + last * damp
|
||||
buf[idx] = x + last * feedback
|
||||
idx = (idx + 1) % buf.size
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
private class Allpass(size: Int) {
|
||||
private val buf = FloatArray(size.coerceAtLeast(1))
|
||||
private var idx = 0
|
||||
private val g = 0.5f
|
||||
fun process(x: Float): Float {
|
||||
val bufOut = buf[idx]
|
||||
val out = -x + bufOut
|
||||
buf[idx] = x + bufOut * g
|
||||
idx = (idx + 1) % buf.size
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10-band graphic EQ: ten peaking biquads at octave-spaced centre frequencies.
|
||||
// ---------------------------------------------------------------------------
|
||||
private class GraphicEq(private val sampleRate: Int) : AudioEffect {
|
||||
private val centers = floatArrayOf(31f, 63f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
|
||||
private val bands = Array(10) { PeakBiquad() }
|
||||
|
||||
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||
for (i in bands.indices) {
|
||||
val gainDb = slot.float("band$i", 0f).coerceIn(-1f, 1f) * 12f // -12..+12 dB
|
||||
bands[i].set(sampleRate, centers[i], gainDb)
|
||||
}
|
||||
}
|
||||
|
||||
override fun process(x: Float): Float {
|
||||
var y = x
|
||||
for (b in bands) y = b.process(y)
|
||||
return y
|
||||
}
|
||||
|
||||
private class PeakBiquad {
|
||||
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
|
||||
private var a1 = 0.0; private var a2 = 0.0
|
||||
private var z1 = 0.0; private var z2 = 0.0
|
||||
|
||||
fun set(sampleRate: Int, freq: Float, gainDb: Float) {
|
||||
if (freq >= sampleRate * 0.45f) return
|
||||
val a = 10.0.pow(gainDb / 40.0)
|
||||
val w0 = 2.0 * PI * freq / sampleRate
|
||||
val cosW = cos(w0)
|
||||
val alpha = sin(w0) / (2.0 * 1.0) // Q ~ 1 per band
|
||||
val a0 = 1 + alpha / a
|
||||
b0 = (1 + alpha * a) / a0
|
||||
b1 = (-2 * cosW) / a0
|
||||
b2 = (1 - alpha * a) / a0
|
||||
a1 = (-2 * cosW) / a0
|
||||
a2 = (1 - alpha / a) / a0
|
||||
}
|
||||
|
||||
fun process(x: Float): Float {
|
||||
val input = x.toDouble()
|
||||
val out = b0 * input + z1
|
||||
z1 = b1 * input - a1 * out + z2
|
||||
z2 = b2 * input - a2 * out
|
||||
return out.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
/**
|
||||
* Bridges the native Oboe (AAudio) output backend to the Kotlin [AudioEngine].
|
||||
*
|
||||
* The C++ side (see `src/main/cpp/native_audio.cpp`) opens a low-latency Oboe
|
||||
* stream whose real-time data callback calls [renderAudio] via JNI, which simply
|
||||
* delegates to [AudioEngine.fillBlock]. So the exact same synth/sequencer/mixer
|
||||
* code produces the audio; only the *delivery* path changes (a native callback
|
||||
* pulling data, vs. AudioTrack blocking writes), which is what lowers latency.
|
||||
*
|
||||
* If the native library fails to load (e.g. the NDK build was skipped) the whole
|
||||
* feature is inert and the engine transparently uses its AudioTrack loop.
|
||||
*/
|
||||
class NativeAudioBridge(private val engine: AudioEngine) {
|
||||
|
||||
/** Invoked from the native audio callback thread. Must not allocate. */
|
||||
@Suppress("unused") // called via JNI
|
||||
fun renderAudio(buffer: FloatArray, frames: Int) {
|
||||
engine.fillBlock(buffer, frames)
|
||||
}
|
||||
|
||||
fun start(sampleRate: Int, framesPerCallback: Int): Boolean =
|
||||
if (LIB_LOADED) nativeStart(this, sampleRate, framesPerCallback) else false
|
||||
|
||||
fun stop() {
|
||||
if (LIB_LOADED) nativeStop()
|
||||
}
|
||||
|
||||
private external fun nativeStart(callback: NativeAudioBridge, sampleRate: Int, framesPerCallback: Int): Boolean
|
||||
private external fun nativeStop()
|
||||
|
||||
companion object {
|
||||
private val LIB_LOADED = runCatching { System.loadLibrary("sizzle_native") }.isSuccess
|
||||
|
||||
/** Whether the native Oboe backend is available on this build/device. */
|
||||
fun isAvailable(): Boolean = LIB_LOADED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Ad-hoc sample recorder. Captures audio from the microphone (or a connected USB
|
||||
* audio input, which the platform routes to the default source) into a mono float
|
||||
* buffer that becomes a [SampleStore.Sample].
|
||||
*
|
||||
* Requires the RECORD_AUDIO permission (requested at app launch). Recording runs
|
||||
* on its own thread so it never blocks the UI or the playback engine.
|
||||
*/
|
||||
class SampleRecorder(private val sampleRate: Int = 44_100) {
|
||||
|
||||
@Volatile private var recording = false
|
||||
private var worker: Thread? = null
|
||||
private val chunks = ArrayList<FloatArray>()
|
||||
|
||||
/** Preferred capture device (e.g. a USB audio input), or null for the default. */
|
||||
var preferredInput: android.media.AudioDeviceInfo? = null
|
||||
|
||||
val isRecording: Boolean get() = recording
|
||||
|
||||
@SuppressLint("MissingPermission") // caller ensures RECORD_AUDIO is granted
|
||||
fun start(): Boolean {
|
||||
if (recording) return true
|
||||
val minBuf = AudioRecord.getMinBufferSize(
|
||||
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||
)
|
||||
if (minBuf <= 0) return false
|
||||
val record = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,
|
||||
minBuf * 2,
|
||||
)
|
||||
if (record.state != AudioRecord.STATE_INITIALIZED) { record.release(); return false }
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||
record.setPreferredDevice(preferredInput)
|
||||
}
|
||||
|
||||
chunks.clear()
|
||||
recording = true
|
||||
record.startRecording()
|
||||
worker = thread(name = "sizzle-rec") {
|
||||
val buf = FloatArray(minBuf)
|
||||
while (recording) {
|
||||
val n = record.read(buf, 0, buf.size, AudioRecord.READ_BLOCKING)
|
||||
if (n > 0) chunks.add(buf.copyOf(n))
|
||||
// Safety cap: stop after ~30 s to avoid unbounded memory.
|
||||
if (chunks.sumOf { it.size } > sampleRate * 30) recording = false
|
||||
}
|
||||
record.stop()
|
||||
record.release()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop and return the recorded audio (null if nothing was captured). */
|
||||
fun stop(): SampleStore.Sample? {
|
||||
if (!recording && worker == null) return null
|
||||
recording = false
|
||||
worker?.join(1000)
|
||||
worker = null
|
||||
val total = chunks.sumOf { it.size }
|
||||
if (total == 0) return null
|
||||
val out = FloatArray(total)
|
||||
var pos = 0
|
||||
for (c in chunks) { c.copyInto(out, pos); pos += c.size }
|
||||
chunks.clear()
|
||||
return SampleStore.Sample(out, sampleRate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* A process-wide cache of decoded audio samples used by the Sampler instrument.
|
||||
*
|
||||
* Decoding (reading a file, parsing WAV) happens on a normal thread in the UI
|
||||
* layer, which then [put]s the finished [Sample] here under a string id. The
|
||||
* audio thread only ever [get]s already-decoded samples — it never touches the
|
||||
* filesystem — so real-time playback stays allocation- and IO-free.
|
||||
*
|
||||
* The id stored in a toolbox slot's `samplePath` parameter is the key here.
|
||||
*/
|
||||
object SampleStore {
|
||||
/** Decoded mono PCM plus the rate it was recorded at. */
|
||||
class Sample(val data: FloatArray, val sampleRate: Int)
|
||||
|
||||
private val samples = ConcurrentHashMap<String, Sample>()
|
||||
|
||||
fun put(id: String, sample: Sample) { samples[id] = sample }
|
||||
fun get(id: String): Sample? = if (id.isEmpty()) null else samples[id]
|
||||
fun has(id: String): Boolean = samples.containsKey(id)
|
||||
|
||||
/**
|
||||
* Decode a WAV file (the common uncompressed formats) into a mono [Sample].
|
||||
* Supports 8/16/24-bit PCM and 32-bit float, mono or stereo (stereo is
|
||||
* down-mixed). Returns null if the bytes are not a WAV we understand.
|
||||
*/
|
||||
fun decodeWav(bytes: ByteArray): Sample? {
|
||||
if (bytes.size < 44) return null
|
||||
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
if (bytes[0].toInt().toChar() != 'R' || bytes[1].toInt().toChar() != 'I') return null // "RIFF"
|
||||
bb.position(8)
|
||||
if (readTag(bb) != "WAVE") return null
|
||||
|
||||
var audioFormat = 1
|
||||
var channels = 1
|
||||
var sampleRate = 44100
|
||||
var bitsPerSample = 16
|
||||
var dataOffset = -1
|
||||
var dataLength = 0
|
||||
|
||||
// Walk the chunks looking for "fmt " and "data".
|
||||
while (bb.remaining() >= 8) {
|
||||
val tag = readTag(bb)
|
||||
val size = bb.int
|
||||
val next = bb.position() + size
|
||||
when (tag) {
|
||||
"fmt " -> {
|
||||
audioFormat = bb.short.toInt() and 0xFFFF
|
||||
channels = (bb.short.toInt() and 0xFFFF).coerceAtLeast(1)
|
||||
sampleRate = bb.int
|
||||
bb.int // byteRate
|
||||
bb.short // blockAlign
|
||||
bitsPerSample = bb.short.toInt() and 0xFFFF
|
||||
}
|
||||
"data" -> { dataOffset = bb.position(); dataLength = size }
|
||||
}
|
||||
if (dataOffset >= 0 && audioFormat != 0) { /* keep scanning is fine */ }
|
||||
if (next <= bb.limit()) bb.position(next + (size and 1)) else break // chunks are word-aligned
|
||||
}
|
||||
if (dataOffset < 0) return null
|
||||
|
||||
val bytesPerSample = bitsPerSample / 8
|
||||
if (bytesPerSample == 0) return null
|
||||
val frameCount = dataLength / (bytesPerSample * channels)
|
||||
val out = FloatArray(frameCount)
|
||||
val data = ByteBuffer.wrap(bytes, dataOffset, dataLength).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
||||
for (f in 0 until frameCount) {
|
||||
var acc = 0f
|
||||
for (c in 0 until channels) {
|
||||
acc += when {
|
||||
audioFormat == 3 && bitsPerSample == 32 -> data.float
|
||||
bitsPerSample == 16 -> data.short / 32768f
|
||||
bitsPerSample == 8 -> ((data.get().toInt() and 0xFF) - 128) / 128f
|
||||
bitsPerSample == 24 -> {
|
||||
val b0 = data.get().toInt() and 0xFF
|
||||
val b1 = data.get().toInt() and 0xFF
|
||||
val b2 = data.get().toInt() // signed high byte
|
||||
((b2 shl 16) or (b1 shl 8) or b0) / 8388608f
|
||||
}
|
||||
bitsPerSample == 32 -> data.int / 2147483648f
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
out[f] = acc / channels // down-mix to mono
|
||||
}
|
||||
return Sample(out, sampleRate)
|
||||
}
|
||||
|
||||
private fun readTag(bb: ByteBuffer): String {
|
||||
val b = ByteArray(4)
|
||||
bb.get(b)
|
||||
return String(b, Charsets.US_ASCII)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
/**
|
||||
* Plays back a decoded [SampleStore.Sample] as a pitched, one-shot voice for the
|
||||
* Sampler instrument. The note's distance from the sampler's root note sets the
|
||||
* playback rate (so a single sample is pitch-stretched across the keyboard), and
|
||||
* the slice start/end markers bound the region that plays.
|
||||
*
|
||||
* Runs on the audio thread: [render] must not allocate. Linear interpolation
|
||||
* keeps pitched playback smooth; short fade-in/out ramps avoid clicks.
|
||||
*/
|
||||
class SampleVoice(private val engineSampleRate: Int) {
|
||||
private var sample: SampleStore.Sample? = null
|
||||
private var position = 0.0 // fractional read index into sample.data
|
||||
private var rate = 1.0 // samples advanced per output sample
|
||||
private var startIndex = 0
|
||||
private var endIndex = 0
|
||||
private var amp = 0f
|
||||
private var releasing = false
|
||||
private var gain = 0f // current fade gain 0..1
|
||||
var pitch = -1; private set
|
||||
|
||||
val isActive: Boolean get() = sample != null
|
||||
|
||||
/**
|
||||
* @param root MIDI note at which the sample plays at its natural pitch.
|
||||
* @param sliceStart/[sliceEnd] 0..1 fractions of the sample to play.
|
||||
* @param volume 0..1 instrument volume.
|
||||
*/
|
||||
fun noteOn(
|
||||
s: SampleStore.Sample, midi: Int, velocity: Int, root: Int,
|
||||
sliceStart: Float, sliceEnd: Float, volume: Float,
|
||||
) {
|
||||
sample = s
|
||||
pitch = midi
|
||||
val len = s.data.size
|
||||
val a = sliceStart.coerceIn(0f, 1f)
|
||||
val b = sliceEnd.coerceIn(0f, 1f)
|
||||
startIndex = (minOf(a, b) * len).toInt().coerceIn(0, (len - 1).coerceAtLeast(0))
|
||||
endIndex = (maxOf(a, b) * len).toInt().coerceIn(startIndex + 1, len)
|
||||
position = startIndex.toDouble()
|
||||
// Pitch ratio * sample-rate conversion.
|
||||
val semis = (midi - root) / 12.0
|
||||
rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate)
|
||||
amp = (velocity / 127f).coerceIn(0f, 1f) * volume
|
||||
releasing = false
|
||||
gain = 0f
|
||||
}
|
||||
|
||||
fun noteOff() { releasing = true }
|
||||
|
||||
fun kill() { sample = null; pitch = -1; gain = 0f }
|
||||
|
||||
fun render(): Float {
|
||||
val s = sample ?: return 0f
|
||||
val data = s.data
|
||||
val i = position.toInt()
|
||||
if (i >= endIndex - 1 || i >= data.size - 1) { kill(); return 0f }
|
||||
|
||||
// Linear interpolation between neighbouring samples.
|
||||
val frac = (position - i).toFloat()
|
||||
val out = data[i] + (data[i + 1] - data[i]) * frac
|
||||
|
||||
// Fade in quickly, and fade out when releasing, to avoid clicks.
|
||||
gain = if (releasing) (gain - FADE_STEP).coerceAtLeast(0f)
|
||||
else (gain + FADE_STEP).coerceAtMost(1f)
|
||||
if (releasing && gain <= 0f) { kill(); return 0f }
|
||||
|
||||
position += rate
|
||||
return out * amp * gain
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FADE_STEP = 0.01f // ~2 ms ramp at 48 kHz
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* One playing note (a "voice"). This is a deliberately simple NES/Famicom-style
|
||||
* tone generator: a few band-limited-ish waveforms plus a linear ADSR envelope.
|
||||
* It renders sample-by-sample into the mix buffer.
|
||||
*
|
||||
* Everything here runs on the audio thread and must never allocate or block.
|
||||
*/
|
||||
class SynthVoice(private val sampleRate: Int) {
|
||||
|
||||
enum class Wave { PULSE_12, PULSE_25, PULSE_50, TRIANGLE, NOISE }
|
||||
|
||||
private enum class Stage { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
|
||||
|
||||
// --- Note / oscillator state ---
|
||||
private var wave = Wave.PULSE_50
|
||||
private var phase = 0.0 // 0..1
|
||||
private var phaseInc = 0.0 // per-sample phase advance
|
||||
private var noiseReg = 0x7FFF // linear-feedback shift register for noise
|
||||
var pitch = -1; private set // MIDI note this voice is playing, -1 idle
|
||||
|
||||
// --- Envelope (times are in seconds) ---
|
||||
private var stage = Stage.IDLE
|
||||
private var env = 0.0
|
||||
private var attack = 0.01; private var decay = 0.15
|
||||
private var sustainLevel = 0.6; private var release = 0.1
|
||||
private var amp = 0.0 // note velocity as 0..1
|
||||
|
||||
val isActive: Boolean get() = stage != Stage.IDLE
|
||||
|
||||
/** Start (or retrigger) this voice. Called from the sequencer/live input. */
|
||||
fun noteOn(midi: Int, velocity: Int, w: Wave, a: Double, d: Double, s: Double, r: Double) {
|
||||
pitch = midi
|
||||
wave = w
|
||||
attack = a.coerceAtLeast(0.001); decay = d; sustainLevel = s; release = r
|
||||
amp = (velocity / 127.0).coerceIn(0.0, 1.0)
|
||||
phaseInc = midiToHz(midi) / sampleRate
|
||||
stage = Stage.ATTACK
|
||||
// env is intentionally NOT reset to 0 on retrigger to avoid clicks.
|
||||
}
|
||||
|
||||
fun noteOff() {
|
||||
if (stage != Stage.IDLE) stage = Stage.RELEASE
|
||||
}
|
||||
|
||||
/** Hard stop with no release tail (used on transport stop / voice steal). */
|
||||
fun kill() {
|
||||
stage = Stage.IDLE; env = 0.0; pitch = -1
|
||||
}
|
||||
|
||||
/** Render one sample and return its signed float value (roughly -1..1). */
|
||||
fun render(): Float {
|
||||
if (stage == Stage.IDLE) return 0f
|
||||
advanceEnvelope()
|
||||
val osc = oscillator()
|
||||
// advance oscillator phase
|
||||
phase += phaseInc
|
||||
if (phase >= 1.0) phase -= 1.0
|
||||
return (osc * env * amp).toFloat()
|
||||
}
|
||||
|
||||
private fun oscillator(): Double = when (wave) {
|
||||
Wave.PULSE_12 -> if (phase < 0.125) 1.0 else -1.0
|
||||
Wave.PULSE_25 -> if (phase < 0.25) 1.0 else -1.0
|
||||
Wave.PULSE_50 -> if (phase < 0.5) 1.0 else -1.0
|
||||
Wave.TRIANGLE -> 1.0 - 4.0 * kotlin.math.abs((phase % 1.0) - 0.5) // -1..1
|
||||
Wave.NOISE -> {
|
||||
// 15-bit LFSR clocked at the note frequency for a pitched-noise feel.
|
||||
if (phase < phaseInc) {
|
||||
val bit = (noiseReg xor (noiseReg shr 1)) and 1
|
||||
noiseReg = (noiseReg shr 1) or (bit shl 14)
|
||||
}
|
||||
if (noiseReg and 1 == 0) 1.0 else -1.0
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceEnvelope() {
|
||||
val dt = 1.0 / sampleRate
|
||||
when (stage) {
|
||||
Stage.ATTACK -> {
|
||||
env += dt / attack
|
||||
if (env >= 1.0) { env = 1.0; stage = Stage.DECAY }
|
||||
}
|
||||
Stage.DECAY -> {
|
||||
env -= dt / decay.coerceAtLeast(0.001) * (1.0 - sustainLevel)
|
||||
if (env <= sustainLevel) { env = sustainLevel; stage = Stage.SUSTAIN }
|
||||
}
|
||||
Stage.SUSTAIN -> { /* hold until noteOff */ }
|
||||
Stage.RELEASE -> {
|
||||
env -= dt / release.coerceAtLeast(0.001) * sustainLevel.coerceAtLeast(0.001)
|
||||
if (env <= 0.0) kill()
|
||||
}
|
||||
Stage.IDLE -> {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Equal-tempered MIDI note -> frequency in Hz (A4 = 69 = 440 Hz). */
|
||||
fun midiToHz(midi: Int): Double = 440.0 * Math.pow(2.0, (midi - 69) / 12.0)
|
||||
|
||||
@Suppress("unused") private const val TWO_PI = 2.0 * PI
|
||||
@Suppress("unused") private fun s(x: Double) = sin(x) // kept for future waves
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.audio
|
||||
|
||||
/**
|
||||
* A small, immutable snapshot of "where playback is" that the audio thread
|
||||
* publishes and the UI reads to draw the playhead. Kept as a plain value class so
|
||||
* copying it across threads is cheap and lock-free.
|
||||
*/
|
||||
data class TransportState(
|
||||
val isPlaying: Boolean = false,
|
||||
/** Absolute line index within the currently-playing pattern. */
|
||||
val currentLine: Int = 0,
|
||||
/** Beat index within the arrangement (0 until arrangement.lengthBeats). */
|
||||
val currentBeat: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.input
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
|
||||
/**
|
||||
* Translates USB / Bluetooth gamepad input into [InputAction]s. Two kinds of
|
||||
* events arrive:
|
||||
* - button presses -> [KeyEvent] (handled in [onKeyDown])
|
||||
* - stick / trigger / D-pad-as-hat movement -> [MotionEvent] (handled in [onMotion])
|
||||
*
|
||||
* The button map is user-rebindable: [bindings] maps an Android key-code to an
|
||||
* action id string, editable from the Settings tab. A sensible default layout is
|
||||
* provided so the pad works out of the box.
|
||||
*/
|
||||
class GamepadInput(
|
||||
private val router: InputRouter,
|
||||
var bindings: MutableMap<Int, String> = defaultBindings(),
|
||||
) {
|
||||
// Debounce hat/stick navigation so a held stick doesn't fire every frame.
|
||||
private var lastHatX = 0
|
||||
private var lastHatY = 0
|
||||
|
||||
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD) &&
|
||||
!event.isFromSource(InputDevice.SOURCE_JOYSTICK)
|
||||
) return false
|
||||
|
||||
val actionId = bindings[keyCode]
|
||||
if (actionId == null) {
|
||||
// Unbound button: forward as raw so "learn" mode can capture it.
|
||||
if (router.learnMode) router.dispatch(InputAction.RawControl(InputSource.GAMEPAD, keyCode, 1f))
|
||||
return false
|
||||
}
|
||||
actionFromId(actionId)?.let { router.dispatch(it); return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/** Handle analog sticks, triggers, and D-pad hat axes. */
|
||||
fun onMotion(event: MotionEvent): Boolean {
|
||||
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
|
||||
|
||||
// D-pad reported as HAT axes on many pads.
|
||||
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X).toSign()
|
||||
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y).toSign()
|
||||
if (hatX != lastHatX) {
|
||||
lastHatX = hatX
|
||||
when (hatX) { -1 -> router.dispatch(InputAction.NavLeft); 1 -> router.dispatch(InputAction.NavRight) }
|
||||
}
|
||||
if (hatY != lastHatY) {
|
||||
lastHatY = hatY
|
||||
when (hatY) { -1 -> router.dispatch(InputAction.NavUp); 1 -> router.dispatch(InputAction.NavDown) }
|
||||
}
|
||||
|
||||
// Right stick vertical edits the focused value (up = increment).
|
||||
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
when {
|
||||
ry < -EDIT_THRESHOLD -> router.dispatch(InputAction.Increment(1))
|
||||
ry > EDIT_THRESHOLD -> router.dispatch(InputAction.Decrement(1))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun actionFromId(id: String): InputAction? = when (id) {
|
||||
"playPause" -> InputAction.PlayPause
|
||||
"stop" -> InputAction.Stop
|
||||
"loop" -> InputAction.ToggleLoop
|
||||
"up" -> InputAction.NavUp
|
||||
"down" -> InputAction.NavDown
|
||||
"left" -> InputAction.NavLeft
|
||||
"right" -> InputAction.NavRight
|
||||
"increment" -> InputAction.Increment(1)
|
||||
"decrement" -> InputAction.Decrement(1)
|
||||
"clear" -> InputAction.ClearCell
|
||||
"nextTab" -> InputAction.NextTab
|
||||
"prevTab" -> InputAction.PrevTab
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun Float.toSign(): Int = when {
|
||||
this < -0.5f -> -1; this > 0.5f -> 1; else -> 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EDIT_THRESHOLD = 0.6f
|
||||
|
||||
/** Default Xbox/standard-layout mapping; every value is an action id. */
|
||||
fun defaultBindings(): MutableMap<Int, String> = mutableMapOf(
|
||||
KeyEvent.KEYCODE_BUTTON_A to "playPause",
|
||||
KeyEvent.KEYCODE_BUTTON_B to "stop",
|
||||
KeyEvent.KEYCODE_BUTTON_X to "clear",
|
||||
KeyEvent.KEYCODE_BUTTON_Y to "loop",
|
||||
KeyEvent.KEYCODE_BUTTON_L1 to "prevTab",
|
||||
KeyEvent.KEYCODE_BUTTON_R1 to "nextTab",
|
||||
KeyEvent.KEYCODE_DPAD_UP to "up",
|
||||
KeyEvent.KEYCODE_DPAD_DOWN to "down",
|
||||
KeyEvent.KEYCODE_DPAD_LEFT to "left",
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT to "right",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.input
|
||||
|
||||
/**
|
||||
* The heart of the "four equal input methods" requirement. Touch, physical
|
||||
* keyboard, gamepad and MIDI never talk to the app directly — each is translated
|
||||
* into one of these neutral [InputAction]s and fed through the same [InputRouter].
|
||||
* That means a feature only has to be implemented once (in the router/handler)
|
||||
* and it instantly works from every input device.
|
||||
*
|
||||
* If you want to support a new gesture or control, add a case here first, then
|
||||
* teach each source how to produce it and the handler how to react.
|
||||
*/
|
||||
sealed interface InputAction {
|
||||
|
||||
// ----- Grid navigation (cursor movement in the tracker) -----
|
||||
data object NavUp : InputAction
|
||||
data object NavDown : InputAction
|
||||
data object NavLeft : InputAction
|
||||
data object NavRight : InputAction
|
||||
/** Move to the next / previous editable column within the focused track. */
|
||||
data object NextColumn : InputAction
|
||||
data object PrevColumn : InputAction
|
||||
|
||||
// ----- Value editing on the focused cell -----
|
||||
/** +1 step: pitch cycles within the active scale; velocity/channel +1. */
|
||||
data class Increment(val amount: Int = 1) : InputAction
|
||||
data class Decrement(val amount: Int = 1) : InputAction
|
||||
/** Clear the focused cell / place a note-off. */
|
||||
data object ClearCell : InputAction
|
||||
data object NoteOffCell : InputAction
|
||||
|
||||
// ----- Live note entry (also used to audition instruments) -----
|
||||
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction
|
||||
data class NoteOff(val pitch: Int) : InputAction
|
||||
|
||||
// ----- Transport -----
|
||||
data object PlayPause : InputAction
|
||||
/** First tap: stop. Second tap while stopped: rewind to start. */
|
||||
data object Stop : InputAction
|
||||
data object ToggleLoop : InputAction
|
||||
|
||||
// ----- Navigation between the four tabs -----
|
||||
data class SelectTab(val index: Int) : InputAction
|
||||
data object NextTab : InputAction
|
||||
data object PrevTab : InputAction
|
||||
|
||||
// ----- A raw, unmapped control (e.g. an unbound MIDI CC). Handlers may
|
||||
// ignore it; the settings screen uses it to "learn" new bindings. -----
|
||||
data class RawControl(val source: InputSource, val code: Int, val value: Float) : InputAction
|
||||
}
|
||||
|
||||
/** Which physical device an action came from — handy for on-screen feedback. */
|
||||
enum class InputSource { TOUCH, KEYBOARD, GAMEPAD, MIDI }
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.input
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* A tiny message bus. Every input source calls [dispatch]; the app's ViewModel
|
||||
* collects [actions] and reacts. Using a flow (rather than a direct callback)
|
||||
* decouples the sources from the UI and lets us buffer bursts of MIDI events
|
||||
* without dropping them.
|
||||
*
|
||||
* When "MIDI learn" mode is on, controls that aren't bound to anything are still
|
||||
* forwarded as [InputAction.RawControl] so the settings screen can capture them.
|
||||
*/
|
||||
class InputRouter {
|
||||
// extraBufferCapacity gives us headroom for fast MIDI/gamepad bursts.
|
||||
private val _actions = MutableSharedFlow<InputAction>(extraBufferCapacity = 128)
|
||||
val actions: SharedFlow<InputAction> = _actions.asSharedFlow()
|
||||
|
||||
/** True while the settings screen is waiting to learn a new binding. */
|
||||
@Volatile var learnMode: Boolean = false
|
||||
|
||||
fun dispatch(action: InputAction) {
|
||||
// tryEmit never suspends; if the buffer is somehow full we drop the
|
||||
// oldest by design (a missed nav key is harmless, a stall is not).
|
||||
_actions.tryEmit(action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.input
|
||||
|
||||
import android.view.KeyEvent
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
|
||||
/**
|
||||
* Translates physical (USB / Bluetooth) keyboard events into [InputAction]s.
|
||||
* [MainActivity] forwards every hardware key here from its
|
||||
* `onKeyDown` / `onKeyUp` overrides.
|
||||
*
|
||||
* The letter rows form a two-octave "tracker piano" in the classic layout so you
|
||||
* can play and record notes without a MIDI device:
|
||||
* lower octave : Z S X D C V G B H N J M (C .. B)
|
||||
* upper octave : Q 2 W 3 E R 5 T 6 Y 7 U (C .. B, one octave up)
|
||||
*/
|
||||
class KeyboardInput(private val router: InputRouter) {
|
||||
|
||||
/** The base octave the keyboard piano plays in; adjustable with +/- keys. */
|
||||
var baseOctave: Int = 4
|
||||
|
||||
// keyCode -> semitone offset from the base octave's C.
|
||||
private val pianoMap: Map<Int, Int> = mapOf(
|
||||
KeyEvent.KEYCODE_Z to 0, KeyEvent.KEYCODE_S to 1, KeyEvent.KEYCODE_X to 2,
|
||||
KeyEvent.KEYCODE_D to 3, KeyEvent.KEYCODE_C to 4, KeyEvent.KEYCODE_V to 5,
|
||||
KeyEvent.KEYCODE_G to 6, KeyEvent.KEYCODE_B to 7, KeyEvent.KEYCODE_H to 8,
|
||||
KeyEvent.KEYCODE_N to 9, KeyEvent.KEYCODE_J to 10, KeyEvent.KEYCODE_M to 11,
|
||||
KeyEvent.KEYCODE_Q to 12, KeyEvent.KEYCODE_2 to 13, KeyEvent.KEYCODE_W to 14,
|
||||
KeyEvent.KEYCODE_3 to 15, KeyEvent.KEYCODE_E to 16, KeyEvent.KEYCODE_R to 17,
|
||||
KeyEvent.KEYCODE_5 to 18, KeyEvent.KEYCODE_T to 19, KeyEvent.KEYCODE_6 to 20,
|
||||
KeyEvent.KEYCODE_Y to 21, KeyEvent.KEYCODE_7 to 22, KeyEvent.KEYCODE_U to 23,
|
||||
)
|
||||
|
||||
/** @return true if we consumed the event. */
|
||||
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
// Ignore auto-repeat for one-shot actions but let piano notes retrigger.
|
||||
val action: InputAction? = when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_UP -> InputAction.NavUp
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> InputAction.NavDown
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> InputAction.NavLeft
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> InputAction.NavRight
|
||||
KeyEvent.KEYCODE_TAB -> if (event.isShiftPressed) InputAction.PrevColumn else InputAction.NextColumn
|
||||
KeyEvent.KEYCODE_SPACE -> InputAction.PlayPause
|
||||
KeyEvent.KEYCODE_ENTER -> InputAction.Stop
|
||||
KeyEvent.KEYCODE_L -> InputAction.ToggleLoop
|
||||
KeyEvent.KEYCODE_PAGE_UP -> InputAction.Increment(1)
|
||||
KeyEvent.KEYCODE_PAGE_DOWN -> InputAction.Decrement(1)
|
||||
KeyEvent.KEYCODE_DEL -> InputAction.ClearCell
|
||||
KeyEvent.KEYCODE_GRAVE -> InputAction.NoteOffCell
|
||||
KeyEvent.KEYCODE_MINUS -> { baseOctave = (baseOctave - 1).coerceIn(0, 8); null }
|
||||
KeyEvent.KEYCODE_EQUALS -> { baseOctave = (baseOctave + 1).coerceIn(0, 8); null }
|
||||
else -> pianoMap[keyCode]?.let { offset ->
|
||||
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
|
||||
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
InputAction.NoteOn(pitch, velocity = 100)
|
||||
}
|
||||
}
|
||||
return action?.let { router.dispatch(it); true } ?: false
|
||||
}
|
||||
|
||||
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent): Boolean {
|
||||
val offset = pianoMap[keyCode] ?: return false
|
||||
val pitch = (Pitch.MIDDLE_C + (baseOctave - 4) * 12 + offset)
|
||||
.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
router.dispatch(InputAction.NoteOff(pitch))
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.input
|
||||
|
||||
import android.content.Context
|
||||
import android.media.midi.MidiDeviceInfo
|
||||
import android.media.midi.MidiManager
|
||||
import android.media.midi.MidiOutputPort
|
||||
import android.media.midi.MidiReceiver
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Bridges hardware MIDI (USB *and* Bluetooth — Android surfaces both through the
|
||||
* same [MidiManager]) into the app's [InputAction] stream.
|
||||
*
|
||||
* MIDI note-on/off become live notes; MIDI Control Change (CC) messages are
|
||||
* matched against [ccBindings] so any knob/pad can drive navigation or transport.
|
||||
* Unmatched CCs are forwarded as [InputAction.RawControl] so the Settings screen
|
||||
* can "learn" them.
|
||||
*
|
||||
* Note: this handles MIDI *input*. Sending MIDI out lives in the audio/midi
|
||||
* output module; both share the same [MidiManager].
|
||||
*/
|
||||
class MidiInput(
|
||||
context: Context,
|
||||
private val router: InputRouter,
|
||||
/** CC number -> action id, editable from Settings (MIDI bindings section). */
|
||||
var ccBindings: MutableMap<Int, String> = mutableMapOf(),
|
||||
) {
|
||||
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val openPorts = mutableListOf<MidiOutputPort>()
|
||||
|
||||
/** Start listening. Opens every currently-attached input-capable device and
|
||||
* watches for hot-plug events. Safe to call if the device has no MIDI. */
|
||||
fun start() {
|
||||
val mm = midiManager ?: return
|
||||
mm.devices.forEach(::openDevice)
|
||||
mm.registerDeviceCallback(object : MidiManager.DeviceCallback() {
|
||||
override fun onDeviceAdded(device: MidiDeviceInfo) = openDevice(device)
|
||||
}, handler)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
openPorts.forEach { runCatching { it.close() } }
|
||||
openPorts.clear()
|
||||
}
|
||||
|
||||
private fun openDevice(info: MidiDeviceInfo) {
|
||||
// We want devices that can SEND to us (they expose output ports).
|
||||
if (info.outputPortCount == 0) return
|
||||
midiManager?.openDevice(info, { device ->
|
||||
val port = device?.openOutputPort(0) ?: return@openDevice
|
||||
port.connect(parser)
|
||||
openPorts += port
|
||||
}, handler)
|
||||
}
|
||||
|
||||
/** Parses the raw MIDI byte stream. MIDI status bytes have the high bit set;
|
||||
* the low nibble is the channel, the high nibble the message type. */
|
||||
private val parser = object : MidiReceiver() {
|
||||
override fun onSend(data: ByteArray, offset: Int, count: Int, timestamp: Long) {
|
||||
var i = offset
|
||||
val end = offset + count
|
||||
while (i < end) {
|
||||
val status = data[i].toInt() and 0xFF
|
||||
if (status < 0x80) { i++; continue } // skip stray data bytes
|
||||
val type = status and 0xF0
|
||||
when (type) {
|
||||
0x90 -> { // Note On
|
||||
val note = data.getOrZero(i + 1)
|
||||
val vel = data.getOrZero(i + 2)
|
||||
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
|
||||
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
|
||||
i += 3
|
||||
}
|
||||
0x80 -> { // Note Off
|
||||
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
|
||||
}
|
||||
0xB0 -> { // Control Change
|
||||
val cc = data.getOrZero(i + 1)
|
||||
val value = data.getOrZero(i + 2)
|
||||
handleCc(cc, value); i += 3
|
||||
}
|
||||
0xC0, 0xD0 -> i += 2 // program change / channel pressure: 1 data byte
|
||||
else -> i += 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCc(cc: Int, value: Int) {
|
||||
val actionId = ccBindings[cc]
|
||||
if (actionId == null) {
|
||||
router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f))
|
||||
return
|
||||
}
|
||||
// Momentary controls fire on the "press" half (value >= 64).
|
||||
val action = when (actionId) {
|
||||
"playPause" -> if (value >= 64) InputAction.PlayPause else null
|
||||
"stop" -> if (value >= 64) InputAction.Stop else null
|
||||
"loop" -> if (value >= 64) InputAction.ToggleLoop else null
|
||||
"up" -> if (value >= 64) InputAction.NavUp else null
|
||||
"down" -> if (value >= 64) InputAction.NavDown else null
|
||||
"left" -> if (value >= 64) InputAction.NavLeft else null
|
||||
"right" -> if (value >= 64) InputAction.NavRight else null
|
||||
"increment" -> InputAction.Increment(1)
|
||||
"decrement" -> InputAction.Decrement(1)
|
||||
"clear" -> if (value >= 64) InputAction.ClearCell else null
|
||||
"nextTab" -> if (value >= 64) InputAction.NextTab else null
|
||||
"prevTab" -> if (value >= 64) InputAction.PrevTab else null
|
||||
else -> null
|
||||
}
|
||||
action?.let(router::dispatch)
|
||||
}
|
||||
|
||||
private fun ByteArray.getOrZero(index: Int): Int =
|
||||
if (index < size) this[index].toInt() and 0xFF else 0
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.io
|
||||
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||
|
||||
/**
|
||||
* Reads and writes a single instrument/effect preset as plain, human-readable
|
||||
* text. The format is deliberately trivial so a person can hand-edit it and a
|
||||
* machine can parse it with two lines of code:
|
||||
*
|
||||
* SIZZLE-PRESET 1
|
||||
* TYPE=NES_SYNTH
|
||||
* NAME=Lead
|
||||
* wave=Pulse25
|
||||
* attack=0.01
|
||||
* ...
|
||||
*
|
||||
* The same routine is used for the clipboard copy/paste in the Toolbox tab and
|
||||
* for the on-disk preset library.
|
||||
*/
|
||||
object PresetIo {
|
||||
private const val HEADER = "SIZZLE-PRESET 1"
|
||||
|
||||
/** Serialize a slot's device + parameters to text. */
|
||||
fun export(slot: ToolboxSlot): String {
|
||||
val type = slot.type ?: return "$HEADER\nTYPE=EMPTY"
|
||||
return buildString {
|
||||
appendLine(HEADER)
|
||||
appendLine("TYPE=${type.name}")
|
||||
appendLine("NAME=${slot.name}")
|
||||
// Emit params in the type's declared order for stable, readable files.
|
||||
type.params.forEach { spec ->
|
||||
appendLine("${spec.key}=${slot.string(spec.key, spec.default.toString())}")
|
||||
}
|
||||
// Emit any extra string params the engine stored (e.g. samplePath).
|
||||
slot.values.keys
|
||||
.filter { key -> type.params.none { it.key == key } }
|
||||
.forEach { key -> appendLine("$key=${slot.values[key]}") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse preset [text] into [slot], overwriting its current contents. Unknown
|
||||
* or malformed lines are ignored so a partially-edited file still loads.
|
||||
* @return true if a valid device type was found and applied.
|
||||
*/
|
||||
fun import(slot: ToolboxSlot, text: String): Boolean {
|
||||
val lines = text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val map = LinkedHashMap<String, String>()
|
||||
var type: ToolboxType? = null
|
||||
for (line in lines) {
|
||||
if (line == HEADER || !line.contains('=')) continue
|
||||
val (key, value) = line.split('=', limit = 2)
|
||||
when (key) {
|
||||
"TYPE" -> type = ToolboxType.entries.firstOrNull { it.name == value }
|
||||
"NAME" -> slot.name = value
|
||||
else -> map[key] = value
|
||||
}
|
||||
}
|
||||
val t = type ?: return false
|
||||
slot.fill(t) // reset to defaults for the type ...
|
||||
slot.name = slot.name.ifBlank { t.displayName }
|
||||
map.forEach { (k, v) -> slot.set(k, v) } // ... then apply the saved values
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.io
|
||||
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Mixer
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Scale
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||
|
||||
/**
|
||||
* Saves and loads a whole [Project] to/from the `.sng` text format.
|
||||
*
|
||||
* The format is line-oriented and human-readable (sections in [BRACKETS], one
|
||||
* record per line). It is our own layout, aligned in spirit with the reference
|
||||
* Sizzletracker project (github.com/reactorcoremeltdown/sizzletracker). A field
|
||||
* mapping to that project's exact byte layout is the remaining work — see
|
||||
* DEVELOPER_HANDOVER.md, "Project format". Because this writer/reader is fully
|
||||
* round-trip safe on its own, the app is usable today and the compatibility
|
||||
* shim can be added without touching any other code.
|
||||
*
|
||||
* Only non-empty tracker cells are written, keeping songs compact.
|
||||
*/
|
||||
object ProjectIo {
|
||||
private const val HEADER = "SIZZLETRACKER-SNG 1"
|
||||
|
||||
// ------------------------------------------------------------------- write
|
||||
fun save(project: Project): String = buildString {
|
||||
appendLine(HEADER)
|
||||
appendLine("NAME=${project.name}")
|
||||
appendLine("TEMPO=${project.tempoBpm}")
|
||||
appendLine("SIG=${project.timeSignature.name}")
|
||||
appendLine("SCALE=${project.scale.name}")
|
||||
appendLine("ROOT=${project.rootNote}")
|
||||
appendLine("ACTIVE=${project.activePatternId}")
|
||||
|
||||
project.patterns.forEach { p ->
|
||||
appendLine("[PATTERN id=${p.id} name=${p.name} len=${p.length}]")
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
for (line in 0 until p.length) {
|
||||
val cell = p.cell(t, line)
|
||||
if (!cell.isEmpty) appendLine("$t,$line,${cell.note},${cell.velocity},${cell.channel}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val arr = project.arrangement
|
||||
appendLine("[ARRANGEMENT len=${arr.lengthBeats} loop=${arr.loopEnabled.b()}]")
|
||||
for (lane in arr.slots.indices) {
|
||||
for (beat in 0 until arr.lengthBeats) {
|
||||
val pid = arr.slots[lane][beat]
|
||||
if (pid >= 0) appendLine("$lane,$beat,$pid")
|
||||
}
|
||||
}
|
||||
appendLine("[REGION A en=${arr.regionA.enabled.b()} s=${arr.regionA.start} e=${arr.regionA.end} r=${arr.regionA.repeats}]")
|
||||
appendLine("[REGION B en=${arr.regionB.enabled.b()} s=${arr.regionB.start} e=${arr.regionB.end} r=${arr.regionB.repeats}]")
|
||||
|
||||
appendLine("[MIXER]")
|
||||
project.mixer.channels.forEach { ch ->
|
||||
appendLine(
|
||||
"${ch.index},${ch.instrumentSlot},${ch.fxSlots.joinToString(",")}," +
|
||||
"${ch.midiChannel},${ch.volume},${ch.mute.b()},${ch.solo.b()}",
|
||||
)
|
||||
}
|
||||
|
||||
appendLine("[TOOLBOX]")
|
||||
project.toolbox.forEach { slot ->
|
||||
if (!slot.isEmpty) {
|
||||
val params = slot.values.entries.joinToString(",") { "${it.key}=${it.value}" }
|
||||
appendLine("${slot.index}=${slot.type!!.name};${slot.name};$params")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- read
|
||||
/** Parse [text] into a fresh [Project]. Malformed lines are skipped. */
|
||||
fun load(text: String): Project = loadInto(Project(), text)
|
||||
|
||||
/**
|
||||
* Parse [text] into the *existing* [project], replacing its contents. Used to
|
||||
* load a song into the app's shared singleton without swapping the object the
|
||||
* ViewModel and audio engine already hold a reference to.
|
||||
*/
|
||||
fun loadInto(project: Project, text: String): Project {
|
||||
// Reset everything to a clean slate first.
|
||||
project.patterns.clear()
|
||||
for (lane in project.arrangement.slots.indices)
|
||||
project.arrangement.slots[lane].fill(com.reactorcoremeltdown.sizzletracker.model.Arrangement.EMPTY)
|
||||
project.toolbox.forEach { it.clearSlot() }
|
||||
project.mixer.channels.forEach { ch ->
|
||||
ch.instrumentSlot = -1; ch.fxSlots.fill(-1); ch.midiChannel = 1
|
||||
ch.volume = 0.8f; ch.mute = false; ch.solo = false
|
||||
}
|
||||
var section = ""
|
||||
var current: Pattern? = null
|
||||
|
||||
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||
when {
|
||||
line == HEADER -> {}
|
||||
line.startsWith("[PATTERN") -> {
|
||||
val attrs = parseAttrs(line)
|
||||
current = Pattern(
|
||||
id = attrs["id"]?.toIntOrNull() ?: project.patterns.size,
|
||||
name = attrs["name"] ?: "PTN",
|
||||
length = attrs["len"]?.toIntOrNull() ?: 16,
|
||||
).also { project.patterns.add(it) }
|
||||
section = "PATTERN"
|
||||
}
|
||||
line.startsWith("[ARRANGEMENT") -> {
|
||||
val attrs = parseAttrs(line)
|
||||
project.arrangement.lengthBeats = attrs["len"]?.toIntOrNull() ?: 64
|
||||
project.arrangement.loopEnabled = attrs["loop"] == "1"
|
||||
section = "ARRANGEMENT"
|
||||
}
|
||||
line.startsWith("[REGION A") -> applyRegion(project.arrangement.regionA, parseAttrs(line))
|
||||
line.startsWith("[REGION B") -> applyRegion(project.arrangement.regionB, parseAttrs(line))
|
||||
line.startsWith("[MIXER]") -> section = "MIXER"
|
||||
line.startsWith("[TOOLBOX]") -> section = "TOOLBOX"
|
||||
line.contains('=') && !line.startsWith("[") && section == "" ->
|
||||
applyHeaderField(project, line)
|
||||
section == "PATTERN" -> applyPatternCell(current, line)
|
||||
section == "ARRANGEMENT" -> applyArrangementSlot(project, line)
|
||||
section == "MIXER" -> applyMixerRow(project, line)
|
||||
section == "TOOLBOX" -> applyToolboxRow(project, line)
|
||||
}
|
||||
}
|
||||
// Guarantee one block per lane (lane i is tied to block i), padding any
|
||||
// that the file did not define so the arrangement/editor always resolve.
|
||||
for (id in 0 until com.reactorcoremeltdown.sizzletracker.model.Arrangement.LANE_COUNT) {
|
||||
if (project.patterns.none { it.id == id }) project.patterns.add(Pattern(id, name = "BLK${id + 1}"))
|
||||
}
|
||||
project.patterns.sortBy { it.id }
|
||||
project.activePatternId = project.activePatternId.coerceIn(0, project.patterns.size - 1)
|
||||
return project
|
||||
}
|
||||
|
||||
// ----- small helpers -----
|
||||
private fun Boolean.b() = if (this) "1" else "0"
|
||||
|
||||
private fun parseAttrs(line: String): Map<String, String> =
|
||||
line.trim('[', ']').substringAfter(' ', "").split(' ')
|
||||
.mapNotNull { tok -> tok.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }
|
||||
.toMap()
|
||||
|
||||
private fun applyHeaderField(p: Project, line: String) {
|
||||
val (k, v) = line.split('=', limit = 2)
|
||||
when (k) {
|
||||
"NAME" -> p.name = v
|
||||
"TEMPO" -> p.tempoBpm = v.toFloatOrNull() ?: 120f
|
||||
"SIG" -> p.timeSignature = runCatching { TimeSignature.valueOf(v) }.getOrDefault(TimeSignature.FOUR_FOUR)
|
||||
"SCALE" -> p.scale = runCatching { Scale.valueOf(v) }.getOrDefault(Scale.CHROMATIC)
|
||||
"ROOT" -> p.rootNote = v.toIntOrNull() ?: 0
|
||||
"ACTIVE" -> p.activePatternId = v.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPatternCell(pattern: Pattern?, line: String) {
|
||||
val p = pattern ?: return
|
||||
val f = line.split(',')
|
||||
if (f.size < 5) return
|
||||
val t = f[0].toIntOrNull() ?: return
|
||||
val ln = f[1].toIntOrNull() ?: return
|
||||
if (t !in 0 until Pattern.TRACK_COUNT || ln !in 0 until p.length) return
|
||||
p.cell(t, ln).apply {
|
||||
note = f[2].toIntOrNull() ?: Cell.EMPTY
|
||||
velocity = (f[3].toIntOrNull() ?: Cell.MAX_VELOCITY).coerceIn(0, Cell.MAX_VELOCITY)
|
||||
channel = (f[4].toIntOrNull() ?: 1).coerceIn(1, Cell.MAX_CHANNEL)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyArrangementSlot(p: Project, line: String) {
|
||||
val f = line.split(',')
|
||||
if (f.size < 3) return
|
||||
p.arrangement.set(f[0].toInt(), f[1].toInt(), f[2].toInt())
|
||||
}
|
||||
|
||||
private fun applyRegion(region: com.reactorcoremeltdown.sizzletracker.model.LoopRegion, a: Map<String, String>) {
|
||||
region.enabled = a["en"] == "1"
|
||||
region.start = a["s"]?.toIntOrNull() ?: 0
|
||||
region.end = a["e"]?.toIntOrNull() ?: 0
|
||||
region.repeats = a["r"]?.toIntOrNull() ?: 2
|
||||
}
|
||||
|
||||
private fun applyMixerRow(p: Project, line: String) {
|
||||
val f = line.split(',')
|
||||
if (f.size < 10) return
|
||||
val idx = f[0].toIntOrNull() ?: return
|
||||
val ch = p.mixer.channels.getOrNull(idx) ?: return
|
||||
ch.instrumentSlot = f[1].toInt()
|
||||
for (i in 0 until Mixer.FX_SLOTS) ch.fxSlots[i] = f[2 + i].toInt()
|
||||
ch.midiChannel = f[6].toInt()
|
||||
ch.volume = f[7].toFloat()
|
||||
ch.mute = f[8] == "1"
|
||||
ch.solo = f[9] == "1"
|
||||
}
|
||||
|
||||
private fun applyToolboxRow(p: Project, line: String) {
|
||||
val idx = line.substringBefore('=').toIntOrNull() ?: return
|
||||
val rest = line.substringAfter('=')
|
||||
val parts = rest.split(';')
|
||||
if (parts.size < 2) return
|
||||
val type = ToolboxType.entries.firstOrNull { it.name == parts[0] } ?: return
|
||||
val slot = p.toolbox.getOrNull(idx) ?: return
|
||||
slot.fill(type)
|
||||
slot.name = parts[1]
|
||||
if (parts.size >= 3 && parts[2].isNotBlank()) {
|
||||
parts[2].split(',').forEach { kv ->
|
||||
val e = kv.split('=', limit = 2)
|
||||
if (e.size == 2) slot.set(e[0], e[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.io
|
||||
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||
|
||||
/**
|
||||
* Reads and writes the reference desktop **sizzletracker `.sng`** format
|
||||
* (github.com/reactorcoremeltdown/sizzletracker) so songs interchange with the
|
||||
* CLI version. It is line-oriented text:
|
||||
*
|
||||
* ```
|
||||
* version 1
|
||||
* bpm 120
|
||||
* sig 4 4
|
||||
*
|
||||
* block A 16 4
|
||||
* roll ####
|
||||
* track T1 0
|
||||
* 0 C-4 64 01
|
||||
* 4 E-4 .. ..
|
||||
* 8 OFF .. ..
|
||||
* endblock
|
||||
* ```
|
||||
*
|
||||
* The per-block `roll` (one char per beat, `#` = plays) maps directly onto our
|
||||
* lane-per-block arrangement. Velocity is hexadecimal; `..` means "default"; a
|
||||
* step channel of `..` inherits the track's declared channel. This format carries
|
||||
* the musical data only — mixer/toolbox settings use our own [ProjectIo].
|
||||
*/
|
||||
object SngFormat {
|
||||
|
||||
// ------------------------------------------------------------------- write
|
||||
fun export(project: Project): String = buildString {
|
||||
appendLine("version 1")
|
||||
appendLine("bpm ${project.tempoBpm.toInt()}")
|
||||
appendLine("sig ${project.timeSignature.beatsPerBar} 4")
|
||||
appendLine()
|
||||
|
||||
val arr = project.arrangement
|
||||
for (block in project.patterns) {
|
||||
val rollUsed = (0 until arr.lengthBeats).any { arr.patternAt(block.id, it) != Arrangement.EMPTY }
|
||||
val hasNotes = (0 until Pattern.TRACK_COUNT).any { t ->
|
||||
(0 until block.length).any { !block.cell(t, it).isEmpty }
|
||||
}
|
||||
if (!rollUsed && !hasNotes) continue // skip untouched blocks
|
||||
|
||||
appendLine("block ${block.name} ${block.length} ${project.timeSignature.beatsPerBar}")
|
||||
|
||||
val roll = buildString {
|
||||
for (beat in 0 until arr.lengthBeats) {
|
||||
append(if (arr.patternAt(block.id, beat) != Arrangement.EMPTY) '#' else '.')
|
||||
}
|
||||
}.trimEnd('.')
|
||||
if (roll.isNotEmpty()) appendLine("roll $roll")
|
||||
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val steps = (0 until block.length).filter { !block.cell(t, it).isEmpty }
|
||||
if (steps.isEmpty()) continue
|
||||
val defChannel = (project.mixer.channels[t].midiChannel - 1).coerceAtLeast(0)
|
||||
appendLine("track T${t + 1} $defChannel")
|
||||
for (line in steps) {
|
||||
val cell = block.cell(t, line)
|
||||
val note = if (cell.isNoteOff) "OFF" else Pitch.name(cell.note)
|
||||
val vel = cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
val chan = cell.channel.toString().padStart(2, '0')
|
||||
appendLine("$line $note $vel $chan")
|
||||
}
|
||||
}
|
||||
appendLine("endblock")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- read
|
||||
fun import(text: String): Project = importInto(Project(), text)
|
||||
|
||||
/** Parse `.sng` [text] into the existing [project] (resetting its content). */
|
||||
fun importInto(project: Project, text: String): Project {
|
||||
// Clear blocks and arrangement (keep the 8 lane-blocks, wipe their cells).
|
||||
project.patterns.forEach { p -> for (t in 0 until Pattern.TRACK_COUNT) p.tracks[t].forEach { it.clear() } }
|
||||
for (lane in project.arrangement.slots.indices) project.arrangement.slots[lane].fill(Arrangement.EMPTY)
|
||||
|
||||
var blockIndex = 0
|
||||
var current: Pattern? = null
|
||||
var track = 0
|
||||
var trackChannel = 1
|
||||
var maxRoll = 0
|
||||
|
||||
text.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.forEach { line ->
|
||||
val tok = line.split(Regex("\\s+"))
|
||||
when (tok[0]) {
|
||||
"version" -> {}
|
||||
"bpm" -> project.tempoBpm = tok.getOrNull(1)?.toFloatOrNull() ?: 120f
|
||||
"sig" -> project.timeSignature = when (tok.getOrNull(1)?.toIntOrNull()) {
|
||||
3 -> TimeSignature.THREE_FOUR
|
||||
5 -> TimeSignature.FIVE_FOUR
|
||||
else -> TimeSignature.FOUR_FOUR
|
||||
}
|
||||
"block" -> {
|
||||
current = project.patterns.getOrNull(blockIndex)?.also { blk ->
|
||||
tok.getOrNull(1)?.let { blk.name = it }
|
||||
tok.getOrNull(2)?.toIntOrNull()?.let { blk.resize(it) }
|
||||
}
|
||||
blockIndex++
|
||||
track = 0; trackChannel = 1
|
||||
}
|
||||
"roll" -> current?.let { blk ->
|
||||
val roll = tok.getOrNull(1) ?: ""
|
||||
roll.forEachIndexed { beat, ch -> if (ch == '#') project.arrangement.set(blk.id, beat, blk.id) }
|
||||
maxRoll = maxOf(maxRoll, roll.length)
|
||||
}
|
||||
"track" -> {
|
||||
track = tok.getOrNull(1)?.filter { it.isDigit() }?.toIntOrNull()?.minus(1)?.coerceIn(0, Pattern.TRACK_COUNT - 1) ?: 0
|
||||
trackChannel = tok.getOrNull(2)?.toIntOrNull()?.plus(1)?.coerceIn(1, 16) ?: 1
|
||||
}
|
||||
"endblock" -> current = null
|
||||
else -> applyStep(project, current, track, trackChannel, tok)
|
||||
}
|
||||
}
|
||||
if (maxRoll > 0) project.arrangement.lengthBeats = maxOf(project.arrangement.lengthBeats, maxRoll)
|
||||
project.activePatternId = 0
|
||||
return project
|
||||
}
|
||||
|
||||
private fun applyStep(project: Project, block: Pattern?, track: Int, trackChannel: Int, tok: List<String>) {
|
||||
val blk = block ?: return
|
||||
val tick = tok.getOrNull(0)?.toIntOrNull() ?: return
|
||||
if (tick !in 0 until blk.length) return
|
||||
val cell = blk.cell(track, tick)
|
||||
cell.note = Pitch.parse(tok.getOrElse(1) { "..." })
|
||||
cell.velocity = (tok.getOrNull(2)?.takeIf { it != ".." }?.toIntOrNull(16) ?: Cell.MAX_VELOCITY)
|
||||
.coerceIn(0, Cell.MAX_VELOCITY)
|
||||
cell.channel = (tok.getOrNull(3)?.takeIf { it != ".." }?.toIntOrNull() ?: trackChannel)
|
||||
.coerceIn(1, Cell.MAX_CHANNEL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.model
|
||||
|
||||
/**
|
||||
* The lower-half piano-roll arrangement. It has [LANE_COUNT] lanes stacked
|
||||
* vertically and up to [MAX_BEATS] beats horizontally. Each slot holds the id of
|
||||
* the [Pattern] to trigger on that beat (or [EMPTY]). Per the spec, one marked
|
||||
* slot triggers exactly ONE beat of that pattern/block, even if the pattern is
|
||||
* longer — so the arrangement grid resolution is one-beat-per-column.
|
||||
*/
|
||||
class Arrangement {
|
||||
/** slots[lane][beat] = patternId, or [EMPTY]. */
|
||||
val slots: Array<IntArray> = Array(LANE_COUNT) { IntArray(MAX_BEATS) { EMPTY } }
|
||||
|
||||
/** How many beats are actually in use; the roll can grow up to [MAX_BEATS]. */
|
||||
var lengthBeats: Int = 64
|
||||
|
||||
/** Loop transport state (the toolbar under the roll). */
|
||||
var loopEnabled: Boolean = false
|
||||
val regionA: LoopRegion = LoopRegion()
|
||||
val regionB: LoopRegion = LoopRegion()
|
||||
|
||||
fun patternAt(lane: Int, beat: Int): Int =
|
||||
if (beat in 0 until MAX_BEATS) slots[lane][beat] else EMPTY
|
||||
|
||||
fun set(lane: Int, beat: Int, patternId: Int) {
|
||||
if (lane in 0 until LANE_COUNT && beat in 0 until MAX_BEATS) {
|
||||
slots[lane][beat] = patternId
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LANE_COUNT = 8
|
||||
const val MAX_BEATS = 256
|
||||
const val EMPTY = -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An A or B loop region on the arrangement roll. [start] and [end] are beat
|
||||
* indices (inclusive start, exclusive end); [repeats] is how many times the
|
||||
* region plays before control moves on. A region is only active when it has a
|
||||
* non-empty span AND [enabled] is set (toggled by the A/B buttons).
|
||||
*/
|
||||
class LoopRegion(
|
||||
var enabled: Boolean = false,
|
||||
var start: Int = 0,
|
||||
var end: Int = 0,
|
||||
var repeats: Int = 2,
|
||||
) {
|
||||
val isValid: Boolean get() = enabled && end > start
|
||||
val spanBeats: Int get() = (end - start).coerceAtLeast(0)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.model
|
||||
|
||||
/**
|
||||
* The mixer has exactly [CHANNEL_COUNT] channels, one per tracker track. Each
|
||||
* channel routes through one instrument and up to four effects, all referenced
|
||||
* by their slot index in the [Toolbox] (tab 3). A value of -1 means "no device".
|
||||
*/
|
||||
class Mixer {
|
||||
val channels: List<MixerChannel> = List(CHANNEL_COUNT) { MixerChannel(it) }
|
||||
|
||||
/** True if ANY channel is soloed — used to mute the non-soloed ones. */
|
||||
fun anySolo(): Boolean = channels.any { it.solo }
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_COUNT = Pattern.TRACK_COUNT // 4, kept in lockstep
|
||||
const val FX_SLOTS = 4
|
||||
const val NO_DEVICE = -1
|
||||
}
|
||||
}
|
||||
|
||||
class MixerChannel(
|
||||
val index: Int,
|
||||
/** Toolbox slot index of the instrument feeding this channel, or -1. */
|
||||
var instrumentSlot: Int = Mixer.NO_DEVICE,
|
||||
/** Toolbox slot indices of up to four insert effects, -1 where unused. */
|
||||
val fxSlots: IntArray = IntArray(Mixer.FX_SLOTS) { Mixer.NO_DEVICE },
|
||||
/** MIDI channel (1..16) this mixer channel listens/sends on. */
|
||||
var midiChannel: Int = 1,
|
||||
/** Linear 0..1 fader. */
|
||||
var volume: Float = 0.8f,
|
||||
var mute: Boolean = false,
|
||||
var solo: Boolean = false,
|
||||
) {
|
||||
/** Whether this channel should actually produce sound given the solo state. */
|
||||
fun audible(anySolo: Boolean): Boolean = !mute && (!anySolo || solo)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.model
|
||||
|
||||
/**
|
||||
* Musical primitives shared across the whole app: pitches, scales and time
|
||||
* signatures. Everything here is deliberately small, immutable and free of any
|
||||
* Android dependency so it can be unit-tested and reasoned about in isolation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A MIDI note number, 0..127, where 60 == middle C (C4 in the convention we use).
|
||||
* We keep notes as plain Ints in the hot paths (patterns) for memory reasons and
|
||||
* only wrap them when we need naming/formatting helpers.
|
||||
*/
|
||||
object Pitch {
|
||||
const val LOWEST = 0
|
||||
const val HIGHEST = 127
|
||||
const val MIDDLE_C = 60
|
||||
|
||||
private val NAMES = arrayOf(
|
||||
"C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-",
|
||||
)
|
||||
|
||||
/** Human/tracker-readable name, e.g. 60 -> "C-4", 61 -> "C#4". */
|
||||
fun name(midi: Int): String {
|
||||
if (midi !in LOWEST..HIGHEST) return "..."
|
||||
val octave = midi / 12 - 1 // MIDI 12 == C0 in this convention
|
||||
return NAMES[midi % 12] + octave
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of [name]: parse "C-4" / "C#4" / "OFF" / "..." into a note value.
|
||||
* Returns [Cell.OFF] for a note-off, [Cell.EMPTY] for empty/unparseable input.
|
||||
* Used by the sizzletracker `.sng` importer.
|
||||
*/
|
||||
fun parse(text: String): Int {
|
||||
val t = text.trim()
|
||||
if (t == "OFF" || t == "===") return Cell.OFF
|
||||
if (t.length < 3 || t.startsWith(".")) return Cell.EMPTY
|
||||
val idx = NAMES.indexOf(t.substring(0, 2))
|
||||
if (idx < 0) return Cell.EMPTY
|
||||
val octave = t.substring(2).toIntOrNull() ?: return Cell.EMPTY
|
||||
return ((octave + 1) * 12 + idx).coerceIn(LOWEST, HIGHEST)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The three time signatures the tracker supports. The key idea in this app is
|
||||
* that a *beat* is made of a fixed number of tracker *lines* (ticks), and that
|
||||
* number is what changes between signatures:
|
||||
* - 3/4 -> 3 lines per beat
|
||||
* - 4/4 -> 4 lines per beat
|
||||
* - 5/4 -> 5 lines per beat
|
||||
* A *bar* is always [beatsPerBar] beats long (here, equal to the numerator).
|
||||
*/
|
||||
enum class TimeSignature(
|
||||
val label: String,
|
||||
val linesPerBeat: Int,
|
||||
val beatsPerBar: Int,
|
||||
/** Allowed pattern lengths (in lines) offered in the length dropdown. */
|
||||
val lengthOptions: List<Int>,
|
||||
) {
|
||||
THREE_FOUR("3/4", linesPerBeat = 3, beatsPerBar = 3, lengthOptions = listOf(12, 24, 48)),
|
||||
FOUR_FOUR("4/4", linesPerBeat = 4, beatsPerBar = 4, lengthOptions = listOf(16, 32, 64)),
|
||||
FIVE_FOUR("5/4", linesPerBeat = 5, beatsPerBar = 5, lengthOptions = listOf(20, 40, 80));
|
||||
|
||||
/** Lines per bar = lines-per-beat * beats-per-bar. */
|
||||
val linesPerBar: Int get() = linesPerBeat * beatsPerBar
|
||||
}
|
||||
|
||||
/**
|
||||
* A musical scale expressed as semitone offsets from the root (0..11).
|
||||
* Used by the tracker's note stepping (keyboard/gamepad +/- and the touch editor)
|
||||
* so the user can only land on in-key notes when a non-chromatic scale is selected.
|
||||
*/
|
||||
enum class Scale(val label: String, val intervals: List<Int>) {
|
||||
CHROMATIC("Chromatic", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)),
|
||||
MAJOR("Major", listOf(0, 2, 4, 5, 7, 9, 11)),
|
||||
MINOR("Natural Minor", listOf(0, 2, 3, 5, 7, 8, 10)),
|
||||
HARMONIC_MINOR("Harmonic Minor", listOf(0, 2, 3, 5, 7, 8, 11)),
|
||||
DORIAN("Dorian", listOf(0, 2, 3, 5, 7, 9, 10)),
|
||||
PHRYGIAN("Phrygian", listOf(0, 1, 3, 5, 7, 8, 10)),
|
||||
MIXOLYDIAN("Mixolydian", listOf(0, 2, 4, 5, 7, 9, 10)),
|
||||
PENTATONIC_MAJOR("Pentatonic Major", listOf(0, 2, 4, 7, 9)),
|
||||
PENTATONIC_MINOR("Pentatonic Minor", listOf(0, 3, 5, 7, 10)),
|
||||
BLUES("Blues", listOf(0, 3, 5, 6, 7, 10));
|
||||
|
||||
/**
|
||||
* Returns the next in-scale MIDI note above [fromMidi] when [direction] is
|
||||
* +1, or below when -1, given a scale [root] (0=C .. 11=B). If [fromMidi] is
|
||||
* off-scale we snap to the nearest in-scale note first. This is the single
|
||||
* function that powers the tracker's note stepping (keyboard/gamepad + editor).
|
||||
*/
|
||||
fun step(fromMidi: Int, root: Int, direction: Int): Int {
|
||||
if (this == CHROMATIC) {
|
||||
return (fromMidi + direction).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
}
|
||||
// Build the set of pitch-classes that are in-key.
|
||||
val inKey = intervals.map { (it + root) % 12 }.toSortedSet()
|
||||
var candidate = fromMidi
|
||||
// Walk one semitone at a time until we hit an in-key pitch-class.
|
||||
do {
|
||||
candidate += direction
|
||||
if (candidate !in Pitch.LOWEST..Pitch.HIGHEST) {
|
||||
return fromMidi // hit the edge, don't move
|
||||
}
|
||||
} while ((candidate % 12) !in inKey)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.model
|
||||
|
||||
/**
|
||||
* One cell in the tracker grid: a single note event on a single line of a single
|
||||
* track. All three fields map directly to the three on-screen columns:
|
||||
* - [note] : MIDI note number, or [EMPTY], or [OFF] (note-off).
|
||||
* - [velocity] : 0..127 (MIDI range), shown to the user as two hex digits (00..7F).
|
||||
* - [channel] : MIDI channel 1..16, shown as a decimal.
|
||||
*
|
||||
* We use plain mutable fields (not a `data class` copy) because the tracker grid
|
||||
* is edited cell-by-cell in tight gestures and we want to avoid allocating a new
|
||||
* object on every drag tick.
|
||||
*/
|
||||
class Cell(
|
||||
var note: Int = EMPTY,
|
||||
var velocity: Int = MAX_VELOCITY, // default to full velocity so new notes are audible
|
||||
var channel: Int = 1,
|
||||
) {
|
||||
val isEmpty: Boolean get() = note == EMPTY
|
||||
val isNoteOff: Boolean get() = note == OFF
|
||||
val isPlayable: Boolean get() = note in Pitch.LOWEST..Pitch.HIGHEST
|
||||
|
||||
fun clear() {
|
||||
note = EMPTY
|
||||
velocity = MAX_VELOCITY
|
||||
channel = 1
|
||||
}
|
||||
|
||||
/** A detached copy (for the clipboard) — never aliases the source cell. */
|
||||
fun copyOf(): Cell = Cell(note, velocity, channel)
|
||||
|
||||
/** Overwrite this cell's fields from [other] (paste). */
|
||||
fun setFrom(other: Cell) {
|
||||
note = other.note
|
||||
velocity = other.velocity
|
||||
channel = other.channel
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EMPTY = -1
|
||||
const val OFF = -2
|
||||
/** MIDI velocity ceiling (0x7F). Values are clamped to 0..[MAX_VELOCITY]. */
|
||||
const val MAX_VELOCITY = 0x7F
|
||||
/** MIDI channel ceiling. Channels run 1..[MAX_CHANNEL]. */
|
||||
const val MAX_CHANNEL = 16
|
||||
}
|
||||
}
|
||||
|
||||
/** The four editable columns per track. Only NOTE is pitch; the rest are values. */
|
||||
enum class CellColumn { NOTE, VELOCITY, CHANNEL }
|
||||
|
||||
/**
|
||||
* A pattern (a "block" in arrangement terms): [TRACK_COUNT] parallel tracks, each
|
||||
* a vertical column of [length] cells. Length is one of the current time
|
||||
* signature's allowed values (see [TimeSignature.lengthOptions]).
|
||||
*
|
||||
* The grid is stored as `tracks[trackIndex][lineIndex]`.
|
||||
*/
|
||||
class Pattern(
|
||||
val id: Int,
|
||||
var name: String = "PTN",
|
||||
var length: Int = 16,
|
||||
) {
|
||||
/** tracks[t][line]. Always [TRACK_COUNT] tracks; each list has [length] cells. */
|
||||
val tracks: Array<MutableList<Cell>> =
|
||||
Array(TRACK_COUNT) { MutableList(length) { Cell() } }
|
||||
|
||||
fun cell(track: Int, line: Int): Cell = tracks[track][line]
|
||||
|
||||
/**
|
||||
* Resize the pattern to [newLength], preserving existing cells where possible.
|
||||
* Called when the length dropdown or the time signature changes.
|
||||
*/
|
||||
fun resize(newLength: Int) {
|
||||
if (newLength == length) return
|
||||
for (t in 0 until TRACK_COUNT) {
|
||||
val col = tracks[t]
|
||||
when {
|
||||
newLength > col.size -> repeat(newLength - col.size) { col.add(Cell()) }
|
||||
newLength < col.size -> while (col.size > newLength) col.removeAt(col.size - 1)
|
||||
}
|
||||
}
|
||||
length = newLength
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Fixed by the spec: the tracker upper half always has four tracks. */
|
||||
const val TRACK_COUNT = 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.model
|
||||
|
||||
/**
|
||||
* The whole song in memory. This single object is what gets saved to / loaded
|
||||
* from the `.sng` file format and what the audio engine reads while playing.
|
||||
* Keeping one root object makes serialization and undo/redo straightforward.
|
||||
*
|
||||
* Persistence is done by our own text serializer (see
|
||||
* [com.reactorcoremeltdown.sizzletracker.io.ProjectIo]), not kotlinx, so the
|
||||
* model classes carry no serialization annotations.
|
||||
*/
|
||||
class Project {
|
||||
var name: String = "untitled"
|
||||
|
||||
// ----- Global transport -----
|
||||
var tempoBpm: Float = 120f
|
||||
var timeSignature: TimeSignature = TimeSignature.FOUR_FOUR
|
||||
var scale: Scale = Scale.CHROMATIC
|
||||
/** Root note as a pitch-class 0..11 (0 = C). Used with [scale] for entry. */
|
||||
var rootNote: Int = 0
|
||||
|
||||
// ----- Content -----
|
||||
/**
|
||||
* One pattern/block per arrangement lane. Lane i is tied to block i (the
|
||||
* "sizzletracker CLI" model): enabling a cell on arrangement lane i schedules
|
||||
* block i to play when the playhead reaches that beat. Block id == lane index.
|
||||
*/
|
||||
val patterns: MutableList<Pattern> = MutableList(Arrangement.LANE_COUNT) {
|
||||
// Default to the shortest length for the signature: 16 ticks for 4/4.
|
||||
Pattern(id = it, name = "BLK${it + 1}", length = timeSignature.lengthOptions[0])
|
||||
}
|
||||
val arrangement: Arrangement = Arrangement()
|
||||
val mixer: Mixer = Mixer()
|
||||
/** 16 toolbox slots (tab 3). Index in the list == on-screen slot number. */
|
||||
val toolbox: MutableList<ToolboxSlot> = MutableList(TOOLBOX_SLOTS) { ToolboxSlot(it) }
|
||||
|
||||
fun pattern(id: Int): Pattern? = patterns.firstOrNull { it.id == id }
|
||||
|
||||
/** Which pattern the tracker tab is currently editing. */
|
||||
var activePatternId: Int = 0
|
||||
|
||||
fun activePattern(): Pattern = pattern(activePatternId) ?: patterns.first()
|
||||
|
||||
companion object {
|
||||
const val TOOLBOX_SLOTS = 16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.model
|
||||
|
||||
/**
|
||||
* The third tab is a toolbox of 16 slots. Every slot can hold one instrument OR
|
||||
* one effect. Rather than a separate class per device (which would be a lot of
|
||||
* near-identical code for a newcomer to wade through), we describe each device
|
||||
* type with [ToolboxType] and store its settings as a simple
|
||||
* name -> value map. That map is exactly what we serialize to a human-readable
|
||||
* preset, and it is what the audio engine reads at render time.
|
||||
*
|
||||
* When you add a new instrument or effect you only need to:
|
||||
* 1. add an entry to [ToolboxType] with its default parameters, and
|
||||
* 2. teach the audio engine how to interpret those parameters.
|
||||
* The UI (a generic parameter editor) and preset save/load work automatically.
|
||||
*/
|
||||
|
||||
enum class ToolboxKind { INSTRUMENT, EFFECT }
|
||||
|
||||
/**
|
||||
* A single tunable parameter's description: its key, a friendly label, the value
|
||||
* range and a default. [choices] being non-empty marks an enumerated parameter
|
||||
* (rendered as a dropdown, e.g. a waveform selector) instead of a slider.
|
||||
*/
|
||||
data class ParamSpec(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val default: Float = 0f,
|
||||
val min: Float = 0f,
|
||||
val max: Float = 1f,
|
||||
val choices: List<String> = emptyList(),
|
||||
) {
|
||||
val isEnum: Boolean get() = choices.isNotEmpty()
|
||||
}
|
||||
|
||||
enum class ToolboxType(
|
||||
val kind: ToolboxKind,
|
||||
val displayName: String,
|
||||
val params: List<ParamSpec>,
|
||||
) {
|
||||
// ---------- Instruments ----------
|
||||
NES_SYNTH(
|
||||
ToolboxKind.INSTRUMENT, "NES Synth",
|
||||
listOf(
|
||||
ParamSpec("wave", "Wave", choices = listOf("Pulse12", "Pulse25", "Pulse50", "Triangle", "Noise")),
|
||||
ParamSpec("attack", "Attack", 0.01f, 0f, 1f),
|
||||
ParamSpec("decay", "Decay", 0.15f, 0f, 1f),
|
||||
ParamSpec("sustain", "Sustain", 0.6f, 0f, 1f),
|
||||
ParamSpec("release", "Release", 0.1f, 0f, 1f),
|
||||
),
|
||||
),
|
||||
SAMPLER(
|
||||
ToolboxKind.INSTRUMENT, "Sampler",
|
||||
listOf(
|
||||
ParamSpec("startOctave", "Start Oct", 3f, 0f, 8f),
|
||||
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
||||
ParamSpec("sliceStart", "Slice Start", 0f, 0f, 1f),
|
||||
ParamSpec("sliceEnd", "Slice End", 1f, 0f, 1f),
|
||||
// "samplePath" is stored as a string param at runtime (see ToolboxSlot).
|
||||
),
|
||||
),
|
||||
SOUNDFONT(
|
||||
ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
|
||||
listOf(
|
||||
ParamSpec("program", "Program", 0f, 0f, 127f),
|
||||
ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
|
||||
),
|
||||
),
|
||||
|
||||
// ---------- Effects ----------
|
||||
ARPEGGIATOR(
|
||||
ToolboxKind.EFFECT, "MIDI Arpeggiator",
|
||||
listOf(
|
||||
ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
|
||||
ParamSpec("octaves", "Octaves", 1f, 1f, 4f),
|
||||
ParamSpec("division", "Division", 3f, 0f, 6f), // index into TimeDivision
|
||||
),
|
||||
),
|
||||
TRANSPOSER(
|
||||
ToolboxKind.EFFECT, "MIDI Transposer",
|
||||
listOf(ParamSpec("semitones", "Semitones", 0f, -24f, 24f)),
|
||||
),
|
||||
LFO(
|
||||
ToolboxKind.EFFECT, "MIDI LFO",
|
||||
listOf(
|
||||
ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
|
||||
ParamSpec("phase", "Phase", 0f, 0f, 1f),
|
||||
ParamSpec("depth", "Depth", 0.5f, 0f, 1f),
|
||||
ParamSpec("division", "Division", 3f, 0f, 6f),
|
||||
// "target" (which parameter to modulate) is stored as a string param.
|
||||
),
|
||||
),
|
||||
DELAY(
|
||||
ToolboxKind.EFFECT, "Tape Delay",
|
||||
listOf(
|
||||
ParamSpec("division", "Division", 3f, 0f, 6f),
|
||||
ParamSpec("feedback", "Feedback", 0.4f, 0f, 0.95f),
|
||||
ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f),
|
||||
ParamSpec("heads", "Tape Heads", 4f, 1f, 4f),
|
||||
),
|
||||
),
|
||||
REVERB(
|
||||
ToolboxKind.EFFECT, "Digital Reverb",
|
||||
listOf(
|
||||
ParamSpec("amount", "Amount", 0.3f, 0f, 1f),
|
||||
ParamSpec("tone", "Tone", 0.5f, 0f, 1f),
|
||||
),
|
||||
),
|
||||
FILTER(
|
||||
ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
|
||||
listOf(
|
||||
ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
|
||||
ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
|
||||
ParamSpec("resonance", "Resonance", 0.2f, 0f, 1f),
|
||||
),
|
||||
),
|
||||
EQ10(
|
||||
ToolboxKind.EFFECT, "10-Band EQ",
|
||||
// 10 gain bands, each -1..+1 mapping to roughly -12..+12 dB.
|
||||
(0 until 10).map { ParamSpec("band$it", "Band ${it + 1}", 0f, -1f, 1f) },
|
||||
),
|
||||
BITCRUSHER(
|
||||
ToolboxKind.EFFECT, "Bitcrusher",
|
||||
listOf(
|
||||
ParamSpec("bits", "Bits", 8f, 1f, 16f),
|
||||
ParamSpec("downsample", "Downsample", 1f, 1f, 32f),
|
||||
ParamSpec("drive", "Drive", 0.2f, 0f, 1f),
|
||||
),
|
||||
);
|
||||
|
||||
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
|
||||
|
||||
/** Fresh default parameter map for a new instance of this type. */
|
||||
fun defaultValues(): MutableMap<String, String> =
|
||||
params.associate { spec ->
|
||||
spec.key to if (spec.isEnum) spec.choices[spec.default.toInt().coerceIn(0, spec.choices.lastIndex)]
|
||||
else spec.default.toString()
|
||||
}.toMutableMap()
|
||||
}
|
||||
|
||||
/** Tempo-synced note lengths used by arpeggiator / LFO / delay "division" params. */
|
||||
enum class TimeDivision(val label: String, val beatFraction: Double) {
|
||||
WHOLE("1/1", 4.0), HALF("1/2", 2.0), QUARTER("1/4", 1.0),
|
||||
EIGHTH("1/8", 0.5), SIXTEENTH("1/16", 0.25),
|
||||
THIRTYSECOND("1/32", 0.125), DOTTED_EIGHTH("1/8.", 0.75);
|
||||
|
||||
companion object {
|
||||
fun fromIndex(i: Int): TimeDivision = entries[i.coerceIn(0, entries.lastIndex)]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One occupied (or empty) toolbox slot. [type] == null means the slot is empty.
|
||||
* [values] holds every parameter as a string so the same structure serializes
|
||||
* cleanly to text and round-trips without loss. String-only params such as a
|
||||
* sample file path live in the same map under keys the engine knows about
|
||||
* (e.g. "samplePath", "target").
|
||||
*
|
||||
* (Serialized via [com.reactorcoremeltdown.sizzletracker.io.PresetIo], not
|
||||
* kotlinx, so no @Serializable annotation is needed here.)
|
||||
*/
|
||||
class ToolboxSlot(
|
||||
val index: Int,
|
||||
var type: ToolboxType? = null,
|
||||
var name: String = "",
|
||||
val values: MutableMap<String, String> = mutableMapOf(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = type == null
|
||||
|
||||
fun fill(newType: ToolboxType) {
|
||||
type = newType
|
||||
name = newType.displayName
|
||||
values.clear()
|
||||
values.putAll(newType.defaultValues())
|
||||
}
|
||||
|
||||
fun clearSlot() {
|
||||
type = null
|
||||
name = ""
|
||||
values.clear()
|
||||
}
|
||||
|
||||
// Typed accessors used by the audio engine and UI.
|
||||
fun float(key: String, fallback: Float = 0f): Float = values[key]?.toFloatOrNull() ?: fallback
|
||||
fun string(key: String, fallback: String = ""): String = values[key] ?: fallback
|
||||
fun set(key: String, value: Float) { values[key] = value.toString() }
|
||||
fun set(key: String, value: String) { values[key] = value }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.playback
|
||||
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.SimpleBasePlayer
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
||||
|
||||
/**
|
||||
* Adapts our custom [AudioEngine] to the media3 [Player] interface via
|
||||
* [SimpleBasePlayer], so a [androidx.media3.session.MediaSession] can expose the
|
||||
* transport to the system: lock-screen controls, Bluetooth/headset media buttons,
|
||||
* and the media notification. We only implement play/pause/stop — the tracker
|
||||
* itself is the real transport; this is the "remote control" surface.
|
||||
*
|
||||
* Call [refresh] whenever the engine's transport changes so the reported state
|
||||
* (and therefore the notification) stays in sync with on-screen play/pause.
|
||||
*/
|
||||
@UnstableApi
|
||||
class EnginePlayer(private val engine: AudioEngine) : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||
|
||||
private val mediaItem = MediaItem.Builder()
|
||||
.setMediaId("sizzletracker-song")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("Sizzletracker")
|
||||
.setArtist("Song")
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
override fun getState(): State {
|
||||
val playing = engine.transport.value.isPlaying
|
||||
return State.Builder()
|
||||
.setAvailableCommands(
|
||||
Player.Commands.Builder()
|
||||
.addAll(
|
||||
Player.COMMAND_PLAY_PAUSE,
|
||||
Player.COMMAND_STOP,
|
||||
Player.COMMAND_PREPARE,
|
||||
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||
Player.COMMAND_GET_METADATA,
|
||||
Player.COMMAND_GET_TIMELINE,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.setPlaybackState(Player.STATE_READY)
|
||||
.setPlayWhenReady(playing, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
||||
.setPlaylist(
|
||||
listOf(
|
||||
MediaItemData.Builder("sizzletracker-song")
|
||||
.setMediaItem(mediaItem)
|
||||
.build(),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Publish the latest engine state to any connected media controllers. */
|
||||
fun refresh() = invalidateState()
|
||||
|
||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||
if (playWhenReady) engine.play() else engine.pause()
|
||||
invalidateState()
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
|
||||
override fun handleStop(): ListenableFuture<*> {
|
||||
engine.stop()
|
||||
invalidateState()
|
||||
return Futures.immediateVoidFuture()
|
||||
}
|
||||
|
||||
override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.reactorcoremeltdown.sizzletracker.SizzleApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A media3 [MediaSessionService] that owns the audio engine's lifecycle and hosts
|
||||
* a [MediaSession] wrapping it (via [EnginePlayer]). media3 automatically provides
|
||||
* the media notification, lock-screen controls, and Bluetooth/headset media-button
|
||||
* handling from the session — so the same transport the on-screen buttons drive is
|
||||
* also controllable from outside the app, and playback continues in the background.
|
||||
*/
|
||||
@UnstableApi
|
||||
class PlaybackService : MediaSessionService() {
|
||||
|
||||
private val engine get() = (application as SizzleApp).audioEngine
|
||||
private var session: MediaSession? = null
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
engine.start()
|
||||
val player = EnginePlayer(engine)
|
||||
session = MediaSession.Builder(this, player).build()
|
||||
// Keep the session's reported state in sync with the engine (the on-screen
|
||||
// Play/Pause talks to the engine directly, so we mirror it here).
|
||||
scope.launch { engine.transport.collect { player.refresh() } }
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
session?.run { player.release(); release() }
|
||||
session = null
|
||||
engine.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Start the service from a foreground context (safe: plain startService,
|
||||
* MediaSessionService promotes itself to foreground when playback begins). */
|
||||
fun start(context: Context) {
|
||||
context.startService(Intent(context, PlaybackService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Widgets
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.mixer.MixerScreen
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.settings.SettingsScreen
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.toolbox.ToolboxScreen
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.tracker.TrackerScreen
|
||||
|
||||
/**
|
||||
* The top-level layout: the active tab's screen fills the space above a standard
|
||||
* Material 3 [NavigationBar]. The pattern grid / mixer still use the bespoke retro
|
||||
* look, but chrome (this bar and the Settings tab) uses stock Material components
|
||||
* for familiar, accessible interaction.
|
||||
*/
|
||||
private data class TabSpec(val title: String, val icon: ImageVector)
|
||||
|
||||
private val TABS = listOf(
|
||||
TabSpec("Tracker", Icons.Filled.GridView),
|
||||
TabSpec("Mix", Icons.Filled.Tune),
|
||||
TabSpec("Toolbox", Icons.Filled.Widgets),
|
||||
TabSpec("Setup", Icons.Filled.Settings),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SizzleApp(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
// ----- Active screen -----
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (vm.selectedTab) {
|
||||
0 -> TrackerScreen(vm)
|
||||
1 -> MixerScreen(vm)
|
||||
2 -> ToolboxScreen(vm)
|
||||
3 -> SettingsScreen(vm)
|
||||
}
|
||||
}
|
||||
// ----- Standard Material navigation bar -----
|
||||
NavigationBar(containerColor = c.surface) {
|
||||
TABS.forEachIndexed { index, tab ->
|
||||
NavigationBarItem(
|
||||
selected = vm.selectedTab == index,
|
||||
onClick = { vm.selectTab(index) },
|
||||
icon = { Icon(tab.icon, contentDescription = tab.title) },
|
||||
label = { Text(tab.title) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = c.background,
|
||||
selectedTextColor = c.accent,
|
||||
indicatorColor = c.accent,
|
||||
unselectedIconColor = c.textDim,
|
||||
unselectedTextColor = c.textDim,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.reactorcoremeltdown.sizzletracker.audio.AudioEngine
|
||||
import com.reactorcoremeltdown.sizzletracker.input.InputAction
|
||||
import com.reactorcoremeltdown.sizzletracker.input.InputRouter
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||
import com.reactorcoremeltdown.sizzletracker.model.CellColumn
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The single screen-facing state holder. It sits between the UI (Compose) and
|
||||
* the model/engine, and it is the ONE place that reacts to [InputAction]s. Touch
|
||||
* gestures call its methods directly; keyboard/gamepad/MIDI reach it through the
|
||||
* [InputRouter] flow collected in [init]. Either way the same code runs, which is
|
||||
* how all four input methods stay equally capable.
|
||||
*
|
||||
* Recomposition trick: the model classes are plain (non-observable) for audio
|
||||
* performance, so after we mutate the grid we bump [revision]. Compose reads that
|
||||
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
|
||||
*/
|
||||
class AppViewModel(
|
||||
val project: Project,
|
||||
private val engine: AudioEngine,
|
||||
private val router: InputRouter,
|
||||
private val gamepadInput: com.reactorcoremeltdown.sizzletracker.input.GamepadInput,
|
||||
private val midiInput: com.reactorcoremeltdown.sizzletracker.input.MidiInput,
|
||||
) : ViewModel() {
|
||||
|
||||
// ----- Observable UI state -----
|
||||
var selectedTab by mutableIntStateOf(0); private set
|
||||
var revision by mutableIntStateOf(0); private set // bump to force grid redraw
|
||||
/** Active colour theme; read by [MainActivity] to drive SizzleTheme. */
|
||||
var palette by mutableStateOf(com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette.AMBER)
|
||||
|
||||
// Tracker cursor position.
|
||||
var cursorTrack by mutableIntStateOf(0); private set
|
||||
var cursorLine by mutableIntStateOf(0); private set
|
||||
var cursorColumn by mutableStateOf(CellColumn.NOTE); private set
|
||||
|
||||
// ----- Selection & clipboard (tracker edit ops) -----
|
||||
/** When on, the cursor drags out a rectangular selection from [selAnchorTrack]/
|
||||
* [selAnchorLine] to the live cursor; edit ops act on that rectangle. */
|
||||
var selectMode by mutableStateOf(false); private set
|
||||
var selAnchorTrack by mutableIntStateOf(-1); private set
|
||||
var selAnchorLine by mutableIntStateOf(-1); private set
|
||||
/** Copied cells, indexed [trackOffset][lineOffset]; null until first copy/cut. */
|
||||
private var clipboard: Array<Array<Cell>>? = null
|
||||
val hasSelection: Boolean get() = selectMode && selAnchorTrack >= 0
|
||||
val canPaste: Boolean get() = clipboard != null
|
||||
|
||||
// ----- Entry memory -----
|
||||
/** The last note & channel actually entered (by any input). Keyboard/gamepad
|
||||
* edits on an empty tick resume from these so entry continues where you left
|
||||
* off instead of jumping to a fixed default. */
|
||||
var lastNote by mutableIntStateOf(Pitch.MIDDLE_C); private set
|
||||
var lastChannel by mutableIntStateOf(1); private set
|
||||
|
||||
/** Live transport snapshot from the audio thread (drives the playhead). */
|
||||
val transport get() = engine.transport
|
||||
|
||||
// ----- Binding-learn state (Settings tab) -----
|
||||
/** The action id currently waiting to be bound, or null. */
|
||||
var learnTarget by mutableStateOf<String?>(null); private set
|
||||
private var learnSource: com.reactorcoremeltdown.sizzletracker.input.InputSource? = null
|
||||
|
||||
init {
|
||||
// Collect neutral input actions from every non-touch device.
|
||||
viewModelScope.launch {
|
||||
router.actions.collect(::onAction)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- tab control
|
||||
fun selectTab(index: Int) { selectedTab = index.coerceIn(0, 3) }
|
||||
|
||||
// ----------------------------------------------------------- cursor & edit
|
||||
fun focusCell(track: Int, line: Int, column: CellColumn) {
|
||||
val p = project.activePattern()
|
||||
cursorTrack = track.coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||
cursorLine = line.coerceIn(0, p.length - 1)
|
||||
cursorColumn = column
|
||||
}
|
||||
|
||||
private fun focusedCell(): Cell =
|
||||
project.activePattern().cell(cursorTrack, cursorLine)
|
||||
|
||||
fun moveCursor(dTrack: Int, dLine: Int) {
|
||||
val p = project.activePattern()
|
||||
cursorTrack = (cursorTrack + dTrack).coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||
cursorLine = (cursorLine + dLine).coerceIn(0, p.length - 1)
|
||||
}
|
||||
|
||||
fun nextColumn() { cursorColumn = nextOf(cursorColumn, +1) }
|
||||
fun prevColumn() { cursorColumn = nextOf(cursorColumn, -1) }
|
||||
|
||||
/**
|
||||
* The core value-editing operation, shared by drag gestures and +/- input.
|
||||
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
||||
*/
|
||||
fun editFocused(direction: Int) {
|
||||
val cell = focusedCell()
|
||||
when (cursorColumn) {
|
||||
CellColumn.NOTE -> {
|
||||
// Seed an empty tick from the last note entered so filling resumes
|
||||
// where you left off rather than at a fixed default.
|
||||
val seed = if (cell.isPlayable) cell.note else lastNote
|
||||
cell.note = project.scale.step(seed, project.rootNote, direction)
|
||||
lastNote = cell.note
|
||||
}
|
||||
CellColumn.VELOCITY -> cell.velocity = (cell.velocity + direction).coerceIn(0, Cell.MAX_VELOCITY)
|
||||
CellColumn.CHANNEL -> {
|
||||
// Likewise seed an empty tick's channel from the last one entered.
|
||||
val seed = if (cell.isEmpty) lastChannel else cell.channel
|
||||
cell.channel = (seed + direction).coerceIn(1, Cell.MAX_CHANNEL)
|
||||
lastChannel = cell.channel
|
||||
}
|
||||
}
|
||||
touched()
|
||||
}
|
||||
|
||||
fun clearFocused() { focusedCell().clear(); touched() }
|
||||
fun noteOffFocused() { focusedCell().note = Cell.OFF; touched() }
|
||||
|
||||
/** The cell under the cursor — for editor popups to read/display live values. */
|
||||
fun cellUnderCursor(): Cell = project.activePattern().cell(cursorTrack, cursorLine)
|
||||
|
||||
/** Set the focused cell's note directly (piano popup / live entry). Remembers
|
||||
* it as the last note so subsequent nudges resume from here. */
|
||||
fun setFocusedNote(midi: Int) {
|
||||
val n = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
focusedCell().note = n
|
||||
lastNote = n
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Set the focused cell's channel directly, remembering it. */
|
||||
fun setFocusedChannel(channel: Int) {
|
||||
val ch = channel.coerceIn(1, Cell.MAX_CHANNEL)
|
||||
focusedCell().channel = ch
|
||||
lastChannel = ch
|
||||
touched()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- selection & clipboard
|
||||
/** The selected track range (inclusive). Anchor..cursor, or just the cursor
|
||||
* cell when there is no active selection. */
|
||||
val selTrackRange: IntRange
|
||||
get() = if (hasSelection) minOf(selAnchorTrack, cursorTrack)..maxOf(selAnchorTrack, cursorTrack)
|
||||
else cursorTrack..cursorTrack
|
||||
val selLineRange: IntRange
|
||||
get() = if (hasSelection) minOf(selAnchorLine, cursorLine)..maxOf(selAnchorLine, cursorLine)
|
||||
else cursorLine..cursorLine
|
||||
|
||||
/** Toggle select mode. Turning it on anchors the selection at the cursor;
|
||||
* turning it off clears the selection (the clipboard is untouched). */
|
||||
fun toggleSelectMode() {
|
||||
selectMode = !selectMode
|
||||
if (selectMode) { selAnchorTrack = cursorTrack; selAnchorLine = cursorLine }
|
||||
else { selAnchorTrack = -1; selAnchorLine = -1 }
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Copy the selection (or, with none, the focused cell) into the clipboard. */
|
||||
fun copySelection() {
|
||||
val p = project.activePattern()
|
||||
val tr = selTrackRange; val lr = selLineRange
|
||||
clipboard = Array(tr.count()) { ti ->
|
||||
Array(lr.count()) { li -> p.cell(tr.first + ti, lr.first + li).copyOf() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the selected cells (or the focused cell when nothing is selected). */
|
||||
fun deleteSelection() {
|
||||
val p = project.activePattern()
|
||||
for (t in selTrackRange) for (l in selLineRange) p.cell(t, l).clear()
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Copy then clear — a standard cut. */
|
||||
fun cutSelection() { copySelection(); deleteSelection() }
|
||||
|
||||
/** Paste the clipboard with its top-left at the cursor, clipped to the grid. */
|
||||
fun pasteClipboard() {
|
||||
val cb = clipboard ?: return
|
||||
val p = project.activePattern()
|
||||
for (ti in cb.indices) for (li in cb[ti].indices) {
|
||||
val t = cursorTrack + ti
|
||||
val l = cursorLine + li
|
||||
if (t < Pattern.TRACK_COUNT && l < p.length) p.cell(t, l).setFrom(cb[ti][li])
|
||||
}
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Which block (0..7) the tracker is editing. Lane i is tied to block i. */
|
||||
val activeBlock: Int get() = project.activePatternId
|
||||
fun setActiveBlock(i: Int) {
|
||||
project.activePatternId = i.coerceIn(0, project.patterns.size - 1)
|
||||
cursorLine = cursorLine.coerceIn(0, project.activePattern().length - 1)
|
||||
touched()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- transport control
|
||||
fun playPause() {
|
||||
val t = transport.value
|
||||
if (t.isPlaying) engine.pause() else engine.play()
|
||||
}
|
||||
|
||||
fun stop() = engine.stop()
|
||||
|
||||
// --------------------------------------------- project-level setting edits
|
||||
fun setTempo(bpm: Float) { project.tempoBpm = bpm.coerceIn(20f, 300f); touched() }
|
||||
|
||||
fun setTimeSignature(sig: TimeSignature) {
|
||||
project.timeSignature = sig
|
||||
// Snap pattern length to the shortest option for the new signature.
|
||||
val p = project.activePattern()
|
||||
if (p.length !in sig.lengthOptions) p.resize(sig.lengthOptions[0])
|
||||
cursorLine = cursorLine.coerceIn(0, p.length - 1)
|
||||
touched()
|
||||
}
|
||||
|
||||
fun setPatternLength(len: Int) {
|
||||
project.activePattern().resize(len)
|
||||
cursorLine = cursorLine.coerceIn(0, len - 1)
|
||||
touched()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- input action router
|
||||
/** The single dispatch point for keyboard / gamepad / MIDI actions. */
|
||||
private fun onAction(action: InputAction) {
|
||||
when (action) {
|
||||
InputAction.NavUp -> moveCursor(0, -1)
|
||||
InputAction.NavDown -> moveCursor(0, +1)
|
||||
InputAction.NavLeft -> moveCursor(-1, 0)
|
||||
InputAction.NavRight -> moveCursor(+1, 0)
|
||||
InputAction.NextColumn -> nextColumn()
|
||||
InputAction.PrevColumn -> prevColumn()
|
||||
is InputAction.Increment -> editFocused(action.amount)
|
||||
is InputAction.Decrement -> editFocused(-action.amount)
|
||||
InputAction.ClearCell -> clearFocused()
|
||||
InputAction.NoteOffCell -> noteOffFocused()
|
||||
is InputAction.NoteOn -> {
|
||||
engine.liveNoteOn(action.pitch, action.velocity)
|
||||
// Live entry also writes the note into the focused NOTE cell.
|
||||
if (cursorColumn == CellColumn.NOTE) {
|
||||
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
|
||||
}
|
||||
}
|
||||
is InputAction.NoteOff -> engine.liveNoteOff(action.pitch)
|
||||
InputAction.PlayPause -> playPause()
|
||||
InputAction.Stop -> stop()
|
||||
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
|
||||
is InputAction.SelectTab -> selectTab(action.index)
|
||||
InputAction.NextTab -> selectTab(selectedTab + 1)
|
||||
InputAction.PrevTab -> selectTab(selectedTab - 1)
|
||||
is InputAction.RawControl -> onRawControl(action)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- binding learn
|
||||
/** Arm MIDI-learn / gamepad-rebind: the next matching control is bound to
|
||||
* [actionId]. [source] restricts which device kind will be captured. */
|
||||
fun beginLearn(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
|
||||
learnTarget = actionId
|
||||
learnSource = source
|
||||
router.learnMode = true
|
||||
}
|
||||
|
||||
fun cancelLearn() {
|
||||
learnTarget = null
|
||||
learnSource = null
|
||||
router.learnMode = false
|
||||
}
|
||||
|
||||
/** An unbound control arrived. If we're learning and it matches the armed
|
||||
* source, store the binding into the right handler's map. */
|
||||
private fun onRawControl(action: InputAction.RawControl) {
|
||||
val target = learnTarget ?: return
|
||||
if (action.source != learnSource) return
|
||||
when (action.source) {
|
||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
||||
midiInput.ccBindings[action.code] = target
|
||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
|
||||
gamepadInput.bindings[action.code] = target
|
||||
else -> {}
|
||||
}
|
||||
cancelLearn()
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Current MIDI CC number bound to [actionId], or null. */
|
||||
fun midiBindingFor(actionId: String): Int? =
|
||||
midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key
|
||||
|
||||
/** Current gamepad key code bound to [actionId], or null. */
|
||||
fun gamepadBindingFor(actionId: String): Int? =
|
||||
gamepadInput.bindings.entries.firstOrNull { it.value == actionId }?.key
|
||||
|
||||
fun clearBinding(actionId: String, source: com.reactorcoremeltdown.sizzletracker.input.InputSource) {
|
||||
when (source) {
|
||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.MIDI ->
|
||||
midiInput.ccBindings.entries.removeAll { it.value == actionId }
|
||||
com.reactorcoremeltdown.sizzletracker.input.InputSource.GAMEPAD ->
|
||||
gamepadInput.bindings.entries.removeAll { it.value == actionId }
|
||||
else -> {}
|
||||
}
|
||||
touched()
|
||||
}
|
||||
|
||||
private fun touched() { revision++ }
|
||||
|
||||
/** Public hook so screens that mutate the model directly (e.g. dropdowns in
|
||||
* the toolbar, mixer faders) can request a redraw. */
|
||||
fun bumpForToolbar() = touched()
|
||||
|
||||
/** Call after changing mixer FX routing so the audio engine rebuilds the
|
||||
* per-channel insert chains without waiting for the next Play. */
|
||||
fun rebuildAudioRouting() {
|
||||
engine.rebuildFxChains()
|
||||
touched()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- sampler
|
||||
private val recorder = com.reactorcoremeltdown.sizzletracker.audio.SampleRecorder()
|
||||
var isRecording by mutableStateOf(false); private set
|
||||
|
||||
/** Store already-decoded sample bytes into a slot (from a WAV file import). */
|
||||
fun loadSampleInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, id: String, bytes: ByteArray): Boolean {
|
||||
val sample = com.reactorcoremeltdown.sizzletracker.audio.SampleStore.decodeWav(bytes) ?: return false
|
||||
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
||||
slot.set("samplePath", id)
|
||||
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
|
||||
touched()
|
||||
return true
|
||||
}
|
||||
|
||||
fun startRecording() { if (recorder.start()) isRecording = true }
|
||||
|
||||
// ------------------------------------------------------- audio device routing
|
||||
/** Ids of the chosen output/input devices (for the Settings UI), null = default. */
|
||||
var outputDeviceId by mutableStateOf<Int?>(null); private set
|
||||
var inputDeviceId by mutableStateOf<Int?>(null); private set
|
||||
|
||||
fun setAudioOutput(device: android.media.AudioDeviceInfo?) {
|
||||
engine.setPreferredOutput(device)
|
||||
outputDeviceId = device?.id
|
||||
}
|
||||
|
||||
fun setAudioInput(device: android.media.AudioDeviceInfo?) {
|
||||
recorder.preferredInput = device
|
||||
inputDeviceId = device?.id
|
||||
}
|
||||
|
||||
/** Whether the native Oboe output backend is selected. */
|
||||
var nativeEngine by mutableStateOf(false); private set
|
||||
val nativeEngineAvailable: Boolean
|
||||
get() = com.reactorcoremeltdown.sizzletracker.audio.NativeAudioBridge.isAvailable()
|
||||
|
||||
fun applyNativeEngine(enabled: Boolean) {
|
||||
engine.setNativeOutput(enabled)
|
||||
nativeEngine = engine.useNativeOutput
|
||||
}
|
||||
|
||||
/** Stop recording and load the captured audio into [slot]. */
|
||||
fun stopRecordingInto(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot) {
|
||||
val sample = recorder.stop()
|
||||
isRecording = false
|
||||
if (sample != null) {
|
||||
val id = "rec-" + System.currentTimeMillis()
|
||||
com.reactorcoremeltdown.sizzletracker.audio.SampleStore.put(id, sample)
|
||||
slot.set("samplePath", id)
|
||||
slot.set("sliceStart", 0f); slot.set("sliceEnd", 1f)
|
||||
touched()
|
||||
}
|
||||
}
|
||||
|
||||
/** Point an LFO slot at another device's parameter, capturing that param's
|
||||
* current value as the modulation centre. Pass targetSlotIndex < 0 to clear. */
|
||||
fun setLfoTarget(
|
||||
lfoSlot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot,
|
||||
targetSlotIndex: Int,
|
||||
key: String,
|
||||
) {
|
||||
if (targetSlotIndex < 0) {
|
||||
lfoSlot.set("target", "")
|
||||
} else {
|
||||
val tSlot = project.toolbox.getOrNull(targetSlotIndex)
|
||||
val spec = tSlot?.type?.params?.firstOrNull { it.key == key }
|
||||
lfoSlot.set("target", "$targetSlotIndex:$key")
|
||||
lfoSlot.set("center", tSlot?.float(key, spec?.default ?: 0f) ?: 0f)
|
||||
}
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Preview a note of the Sampler slot's loaded sample (one-shot, editor use). */
|
||||
fun auditionSample(slot: com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot, midi: Int) {
|
||||
engine.auditionSlotOn(slot.index, midi)
|
||||
}
|
||||
|
||||
/** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
|
||||
fun auditionOn(slotIndex: Int, midi: Int) = engine.auditionSlotOn(slotIndex, midi)
|
||||
fun auditionOff(midi: Int) = engine.auditionSlotOff(midi)
|
||||
|
||||
// ------------------------------------------------------------ project I/O
|
||||
/** Serialize the current song to `.sng` text. */
|
||||
fun saveProjectText(): String =
|
||||
com.reactorcoremeltdown.sizzletracker.io.ProjectIo.save(project)
|
||||
|
||||
/** Replace the current song's contents from our full internal text (in place). */
|
||||
fun loadProjectText(text: String) {
|
||||
com.reactorcoremeltdown.sizzletracker.io.ProjectIo.loadInto(project, text)
|
||||
cursorLine = 0; cursorTrack = 0
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Export to the reference desktop sizzletracker `.sng` format (musical data). */
|
||||
fun exportSng(): String =
|
||||
com.reactorcoremeltdown.sizzletracker.io.SngFormat.export(project)
|
||||
|
||||
/** Import a reference `.sng` file (in place). */
|
||||
fun importSng(text: String) {
|
||||
com.reactorcoremeltdown.sizzletracker.io.SngFormat.importInto(project, text)
|
||||
cursorLine = 0; cursorTrack = 0
|
||||
touched()
|
||||
}
|
||||
|
||||
private fun nextOf(c: CellColumn, dir: Int): CellColumn {
|
||||
val values = CellColumn.entries
|
||||
return values[(c.ordinal + dir + values.size) % values.size]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.components
|
||||
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
||||
/**
|
||||
* Lays out each distinct (text, style) pair exactly once and reuses the result on
|
||||
* every later frame. The Canvas grids draw the same tiny set of fixed-width strings
|
||||
* — note names, hex velocities, lane/bar numbers — dozens of times per frame, and
|
||||
* re-measuring them every frame was the main cost that made the playhead feel
|
||||
* sluggish. [TextMeasurer]'s own cache is a small LRU (8 entries) that a full grid
|
||||
* blows past immediately, so we keep our own unbounded, allocation-free cache.
|
||||
*
|
||||
* Styles are compared by identity (===): callers pass a fixed handful of remembered
|
||||
* [TextStyle] instances, so a short linear scan avoids hashing a whole TextStyle per
|
||||
* cell. Not thread-safe — only ever touched from the draw phase.
|
||||
*/
|
||||
class GlyphCache(private val measurer: TextMeasurer) {
|
||||
private val styles = ArrayList<TextStyle>(6)
|
||||
private val byStyle = ArrayList<HashMap<String, TextLayoutResult>>(6)
|
||||
|
||||
fun measure(text: String, style: TextStyle): TextLayoutResult {
|
||||
var idx = styles.indexOfFirst { it === style }
|
||||
if (idx < 0) {
|
||||
styles.add(style)
|
||||
byStyle.add(HashMap())
|
||||
idx = styles.size - 1
|
||||
}
|
||||
return byStyle[idx].getOrPut(text) { measurer.measure(text, style) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* A tiny library of "pixel" UI building blocks. They are intentionally plain —
|
||||
* hard rectangles, monospace text, 1px borders — so they are cheap to draw
|
||||
* (helping the "lightweight UI" requirement) and match the retro aesthetic.
|
||||
* Reusing these keeps every screen visually consistent.
|
||||
*/
|
||||
|
||||
/** A flat, square-cornered button. [active] paints it in the accent colour. */
|
||||
@Composable
|
||||
fun RetroButton(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
active: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
Box(
|
||||
modifier
|
||||
.border(1.dp, if (active) c.accent else c.grid, RectangleShape)
|
||||
.background(if (active) c.accent.copy(alpha = 0.18f) else c.surface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (active) c.accent else c.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A labelled dropdown selector rendered as `LABEL:value ▾`. */
|
||||
@Composable
|
||||
fun <T> RetroDropdown(
|
||||
label: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
modifier: Modifier = Modifier,
|
||||
optionLabel: (T) -> String = { it.toString() },
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box(modifier) {
|
||||
Box(
|
||||
Modifier
|
||||
.border(1.dp, c.grid, RectangleShape)
|
||||
.background(c.surface)
|
||||
.clickable { open = true }
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
"$label:${optionLabel(selected)} ▾",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
options.forEach { opt ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(optionLabel(opt), fontFamily = FontFamily.Monospace) },
|
||||
onClick = { onSelect(opt); open = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A dim, spaced section header, e.g. "── TRANSPORT ──". */
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text.uppercase(),
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
color = LocalRetro.current.textDim,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/** A grid of pixel "cells" used as a lightweight icon (e.g. play/stop glyphs).
|
||||
* [rows] are strings of '#' (filled) and ' ' (empty). */
|
||||
@Composable
|
||||
fun PixelGlyph(rows: List<String>, color: Color, cell: Int = 3) {
|
||||
Column {
|
||||
rows.forEach { line ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
line.forEach { ch ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(cell.dp)
|
||||
.background(if (ch == '#') color else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.mixer
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Mixer
|
||||
import com.reactorcoremeltdown.sizzletracker.model.MixerChannel
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.SectionLabel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The second tab: a four-channel mixer, one strip per tracker track. Each strip
|
||||
* chooses an instrument + up to four effects (all drawn from the toolbox), sets a
|
||||
* MIDI channel, and has a volume fader plus mute/solo. Everything writes straight
|
||||
* into the shared [Mixer] model that the audio engine reads.
|
||||
*/
|
||||
@Composable
|
||||
fun MixerScreen(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||
Row(Modifier.fillMaxSize().background(c.background).padding(6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
vm.project.mixer.channels.forEach { channel ->
|
||||
ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh
|
||||
val toolbox = vm.project.toolbox
|
||||
|
||||
// Toolbox slots that hold an instrument / an effect, as (index -> label).
|
||||
val instrumentSlots = toolbox.filter { it.type?.kind == ToolboxKind.INSTRUMENT }
|
||||
val effectSlots = toolbox.filter { it.type?.kind == ToolboxKind.EFFECT }
|
||||
|
||||
Column(
|
||||
modifier.background(c.surface).padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("CH ${channel.index + 1}", color = c.accent,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||
|
||||
SectionLabel("Instrument")
|
||||
RetroDropdown(
|
||||
label = "INS",
|
||||
options = listOf(-1) + instrumentSlots.map { it.index },
|
||||
selected = channel.instrumentSlot,
|
||||
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
||||
onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() },
|
||||
)
|
||||
|
||||
SectionLabel("FX 1-4")
|
||||
for (fx in 0 until Mixer.FX_SLOTS) {
|
||||
RetroDropdown(
|
||||
label = "FX${fx + 1}",
|
||||
options = listOf(-1) + effectSlots.map { it.index },
|
||||
selected = channel.fxSlots[fx],
|
||||
optionLabel = { idx -> if (idx < 0) "—" else "${idx + 1}:${toolbox[idx].name}" },
|
||||
onSelect = { channel.fxSlots[fx] = it; vm.rebuildAudioRouting() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
SectionLabel("MIDI Ch")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(1); vm.bumpForToolbar() }
|
||||
Text(" ${channel.midiChannel} ", color = c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() }
|
||||
}
|
||||
|
||||
SectionLabel("Volume")
|
||||
// Vertical fader fills the remaining strip height.
|
||||
VerticalFader(
|
||||
value = channel.volume,
|
||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
RetroButton("M", active = channel.mute, modifier = Modifier.weight(1f)) {
|
||||
channel.mute = !channel.mute; vm.bumpForToolbar()
|
||||
}
|
||||
RetroButton("S", active = channel.solo, modifier = Modifier.weight(1f)) {
|
||||
channel.solo = !channel.solo; vm.bumpForToolbar()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A vertical volume fader: fill grows from the bottom; tap or drag to set. */
|
||||
@Composable
|
||||
private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
Box(
|
||||
modifier
|
||||
.border(1.dp, c.grid, RectangleShape)
|
||||
.background(c.background)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { off -> onValueChange((1f - off.y / size.height).coerceIn(0f, 1f)) }
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures { change, _ ->
|
||||
change.consume()
|
||||
onValueChange((1f - change.position.y / size.height).coerceIn(0f, 1f))
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(value.coerceIn(0.001f, 1f))
|
||||
.background(c.accent.copy(alpha = 0.30f)),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().height(3.dp).background(c.accent)) // thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Piano
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.reactorcoremeltdown.sizzletracker.input.InputSource
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.RetroPalette
|
||||
|
||||
/**
|
||||
* The fourth tab, built from STANDARD Material 3 components (Cards, Buttons,
|
||||
* ListItems, OutlinedTextField, FilterChips) rather than the bespoke retro grid,
|
||||
* for familiar and accessible interaction. Functionality is unchanged:
|
||||
* project save/load, theme selection + export, gamepad bindings, and a
|
||||
* searchable / collapsible MIDI binding list.
|
||||
*/
|
||||
private enum class SettingsSection { MAIN, GAMEPAD, MIDI }
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(vm: AppViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
var section by remember { mutableStateOf(SettingsSection.MAIN) }
|
||||
|
||||
when (section) {
|
||||
// Top level stays light: the heavy binding lists live in their own
|
||||
// subsections, so they only compose when the user actually opens them.
|
||||
SettingsSection.MAIN -> LazyColumn(
|
||||
Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { ProjectCard(vm) }
|
||||
item { AudioDevicesCard(vm) }
|
||||
item { ThemeCard(vm) }
|
||||
item {
|
||||
NavCard("Gamepad Bindings", "Rebind buttons", Icons.Filled.SportsEsports) {
|
||||
section = SettingsSection.GAMEPAD
|
||||
}
|
||||
}
|
||||
item {
|
||||
NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
|
||||
section = SettingsSection.MIDI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection.GAMEPAD -> LazyColumn(
|
||||
Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
|
||||
item {
|
||||
Text("Tap Learn, then press a button to bind it.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
items(BIND_MODULES.values.flatten()) { action ->
|
||||
BindingRow(vm, action, InputSource.GAMEPAD)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection.MIDI -> LazyColumn(
|
||||
Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item { SubHeader("MIDI Bindings") { section = SettingsSection.MAIN } }
|
||||
item { MidiBindingSections(vm) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Header row with a back arrow for a settings subsection. */
|
||||
@Composable
|
||||
private fun SubHeader(title: String, onBack: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable card that navigates into a settings subsection. */
|
||||
@Composable
|
||||
private fun NavCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
|
||||
ElevatedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
ListItem(
|
||||
leadingContent = { Icon(icon, null) },
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = { Text(subtitle) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- top-level cards
|
||||
|
||||
@Composable
|
||||
private fun ProjectCard(vm: AppViewModel) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
SettingsCard("Project", subtitle = "Interchange with desktop sizzletracker (.sng), or full save") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { clipboard.setText(AnnotatedString(vm.exportSng())) }) {
|
||||
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||
Text("Export .sng")
|
||||
}
|
||||
OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::importSng) }) {
|
||||
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
|
||||
Text("Import .sng")
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { clipboard.setText(AnnotatedString(vm.saveProjectText())) }) { Text("Save full") }
|
||||
OutlinedButton(onClick = { clipboard.getText()?.text?.let(vm::loadProjectText) }) { Text("Load full") }
|
||||
}
|
||||
Text(
|
||||
".sng carries notes/blocks/arrangement (desktop-compatible); full save also keeps mixer, FX and instruments.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioDevicesCard(vm: AppViewModel) {
|
||||
SettingsCard("Audio Devices", subtitle = "Route playback / recording to USB or built-in") {
|
||||
val context = LocalContext.current
|
||||
val am = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
val outputs = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
|
||||
val inputs = am.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
|
||||
DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
|
||||
HorizontalDivider()
|
||||
DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Low-latency engine (Oboe)", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
if (vm.nativeEngineAvailable) "Native AAudio output" else "Native library unavailable",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = vm.nativeEngine,
|
||||
enabled = vm.nativeEngineAvailable,
|
||||
onCheckedChange = { vm.applyNativeEngine(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeCard(vm: AppViewModel) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
SettingsCard("Color Theme", subtitle = "Pick a palette or export it as text") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RetroPalette.ALL.forEach { p ->
|
||||
FilterChip(
|
||||
selected = vm.palette.name == p.name,
|
||||
onClick = { vm.palette = p },
|
||||
label = { Text(p.name) },
|
||||
leadingIcon = { Icon(Icons.Filled.Palette, null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(exportTheme(vm.palette))) },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) { Text("Export theme") }
|
||||
}
|
||||
}
|
||||
|
||||
/** A bindable action: user-facing label + the action id the input handlers use. */
|
||||
private data class BindAction(val label: String, val id: String)
|
||||
|
||||
private val BIND_MODULES: Map<String, List<BindAction>> = linkedMapOf(
|
||||
"Transport" to listOf(
|
||||
BindAction("Play / Pause", "playPause"), BindAction("Stop", "stop"), BindAction("Toggle Loop", "loop"),
|
||||
),
|
||||
"Navigation" to listOf(
|
||||
BindAction("Cursor Up", "up"), BindAction("Cursor Down", "down"),
|
||||
BindAction("Cursor Left", "left"), BindAction("Cursor Right", "right"),
|
||||
),
|
||||
"Editing" to listOf(
|
||||
BindAction("Value +", "increment"), BindAction("Value -", "decrement"), BindAction("Clear Cell", "clear"),
|
||||
),
|
||||
"Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
|
||||
)
|
||||
|
||||
/** One action row with its current binding, a Learn toggle, and a clear button. */
|
||||
@Composable
|
||||
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
|
||||
val listening = vm.learnTarget == action.id
|
||||
val code = if (source == InputSource.MIDI) vm.midiBindingFor(action.id) else vm.gamepadBindingFor(action.id)
|
||||
val label = code?.let { (if (source == InputSource.MIDI) "CC $it" else "key $it") } ?: "unbound"
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(action.label) },
|
||||
supportingContent = { Text(label, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { if (listening) vm.cancelLearn() else vm.beginLearn(action.id, source) }) {
|
||||
Text(if (listening) "Listening…" else "Learn")
|
||||
}
|
||||
if (code != null) {
|
||||
TextButton(onClick = { vm.clearBinding(action.id, source) }) { Text("✕") }
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
/** A label + dropdown that routes audio to a chosen device (or the default). */
|
||||
@Composable
|
||||
private fun DeviceRow(
|
||||
label: String,
|
||||
devices: List<AudioDeviceInfo>,
|
||||
selectedId: Int?,
|
||||
onSelect: (AudioDeviceInfo?) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val current = devices.firstOrNull { it.id == selectedId }?.let { deviceLabel(it) } ?: "Default"
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Box {
|
||||
OutlinedButton(onClick = { open = true }) { Text(current, maxLines = 1) }
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
DropdownMenuItem(text = { Text("Default") }, onClick = { onSelect(null); open = false })
|
||||
devices.forEach { d ->
|
||||
DropdownMenuItem(text = { Text(deviceLabel(d)) }, onClick = { onSelect(d); open = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deviceLabel(d: AudioDeviceInfo): String {
|
||||
val kind = when (d.type) {
|
||||
AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB"
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
|
||||
AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Mic"
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BT"
|
||||
else -> "Dev"
|
||||
}
|
||||
return "${d.productName} · $kind"
|
||||
}
|
||||
|
||||
/** A titled Material card wrapper used for every settings group. */
|
||||
@Composable
|
||||
private fun SettingsCard(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportTheme(p: RetroPalette): String = buildString {
|
||||
appendLine("SIZZLE-THEME 1")
|
||||
appendLine("NAME=${p.name}")
|
||||
fun hex(color: Color) =
|
||||
"#%02X%02X%02X".format((color.red * 255).toInt(), (color.green * 255).toInt(), (color.blue * 255).toInt())
|
||||
appendLine("background=${hex(p.background)}")
|
||||
appendLine("surface=${hex(p.surface)}")
|
||||
appendLine("accent=${hex(p.accent)}")
|
||||
appendLine("beat=${hex(p.beat)}")
|
||||
appendLine("bar=${hex(p.bar)}")
|
||||
appendLine("playhead=${hex(p.playhead)}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable + collapsible MIDI binding list. Each module is a Card whose header
|
||||
* toggles expansion; its actions are real [BindingRow]s whose Learn button arms
|
||||
* MIDI-learn — the next incoming CC is stored into the live MIDI binding map.
|
||||
*/
|
||||
@Composable
|
||||
private fun MidiBindingSections(vm: AppViewModel) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var expanded by remember { mutableStateOf<String?>("Transport") }
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||
placeholder = { Text("Search controls…") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
BIND_MODULES.forEach { (module, actions) ->
|
||||
val matches = actions.filter { it.label.contains(query, ignoreCase = true) }
|
||||
if (query.isNotEmpty() && matches.isEmpty()) return@forEach
|
||||
val isOpen = expanded == module || query.isNotEmpty()
|
||||
|
||||
Card(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = if (expanded == module) null else module }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(module, style = MaterialTheme.typography.titleSmall)
|
||||
Icon(if (isOpen) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null)
|
||||
}
|
||||
if (isOpen) {
|
||||
matches.forEach { action -> BindingRow(vm, action, InputSource.MIDI) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The retro look-and-feel. Two ideas drive everything:
|
||||
* 1. ONE monospace font family for the entire UI (that's the "tracker" feel and
|
||||
* it makes the grid columns line up perfectly).
|
||||
* 2. A small, named [RetroPalette] so colour *themes* can be swapped and
|
||||
* imported/exported from the Settings tab without touching any screen code.
|
||||
*
|
||||
* The palette is exposed through a CompositionLocal ([LocalRetro]) so any
|
||||
* composable can read theme colours with `LocalRetro.current`.
|
||||
*/
|
||||
data class RetroPalette(
|
||||
val name: String,
|
||||
val background: Color,
|
||||
val surface: Color, // panel / toolbar background
|
||||
val grid: Color, // faint grid lines
|
||||
val text: Color, // default monospace text
|
||||
val textDim: Color, // secondary text
|
||||
val accent: Color, // cursor / primary highlight (amber)
|
||||
val beat: Color, // every-beat row tint
|
||||
val bar: Color, // every-bar row tint
|
||||
val playhead: Color, // moving playback line
|
||||
val danger: Color, // mute / destructive
|
||||
) {
|
||||
companion object {
|
||||
/** The built-in default: near-black with amber + green, classic demoscene. */
|
||||
val AMBER = RetroPalette(
|
||||
name = "Amber CRT",
|
||||
background = Color(0xFF0B0D0E),
|
||||
surface = Color(0xFF15181A),
|
||||
grid = Color(0xFF23282B),
|
||||
text = Color(0xFFCFE8D8),
|
||||
textDim = Color(0xFF6E7C74),
|
||||
accent = Color(0xFFF2C14E),
|
||||
beat = Color(0xFF141C1A),
|
||||
bar = Color(0xFF1E2A20),
|
||||
playhead = Color(0xFF7CFC9A),
|
||||
danger = Color(0xFFE05A4B),
|
||||
)
|
||||
|
||||
/** A second built-in so the theme picker has something to switch to. */
|
||||
val ICE = RetroPalette(
|
||||
name = "Ice Terminal",
|
||||
background = Color(0xFF07090F),
|
||||
surface = Color(0xFF10151F),
|
||||
grid = Color(0xFF1E2633),
|
||||
text = Color(0xFFCFE0FF),
|
||||
textDim = Color(0xFF63708A),
|
||||
accent = Color(0xFF62B6FF),
|
||||
beat = Color(0xFF121A26),
|
||||
bar = Color(0xFF1B2740),
|
||||
playhead = Color(0xFF8AF0FF),
|
||||
danger = Color(0xFFE05A7A),
|
||||
)
|
||||
|
||||
val ALL = listOf(AMBER, ICE)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalRetro = staticCompositionLocalOf { RetroPalette.AMBER }
|
||||
|
||||
/** Monospace typography used everywhere — no other font is loaded. */
|
||||
private val Mono = FontFamily.Monospace
|
||||
private val retroTypography = Typography(
|
||||
bodyLarge = TextStyle(fontFamily = Mono, fontSize = 14.sp, fontWeight = FontWeight.Normal),
|
||||
bodyMedium = TextStyle(fontFamily = Mono, fontSize = 12.sp),
|
||||
bodySmall = TextStyle(fontFamily = Mono, fontSize = 10.sp),
|
||||
labelLarge = TextStyle(fontFamily = Mono, fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||
titleMedium = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SizzleTheme(
|
||||
palette: RetroPalette = RetroPalette.AMBER,
|
||||
@Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), // always dark; kept for API symmetry
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scheme = darkColorScheme(
|
||||
background = palette.background,
|
||||
surface = palette.surface,
|
||||
primary = palette.accent,
|
||||
onPrimary = palette.background,
|
||||
onBackground = palette.text,
|
||||
onSurface = palette.text,
|
||||
error = palette.danger,
|
||||
)
|
||||
CompositionLocalProvider(LocalRetro provides palette) {
|
||||
MaterialTheme(colorScheme = scheme, typography = retroTypography, content = content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* A modulation target: a numeric parameter on another device the LFO can drive.
|
||||
* [slotIndex] < 0 represents "no target". [encoded] is the "slotIndex:paramKey"
|
||||
* form stored in the LFO slot; [label] is what the user sees.
|
||||
*/
|
||||
private data class LfoTarget(val slotIndex: Int, val key: String, val label: String) {
|
||||
val encoded: String get() = if (slotIndex < 0) "" else "$slotIndex:$key"
|
||||
}
|
||||
|
||||
/**
|
||||
* The LFO's target picker (rendered under its generated parameter list). It lists
|
||||
* every numeric parameter of every other occupied slot; choosing one wires the
|
||||
* LFO to modulate it. See [AppViewModel.setLfoTarget].
|
||||
*/
|
||||
@Composable
|
||||
fun LfoTargetPicker(vm: AppViewModel, lfo: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
|
||||
val none = LfoTarget(-1, "", "— none —")
|
||||
val targets = buildList {
|
||||
add(none)
|
||||
vm.project.toolbox.forEachIndexed { idx, slot ->
|
||||
val type = slot.type
|
||||
if (idx == lfo.index || type == null) return@forEachIndexed
|
||||
for (spec in type.params) {
|
||||
if (spec.isEnum) continue // only continuous params are modulatable
|
||||
add(LfoTarget(idx, spec.key, "${idx + 1}:${slot.name} · ${spec.label}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
val current = lfo.string("target")
|
||||
val selected = targets.firstOrNull { it.encoded == current } ?: none
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
RetroDropdown(
|
||||
label = "TARGET",
|
||||
options = targets,
|
||||
selected = selected,
|
||||
optionLabel = { it.label },
|
||||
onSelect = { vm.setLfoTarget(lfo, it.slotIndex, it.key) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"The LFO takes over its target parameter while assigned.",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ParamSpec
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* One editable parameter row. Enumerated params ([ParamSpec.isEnum]) render as a
|
||||
* dropdown; numeric params render as a labelled slider. Reading and writing goes
|
||||
* through the slot's string-typed value map so it stays serialization-friendly.
|
||||
*/
|
||||
@Composable
|
||||
fun ParamControl(vm: AppViewModel, slot: ToolboxSlot, spec: ParamSpec) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so sliders/dropdowns refresh
|
||||
if (spec.isEnum) {
|
||||
RetroDropdown(
|
||||
label = spec.label,
|
||||
options = spec.choices,
|
||||
selected = slot.string(spec.key, spec.choices.first()),
|
||||
onSelect = { slot.set(spec.key, it); vm.bumpForToolbar() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
val value = slot.float(spec.key, spec.default)
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row {
|
||||
Text("${spec.label}: ", color = c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
Text(formatValue(value), color = c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
Slider(
|
||||
value = value.coerceIn(spec.min, spec.max),
|
||||
valueRange = spec.min..spec.max,
|
||||
onValueChange = { slot.set(spec.key, it); vm.bumpForToolbar() },
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = c.accent, activeTrackColor = c.accent, inactiveTrackColor = c.grid,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatValue(v: Float): String =
|
||||
if (v == v.toInt().toFloat()) v.toInt().toString() else String.format("%.2f", v)
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.audio.SampleStore
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The Sampler's custom editor (replaces the generic parameter list for the
|
||||
* Sampler device). It lets you import a WAV or record from the mic/USB input,
|
||||
* trim the sample with two draggable slice markers on a waveform, set the start
|
||||
* octave + volume, and audition across a two-octave keyboard.
|
||||
*/
|
||||
@Composable
|
||||
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
val context = LocalContext.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: waveform/markers refresh
|
||||
|
||||
val sampleId = slot.string("samplePath")
|
||||
val sliceStart = slot.float("sliceStart", 0f)
|
||||
val sliceEnd = slot.float("sliceEnd", 1f)
|
||||
val startOctave = slot.float("startOctave", 3f).toInt()
|
||||
val volume = slot.float("volume", 0.8f)
|
||||
|
||||
// System file picker for importing a WAV.
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()?.let { bytes ->
|
||||
val ok = vm.loadSampleInto(slot, uri.toString(), bytes)
|
||||
if (!ok) { /* unsupported file; a toast could go here */ }
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// ----- Source buttons -----
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RetroButton("IMPORT WAV") { picker.launch(arrayOf("audio/*")) }
|
||||
if (vm.isRecording) {
|
||||
RetroButton("■ STOP REC", active = true) { vm.stopRecordingInto(slot) }
|
||||
} else {
|
||||
RetroButton("● RECORD") { vm.startRecording() }
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Waveform + draggable slice markers -----
|
||||
val sample = SampleStore.get(sampleId)
|
||||
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||
var dragging by remember { mutableIntStateOf(-1) }
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(130.dp)
|
||||
.background(c.surface)
|
||||
.pointerInput(sampleId) {
|
||||
detectDragGestures(
|
||||
onDragStart = { off ->
|
||||
val frac = (off.x / size.width).coerceIn(0f, 1f)
|
||||
dragging = if (abs(frac - sliceStart) <= abs(frac - sliceEnd)) 0 else 1
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
val frac = (change.position.x / size.width).coerceIn(0f, 1f)
|
||||
if (dragging == 0) slot.set("sliceStart", frac) else slot.set("sliceEnd", frac)
|
||||
vm.bumpForToolbar()
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val mid = h / 2f
|
||||
if (peaks != null) {
|
||||
val (mins, maxs) = peaks
|
||||
val bw = w / mins.size
|
||||
for (i in mins.indices) {
|
||||
val x = i * bw
|
||||
drawLine(
|
||||
color = c.text,
|
||||
start = Offset(x, mid - maxs[i] * mid),
|
||||
end = Offset(x, mid - mins[i] * mid),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Shade the active slice region and draw the two markers.
|
||||
val sx = sliceStart * w
|
||||
val ex = sliceEnd * w
|
||||
drawRect(color = c.accent.copy(alpha = 0.14f), topLeft = Offset(sx, 0f), size = Size((ex - sx), h))
|
||||
drawLine(c.accent, Offset(sx, 0f), Offset(sx, h), strokeWidth = 3f)
|
||||
drawLine(c.playhead, Offset(ex, 0f), Offset(ex, h), strokeWidth = 3f)
|
||||
}
|
||||
Text(
|
||||
if (sample == null) "No sample loaded — import a WAV or record one."
|
||||
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers to trim",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
|
||||
// ----- Start octave + volume -----
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Start oct:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
modifier = Modifier.padding(top = 6.dp))
|
||||
RetroButton("-") { slot.set("startOctave", (startOctave - 1).coerceAtLeast(0).toFloat()); vm.bumpForToolbar() }
|
||||
Text(" $startOctave ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
modifier = Modifier.padding(top = 6.dp))
|
||||
RetroButton("+") { slot.set("startOctave", (startOctave + 1).coerceAtMost(8).toFloat()); vm.bumpForToolbar() }
|
||||
}
|
||||
ParamControl(vm, slot, com.reactorcoremeltdown.sizzletracker.model.ParamSpec("volume", "Volume", volume, 0f, 1f))
|
||||
|
||||
// ----- Two-octave audition keyboard -----
|
||||
Text("Audition (2 octaves):", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
val root = ((startOctave + 1) * 12).coerceIn(0, 103)
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
for (n in 0 until 24) {
|
||||
val midi = root + n
|
||||
RetroButton(Pitch.name(midi)) { vm.auditionSample(slot, midi) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Down-sample the waveform to [buckets] min/max pairs for cheap drawing. */
|
||||
private fun computePeaks(data: FloatArray, buckets: Int): Pair<FloatArray, FloatArray> {
|
||||
val mins = FloatArray(buckets)
|
||||
val maxs = FloatArray(buckets)
|
||||
if (data.isEmpty()) return mins to maxs
|
||||
val step = (data.size / buckets).coerceAtLeast(1)
|
||||
for (b in 0 until buckets) {
|
||||
var mn = 1f; var mx = -1f
|
||||
val start = b * step
|
||||
val end = minOf(start + step, data.size)
|
||||
if (start >= data.size) { mins[b] = 0f; maxs[b] = 0f; continue }
|
||||
for (i in start until end) {
|
||||
val v = data[i]
|
||||
if (v < mn) mn = v
|
||||
if (v > mx) mx = v
|
||||
}
|
||||
mins[b] = mn.coerceIn(-1f, 1f)
|
||||
maxs[b] = mx.coerceIn(-1f, 1f)
|
||||
}
|
||||
return mins to maxs
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.toolbox
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.io.PresetIo
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
|
||||
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The third tab. Top half: a 4x4 grid of 16 slots holding instruments or effects.
|
||||
* - SINGLE tap -> select the tile (highlighted).
|
||||
* - DOUBLE tap -> open its edit window (device picker if empty, params if filled).
|
||||
* - LONG press -> empty the cell.
|
||||
*
|
||||
* Bottom half: a stacked two-octave keyboard (one octave per row) that plays the
|
||||
* currently-selected tile IF it is an instrument, for quick testing.
|
||||
*
|
||||
* The parameter editor is generated automatically from the device's [ToolboxType]
|
||||
* parameter list, so new devices need no bespoke UI.
|
||||
*/
|
||||
@Composable
|
||||
fun ToolboxScreen(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||
|
||||
var selected by remember { mutableStateOf(-1) }
|
||||
var editing by remember { mutableStateOf<Int?>(null) }
|
||||
var picking by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(vm.project.toolbox) { slot ->
|
||||
SlotTile(
|
||||
// The changing revision defeats strong-skipping so tiles refresh.
|
||||
rev = vm.revision,
|
||||
slot = slot,
|
||||
selected = selected == slot.index,
|
||||
onTap = { selected = slot.index },
|
||||
onDoubleTap = { if (slot.isEmpty) picking = slot.index else editing = slot.index },
|
||||
onLongPress = { slot.clearSlot(); vm.bumpForToolbar() },
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||
AuditionKeyboard(vm, vm.project.toolbox.getOrNull(selected), Modifier.weight(1f))
|
||||
}
|
||||
|
||||
picking?.let { index ->
|
||||
DevicePicker(
|
||||
onDismiss = { picking = null },
|
||||
onPick = { type ->
|
||||
vm.project.toolbox[index].fill(type)
|
||||
vm.bumpForToolbar(); picking = null; editing = index
|
||||
},
|
||||
)
|
||||
}
|
||||
editing?.let { index ->
|
||||
ParamEditor(vm, vm.project.toolbox[index], onClose = { editing = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlotTile(
|
||||
rev: Int,
|
||||
slot: ToolboxSlot,
|
||||
selected: Boolean,
|
||||
onTap: () -> Unit,
|
||||
onDoubleTap: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev // param only used to bust skipping
|
||||
val c = LocalRetro.current
|
||||
val borderColor = when { selected -> c.playhead; !slot.isEmpty -> c.accent; else -> c.grid }
|
||||
val fill = when { selected -> c.accent.copy(alpha = 0.28f); !slot.isEmpty -> c.accent.copy(alpha = 0.12f); else -> c.surface }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.border(if (selected) 2.dp else 1.dp, borderColor, RectangleShape)
|
||||
.background(fill)
|
||||
.pointerInput(slot.index, slot.isEmpty) {
|
||||
detectTapGestures(
|
||||
onTap = { onTap() },
|
||||
onDoubleTap = { onDoubleTap() },
|
||||
onLongPress = { onLongPress() },
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("${slot.index + 1}".padStart(2, '0'),
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||
Text(
|
||||
if (slot.isEmpty) "+" else slot.name,
|
||||
color = if (slot.isEmpty) c.textDim else c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
)
|
||||
slot.type?.let {
|
||||
Text(it.kind.name.take(3), color = c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 9.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- test keyboard
|
||||
|
||||
private val ACCIDENTALS = setOf(1, 3, 6, 8, 10) // pitch-classes of the "black keys"
|
||||
|
||||
/**
|
||||
* Two stacked octaves (one per row) that audition the selected instrument tile.
|
||||
* Disabled (dimmed) when nothing is selected or the selection is an effect.
|
||||
*/
|
||||
@Composable
|
||||
private fun AuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot?, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
val isInstrument = slot?.type?.kind == ToolboxKind.INSTRUMENT
|
||||
Column(
|
||||
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
slot == null -> "Tap a tile to select an instrument to test"
|
||||
!isInstrument -> "${slot.name}: not an instrument"
|
||||
else -> "Test: ${slot.name}"
|
||||
},
|
||||
color = if (isInstrument) c.accent else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||
)
|
||||
val slotIndex = slot?.index ?: -1
|
||||
val base = 48 // C3
|
||||
KeyRow(vm, slotIndex, isInstrument, base + 12, Modifier.weight(1f)) // upper octave (C4..B4) on top
|
||||
KeyRow(vm, slotIndex, isInstrument, base, Modifier.weight(1f)) // lower octave (C3..B3)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) {
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
for (i in 0 until 12) {
|
||||
Key(vm, slotIndex, enabled, startMidi + i, Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Key(vm: AppViewModel, slotIndex: Int, enabled: Boolean, midi: Int, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
val accidental = (midi % 12) in ACCIDENTALS
|
||||
val keyColor = (if (accidental) c.grid else c.surface).let { if (enabled) it else it.copy(alpha = 0.4f) }
|
||||
// Keep the gesture handler stable across selection changes. Keying pointerInput
|
||||
// on `midi` alone (constant per key) means selecting a different instrument does
|
||||
// NOT tear down and relaunch all 24 keys' gesture coroutines every tap — the
|
||||
// live slot/enabled are read through rememberUpdatedState instead.
|
||||
val liveSlot = rememberUpdatedState(slotIndex)
|
||||
val liveEnabled = rememberUpdatedState(enabled)
|
||||
Box(
|
||||
modifier
|
||||
.background(keyColor)
|
||||
.border(1.dp, c.grid, RectangleShape)
|
||||
.pointerInput(midi) {
|
||||
// Press → note on; release/cancel → note off (proper key behaviour).
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
val idx = liveSlot.value
|
||||
if (liveEnabled.value && idx >= 0) {
|
||||
vm.auditionOn(idx, midi)
|
||||
waitForUpOrCancellation()
|
||||
vm.auditionOff(midi)
|
||||
} else {
|
||||
waitForUpOrCancellation()
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
Pitch.name(midi),
|
||||
color = if (accidental) c.textDim else c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A simple overlay list of all available instruments and effects to load. */
|
||||
@Composable
|
||||
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
|
||||
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
|
||||
ToolboxType.entries.forEach { type ->
|
||||
Text(
|
||||
"[${type.kind.name.take(3)}] ${type.displayName}",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(type) { detectTapGestures { onPick(type) } }
|
||||
.padding(vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-generated parameter editor plus preset copy/paste to the clipboard. */
|
||||
@Composable
|
||||
private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
|
||||
val type = slot.type ?: return
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
|
||||
Column(Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
|
||||
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
|
||||
}
|
||||
|
||||
// The Sampler gets a bespoke editor (waveform + slicing + record);
|
||||
// the LFO adds a modulation-target picker; everything else uses the
|
||||
// auto-generated parameter list.
|
||||
when (type) {
|
||||
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
|
||||
ToolboxType.LFO -> {
|
||||
type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
||||
LfoTargetPicker(vm, slot)
|
||||
}
|
||||
else -> type.params.forEach { spec -> ParamControl(vm, slot, spec) }
|
||||
}
|
||||
|
||||
// ----- Preset import / export (human-readable text via clipboard) -----
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
PresetButton("COPY PRESET") {
|
||||
clipboard.setText(AnnotatedString(PresetIo.export(slot)))
|
||||
}
|
||||
PresetButton("PASTE PRESET") {
|
||||
clipboard.getText()?.text?.let { PresetIo.import(slot, it); vm.bumpForToolbar() }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Presets are plain text (KEY=VALUE). Copy to share, paste to load.",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetButton(label: String, onClick: () -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
Text(
|
||||
"[$label]",
|
||||
color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClick() } }.padding(4.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Arrangement as ArrModel
|
||||
import com.reactorcoremeltdown.sizzletracker.model.LoopRegion
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.GlyphCache
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The lower-half arrangement. Layout (spreadsheet-style, like the sizzletracker
|
||||
* CLI): a fixed left column of lane numbers, a bar ruler across the top, and the
|
||||
* scrollable roll body. The ruler and body share one horizontal scroll so they
|
||||
* stay aligned; the roll body is drawn on a single [Canvas] for performance.
|
||||
*
|
||||
* Each lane is tied to its own tracker block (lane i ⟷ block i). Tapping a beat
|
||||
* cell toggles that lane's block on/off at that beat; when the playhead crosses
|
||||
* an enabled cell, the sequencer plays that block. Tap a lane number to edit that
|
||||
* block in the tracker above.
|
||||
*/
|
||||
@Composable
|
||||
fun ArrangementRoll(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
// Composition read: structural edits bump revision (playback does not), so the
|
||||
// lane column / ruler recompose on edits but not per beat. The roll Canvas also
|
||||
// reads vm.revision in its draw phase (below) so cell edits redraw it directly.
|
||||
val redraw = vm.revision
|
||||
@Suppress("UNUSED_EXPRESSION") redraw
|
||||
// Held as State and read ONLY in the draw phase + the follow-scroll collector
|
||||
// below, never in composition, so a playhead advance invalidates the roll draw
|
||||
// alone — no recomposition.
|
||||
val transportState = vm.transport.collectAsState()
|
||||
val arr = vm.project.arrangement
|
||||
val sig = vm.project.timeSignature
|
||||
val scroll = rememberScrollState()
|
||||
val density = LocalDensity.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||
|
||||
val beatWidth = 20.dp
|
||||
val laneColWidth = 34.dp
|
||||
val rulerHeight = 18.dp
|
||||
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||
|
||||
var selStart by remember { mutableIntStateOf(-1) }
|
||||
var selEnd by remember { mutableIntStateOf(-1) }
|
||||
|
||||
val rulerStyle = remember(c) { TextStyle(color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 9.sp) }
|
||||
|
||||
// Follow the playhead by scrolling horizontally. Collects the transport flow
|
||||
// directly rather than keying a LaunchedEffect on a composition read of the beat,
|
||||
// so following the playhead never triggers recomposition. De-duped by beat so we
|
||||
// only animate when the beat actually changes.
|
||||
LaunchedEffect(scroll, beatWidthPx) {
|
||||
var lastBeat = -1
|
||||
vm.transport.collect { t ->
|
||||
if (t.isPlaying && t.currentBeat != lastBeat) {
|
||||
lastBeat = t.currentBeat
|
||||
val target = (t.currentBeat * beatWidthPx - 200f).toInt().coerceAtLeast(0)
|
||||
scroll.animateScrollTo(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// ---- Fixed left column: corner + lane numbers ----
|
||||
Column(Modifier.width(laneColWidth).fillMaxHeight()) {
|
||||
Box(Modifier.height(rulerHeight).fillMaxWidth().background(c.surface),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Text("BLK", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 8.sp)
|
||||
}
|
||||
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||
val active = vm.activeBlock == lane
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.background(if (active) c.accent.copy(alpha = 0.25f) else c.surface)
|
||||
.clickable { vm.setActiveBlock(lane) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("${lane + 1}", color = if (active) c.accent else c.textDim,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Scrollable ruler + roll (one Canvas) ----
|
||||
Box(Modifier.weight(1f).fillMaxHeight().horizontalScroll(scroll)) {
|
||||
Canvas(
|
||||
Modifier
|
||||
.width(beatWidth * arr.lengthBeats)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(arr, sig) {
|
||||
detectTapGestures { off ->
|
||||
if (off.y < rulerHeightPx) return@detectTapGestures
|
||||
val laneH = (size.height - rulerHeightPx) / ArrModel.LANE_COUNT
|
||||
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
||||
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, arr.lengthBeats - 1)
|
||||
// Lane i is tied to block i: toggle block i at this beat.
|
||||
if (arr.patternAt(lane, beat) != ArrModel.EMPTY) arr.set(lane, beat, ArrModel.EMPTY)
|
||||
else arr.set(lane, beat, lane)
|
||||
if (selStart < 0) selStart = beat else selEnd = beat
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Subscribe the draw phase to edit revisions (live state read,
|
||||
// not the captured `redraw` Int) so toggling a cell redraws the
|
||||
// roll even when no other draw-read state changed.
|
||||
@Suppress("UNUSED_EXPRESSION") vm.revision
|
||||
// Draw-phase read: a playhead advance invalidates this draw
|
||||
// only, never composition.
|
||||
val transport = transportState.value
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val laneH = (h - rulerHeightPx) / ArrModel.LANE_COUNT
|
||||
val beatsPerBar = sig.beatsPerBar
|
||||
|
||||
// Ruler background + per-bar labels/ticks.
|
||||
drawRect(c.surface, Offset(0f, 0f), Size(w, rulerHeightPx))
|
||||
for (beat in 0 until arr.lengthBeats) {
|
||||
val x = beat * beatWidthPx
|
||||
if (beat % beatsPerBar == 0) {
|
||||
drawLine(c.grid, Offset(x, 0f), Offset(x, h), strokeWidth = 1f)
|
||||
val bar = beat / beatsPerBar + 1
|
||||
val layout = glyphs.measure("$bar", rulerStyle)
|
||||
drawText(layout, topLeft = Offset(x + 2f, (rulerHeightPx - layout.size.height) / 2f))
|
||||
}
|
||||
}
|
||||
|
||||
// Lane cells.
|
||||
for (lane in 0 until ArrModel.LANE_COUNT) {
|
||||
val laneY = rulerHeightPx + lane * laneH
|
||||
for (beat in 0 until arr.lengthBeats) {
|
||||
val x = beat * beatWidthPx
|
||||
val filled = arr.patternAt(lane, beat) != ArrModel.EMPTY
|
||||
val onPlayhead = transport.isPlaying && beat == transport.currentBeat
|
||||
val inSel = selStart in 0..beat && beat < maxOf(selStart, selEnd + 1)
|
||||
val fill = when {
|
||||
filled -> c.accent.copy(alpha = 0.85f)
|
||||
onPlayhead -> c.playhead.copy(alpha = 0.3f)
|
||||
inSel -> c.bar
|
||||
beat % beatsPerBar == 0 -> c.surface
|
||||
else -> c.grid.copy(alpha = 0.35f)
|
||||
}
|
||||
drawRect(fill, Offset(x + 0.5f, laneY + 0.5f), Size(beatWidthPx - 1f, laneH - 1f))
|
||||
}
|
||||
// Lane separator.
|
||||
drawLine(c.background, Offset(0f, laneY), Offset(w, laneY), strokeWidth = 1f)
|
||||
}
|
||||
|
||||
// Playhead marker across the whole roll.
|
||||
if (transport.isPlaying) {
|
||||
val px = transport.currentBeat * beatWidthPx
|
||||
drawLine(c.playhead, Offset(px, 0f), Offset(px, h), strokeWidth = 2f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoopToolbar(
|
||||
vm = vm,
|
||||
arr = arr,
|
||||
onAssignA = { assign(arr.regionA, selStart, selEnd); vm.bumpForToolbar() },
|
||||
onAssignB = { assign(arr.regionB, selStart, selEnd); vm.bumpForToolbar() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoopToolbar(
|
||||
vm: AppViewModel,
|
||||
arr: ArrModel,
|
||||
onAssignA: () -> Unit,
|
||||
onAssignB: () -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(c.surface).padding(6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RetroButton("LOOP", active = arr.loopEnabled) {
|
||||
arr.loopEnabled = !arr.loopEnabled; vm.bumpForToolbar()
|
||||
}
|
||||
RegionControl("A", arr.regionA, onAssign = onAssignA, vm = vm)
|
||||
RegionControl("B", arr.regionB, onAssign = onAssignB, vm = vm)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RegionControl(name: String, region: LoopRegion, onAssign: () -> Unit, vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RetroButton(name, active = region.enabled) {
|
||||
region.enabled = !region.enabled; onAssign()
|
||||
}
|
||||
RetroButton("-") { region.repeats = (region.repeats - 1).coerceAtLeast(1); vm.bumpForToolbar() }
|
||||
Text(" x${region.repeats} ", color = c.text, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
RetroButton("+") { region.repeats++; vm.bumpForToolbar() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy the tap-selection into a loop region. */
|
||||
private fun assign(region: LoopRegion, selStart: Int, selEnd: Int) {
|
||||
if (selStart < 0) return
|
||||
region.start = minOf(selStart, selEnd.coerceAtLeast(selStart))
|
||||
region.end = maxOf(selStart, selEnd) + 1
|
||||
region.enabled = true
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||
import com.reactorcoremeltdown.sizzletracker.model.CellColumn
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The touch note-entry popup, opened by long-pressing a cell in the pattern grid.
|
||||
* Which editor it shows depends on the column that was long-pressed:
|
||||
* - NOTE : one octave of piano keys plus an octave switcher (- OCTAVE n +).
|
||||
* - VELOCITY : a value readout with - / + steppers.
|
||||
* - CHANNEL : a value readout with - / + steppers.
|
||||
*
|
||||
* It replaces the old vertical drag-to-edit gesture — touch entry is now explicit
|
||||
* (long-press → pick), matching the keyboard/gamepad path (which nudges values and
|
||||
* resumes from the last entered note/channel).
|
||||
*/
|
||||
@Composable
|
||||
fun CellEditorPopup(vm: AppViewModel, column: CellColumn, onDismiss: () -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_EXPRESSION") vm.revision // refresh readouts after each edit
|
||||
val cell = vm.cellUnderCursor()
|
||||
|
||||
// Full-screen scrim; a tap outside the card dismisses.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(c.background.copy(alpha = 0.88f))
|
||||
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// The card. Its own (empty) tap handler swallows taps so they don't dismiss.
|
||||
Column(
|
||||
Modifier
|
||||
.then(if (column == CellColumn.NOTE) Modifier.fillMaxWidth(0.96f) else Modifier)
|
||||
.border(1.dp, c.accent, RectangleShape)
|
||||
.background(c.surface)
|
||||
.pointerInput(Unit) { detectTapGestures { } }
|
||||
.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"T${vm.cursorTrack + 1} LINE ${lineLabel(vm.cursorLine)}",
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||
)
|
||||
when (column) {
|
||||
CellColumn.NOTE -> NoteEditor(vm, cell)
|
||||
CellColumn.VELOCITY -> Stepper("VELOCITY", velLabel(cell)) { vm.editFocused(it) }
|
||||
CellColumn.CHANNEL -> Stepper("CHANNEL", chanLabel(cell)) { vm.editFocused(it) }
|
||||
}
|
||||
RetroButton("DONE", onClick = onDismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One octave of piano keys + an octave switcher, writing the note on tap. */
|
||||
@Composable
|
||||
private fun NoteEditor(vm: AppViewModel, cell: Cell) {
|
||||
val c = LocalRetro.current
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // re-highlight the key after entry
|
||||
// Start on the octave of the cell's note, or of the last note entered if empty.
|
||||
val seedMidi = if (cell.isPlayable) cell.note else vm.lastNote
|
||||
var octave by remember { mutableIntStateOf((seedMidi / 12 - 1).coerceIn(0, 9)) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
RetroButton("-") { octave = (octave - 1).coerceAtLeast(0) }
|
||||
Text("OCTAVE $octave", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
|
||||
RetroButton("+") { octave = (octave + 1).coerceAtMost(9) }
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
for (pc in 0 until 12) {
|
||||
val midi = ((octave + 1) * 12 + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
PianoKey(
|
||||
label = Pitch.name(midi).take(2),
|
||||
accidental = pc in ACCIDENTAL_PCS,
|
||||
selected = cell.isPlayable && cell.note == midi,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { vm.setFocusedNote(midi) }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"NOTE: " + when {
|
||||
cell.isPlayable -> Pitch.name(cell.note)
|
||||
cell.isNoteOff -> "==="
|
||||
else -> "---"
|
||||
},
|
||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PianoKey(
|
||||
label: String,
|
||||
accidental: Boolean,
|
||||
selected: Boolean,
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
val bg = when {
|
||||
selected -> c.accent.copy(alpha = 0.45f)
|
||||
accidental -> c.grid
|
||||
else -> c.background
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
.height(72.dp)
|
||||
.border(1.dp, if (selected) c.accent else c.grid, RectangleShape)
|
||||
.background(bg)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (accidental) c.textDim else c.text,
|
||||
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled value readout with - / + steppers; [onStep] receives -1 or +1. */
|
||||
@Composable
|
||||
private fun Stepper(title: String, value: String, onStep: (Int) -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(title, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
RetroButton("-") { onStep(-1) }
|
||||
Text(value, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 22.sp)
|
||||
RetroButton("+") { onStep(+1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ACCIDENTAL_PCS = setOf(1, 3, 6, 8, 10)
|
||||
private fun velLabel(cell: Cell) = cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
private fun chanLabel(cell: Cell) = cell.channel.toString().padStart(2, '0')
|
||||
private fun lineLabel(line: Int) = line.toString(16).uppercase().padStart(2, '0')
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Cell
|
||||
import com.reactorcoremeltdown.sizzletracker.model.CellColumn
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pattern
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.GlyphCache
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The demoscene pattern grid, drawn as ONE [Canvas] instead of a tree of ~180
|
||||
* per-cell composables. Collapsing it into a single draw pass (with a cached
|
||||
* [rememberTextMeasurer] so repeated cell strings like "···" are measured once)
|
||||
* removes the composition/layout churn that made the grid feel sluggish as the
|
||||
* playhead moved — the whole grid is now a couple of hundred draw calls per frame.
|
||||
*
|
||||
* Visuals: beat and bar rows are tinted distinctly; the four vertical tracks get
|
||||
* an alternating tint + separator lines; the cursor cell and playhead row are
|
||||
* highlighted. Gestures are unchanged (tap to focus, vertical drag to edit).
|
||||
*/
|
||||
@Composable
|
||||
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
|
||||
val c = LocalRetro.current
|
||||
// Read in composition so structural changes (edits, resize, active-block swap —
|
||||
// all of which bump revision) re-fetch `pattern`/`sig` and recompose. Playback
|
||||
// does NOT bump revision, so this never fires per frame during playback.
|
||||
val structureKey = vm.revision
|
||||
@Suppress("UNUSED_EXPRESSION") structureKey
|
||||
// Held as a State and read ONLY inside the draw/gesture lambdas below, never in
|
||||
// composition: a playhead tick then invalidates the draw phase alone, skipping
|
||||
// recomposition + layout entirely.
|
||||
val transportState = vm.transport.collectAsState()
|
||||
val pattern = vm.project.activePattern()
|
||||
val sig = vm.project.timeSignature
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||
// Which column's long-press editor popup is open (null = none).
|
||||
var editorColumn by remember { mutableStateOf<CellColumn?>(null) }
|
||||
|
||||
// Cache the text styles per palette (fontSize is resolved with the ambient density).
|
||||
val mono = FontFamily.Monospace
|
||||
val styleText = remember(c) { TextStyle(color = c.text, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleDimNote = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleVel = remember(c) { TextStyle(color = c.accent, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleChan = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
|
||||
val styleGutter = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 11.sp) }
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(c.background)) {
|
||||
val density = LocalDensity.current
|
||||
val rowPx = with(density) { 20.dp.toPx() }
|
||||
val gutterPx = with(density) { 30.dp.toPx() }
|
||||
val widthPx = with(density) { maxWidth.toPx() }
|
||||
val heightPx = with(density) { maxHeight.toPx() }
|
||||
|
||||
val trackPx = (widthPx - gutterPx) / Pattern.TRACK_COUNT
|
||||
val notePx = trackPx * 0.5f
|
||||
val velPx = trackPx * 0.25f
|
||||
|
||||
val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1)
|
||||
|
||||
// Which row sits at the top of the viewport. Computed on demand (not stored
|
||||
// in composition) so it can be read from the draw phase during playback and
|
||||
// from gestures at touch time, always against the live transport/cursor.
|
||||
fun firstRow(): Int {
|
||||
val t = transportState.value
|
||||
val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
|
||||
return (focusLine - visibleRows / 2)
|
||||
.coerceIn(0, (pattern.length - visibleRows).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun hitTest(x: Float, y: Float): Triple<Int, CellColumn, Int>? {
|
||||
if (x < gutterPx) return null
|
||||
val line = (firstRow() + (y / rowPx).toInt()).coerceIn(0, pattern.length - 1)
|
||||
val tx = x - gutterPx
|
||||
val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
|
||||
val within = tx - track * trackPx
|
||||
val col = when {
|
||||
within < notePx -> CellColumn.NOTE
|
||||
within < notePx + velPx -> CellColumn.VELOCITY
|
||||
else -> CellColumn.CHANNEL
|
||||
}
|
||||
return Triple(track, col, line)
|
||||
}
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(pattern, widthPx, heightPx) {
|
||||
detectTapGestures(
|
||||
// Tap: just move the cursor to the cell/column.
|
||||
onTap = { off ->
|
||||
hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
|
||||
},
|
||||
// Long-press: focus the cell and open its value editor popup.
|
||||
onLongPress = { off ->
|
||||
hitTest(off.x, off.y)?.let { (t, col, line) ->
|
||||
vm.focusCell(t, line, col)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
editorColumn = col
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Subscribe the DRAW PHASE to edit revisions. Model mutations (edit,
|
||||
// clear, DEL/paste) bump revision but touch no other snapshot state the
|
||||
// draw reads, so without this a delete would not invalidate the Canvas
|
||||
// even though composition re-ran (the draw lambda is memoised identical).
|
||||
// Reading it here redraws on every edit, draw-only.
|
||||
@Suppress("UNUSED_EXPRESSION") vm.revision
|
||||
// Draw-phase reads: transport (playhead) and, via firstRow(), the cursor
|
||||
// scroll position. A change to either invalidates THIS DRAW only — no
|
||||
// recomposition, no relayout — which is what keeps the playhead smooth.
|
||||
val transport = transportState.value
|
||||
val top = firstRow()
|
||||
// Selection rectangle (draw-phase reads → redraw as the cursor drags it).
|
||||
val selActive = vm.hasSelection
|
||||
val selTracks = vm.selTrackRange
|
||||
val selLines = vm.selLineRange
|
||||
|
||||
// 1) Alternating vertical track tints + separators (full height).
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val x0 = gutterPx + t * trackPx
|
||||
if (t % 2 == 1) {
|
||||
drawRect(c.grid.copy(alpha = 0.35f), Offset(x0, 0f), Size(trackPx, heightPx))
|
||||
}
|
||||
drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line
|
||||
}
|
||||
|
||||
// 2) Rows: beat/bar/playhead backgrounds + cursor + text.
|
||||
for (screen in 0 until visibleRows) {
|
||||
val line = top + screen
|
||||
if (line >= pattern.length) break
|
||||
val y = screen * rowPx
|
||||
|
||||
val rowColor = when {
|
||||
transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)
|
||||
line % sig.linesPerBar == 0 -> c.bar.copy(alpha = 0.7f)
|
||||
line % sig.linesPerBeat == 0 -> c.beat.copy(alpha = 0.7f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
if (rowColor != Color.Transparent) {
|
||||
drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
|
||||
}
|
||||
|
||||
// Gutter line number (hex).
|
||||
drawCentered(glyphs, line.toString(16).uppercase().padStart(2, '0'),
|
||||
styleGutter, 2f, y, gutterPx, rowPx)
|
||||
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val cell = pattern.cell(t, line)
|
||||
val baseX = gutterPx + t * trackPx
|
||||
val focused = vm.cursorTrack == t && vm.cursorLine == line
|
||||
|
||||
// Selection wash (whole cell, under the cursor highlight).
|
||||
if (selActive && t in selTracks && line in selLines) {
|
||||
drawRect(c.accent.copy(alpha = 0.18f), Offset(baseX, y), Size(trackPx, rowPx))
|
||||
}
|
||||
|
||||
// Cursor cell highlight.
|
||||
if (focused) {
|
||||
val (cx, cw) = when (vm.cursorColumn) {
|
||||
CellColumn.NOTE -> baseX to notePx
|
||||
CellColumn.VELOCITY -> (baseX + notePx) to velPx
|
||||
CellColumn.CHANNEL -> (baseX + notePx + velPx) to velPx
|
||||
}
|
||||
drawRect(c.accent.copy(alpha = 0.30f), Offset(cx, y), Size(cw, rowPx))
|
||||
}
|
||||
|
||||
// NOTE column reads bright even when empty (bright placeholder
|
||||
// dots, per spec). VEL/CHAN use dim dots when empty and their
|
||||
// coloured value when filled, so only the note column is bright.
|
||||
drawCentered(glyphs, noteText(cell), styleText, baseX + 2f, y, notePx, rowPx)
|
||||
drawCentered(glyphs, velText(cell), if (cell.isEmpty) styleDimNote else styleVel,
|
||||
baseX + notePx + 2f, y, velPx, rowPx)
|
||||
drawCentered(glyphs, chanText(cell), if (cell.isEmpty) styleDimNote else styleChan,
|
||||
baseX + notePx + velPx + 2f, y, velPx, rowPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Long-press value editor, overlaid on the grid area.
|
||||
editorColumn?.let { col ->
|
||||
CellEditorPopup(vm, col) { editorColumn = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw [text] vertically centred in a cell of the given width/height. */
|
||||
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCentered(
|
||||
glyphs: GlyphCache,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
x: Float,
|
||||
y: Float,
|
||||
cellWidth: Float,
|
||||
cellHeight: Float,
|
||||
) {
|
||||
val layout = glyphs.measure(text, style)
|
||||
drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
|
||||
}
|
||||
|
||||
// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
|
||||
private fun noteText(cell: Cell): String = when {
|
||||
cell.isEmpty -> "···"
|
||||
cell.isNoteOff -> "==="
|
||||
else -> Pitch.name(cell.note)
|
||||
}
|
||||
private fun velText(cell: Cell): String =
|
||||
if (cell.isEmpty) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
private fun chanText(cell: Cell): String =
|
||||
if (cell.isEmpty) ".." else cell.channel.toString().padStart(2, '0')
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroDropdown
|
||||
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
|
||||
|
||||
/**
|
||||
* The first tab. Two stacked halves:
|
||||
* - upper: the transport toolbar (3 rows) + the demoscene 4-track pattern grid.
|
||||
* - lower: the 8-lane arrangement piano-roll + its loop toolbar.
|
||||
*
|
||||
* The split is weighted toward the top so the pattern grid keeps room for at least
|
||||
* one full 16-tick bar (16 rows) once the three toolbar rows are accounted for; the
|
||||
* arrangement below is squashed to make that room.
|
||||
*/
|
||||
@Composable
|
||||
fun TrackerScreen(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Upper section: transport + pattern grid. Weighted heavier than the lower
|
||||
// half so >=16 grid rows fit under the (now three-row) toolbar.
|
||||
Box(Modifier.weight(1.7f).fillMaxWidth()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TransportToolbar(vm)
|
||||
PatternGrid(vm, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(c.accent),
|
||||
)
|
||||
// Lower section: the arrangement roll, squashed to give the grid its rows.
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
ArrangementRoll(vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The two-row transport toolbar at the top of the upper half. */
|
||||
@Composable
|
||||
private fun TransportToolbar(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
val transport by vm.transport.collectAsState()
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so tempo/len/sig readouts refresh
|
||||
val project = vm.project
|
||||
|
||||
Column(Modifier.fillMaxWidth().background(c.surface).padding(6.dp)) {
|
||||
// Row 1: transport + tempo + time signature + note-off insert.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RetroButton("⧉ STOP", onClick = vm::stop)
|
||||
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||
active = transport.isPlaying, onClick = vm::playPause)
|
||||
// Tempo nudger: tap -/+ around a readout.
|
||||
Row {
|
||||
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
|
||||
Text(
|
||||
" ${project.tempoBpm.toInt()} BPM ",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
)
|
||||
RetroButton("+", onClick = { vm.setTempo(project.tempoBpm + 1) })
|
||||
}
|
||||
RetroDropdown(
|
||||
label = "SIG",
|
||||
options = TimeSignature.entries,
|
||||
selected = project.timeSignature,
|
||||
optionLabel = { it.label },
|
||||
onSelect = vm::setTimeSignature,
|
||||
)
|
||||
// Insert a note-off ("===") at the cursor.
|
||||
RetroButton("OFF ===", onClick = vm::noteOffFocused)
|
||||
}
|
||||
// Row 2: block + length + scale + root.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RetroDropdown(
|
||||
label = "BLK",
|
||||
options = project.patterns.map { it.id },
|
||||
selected = vm.activeBlock,
|
||||
optionLabel = { "${it + 1}" },
|
||||
onSelect = vm::setActiveBlock,
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "LEN",
|
||||
options = project.timeSignature.lengthOptions,
|
||||
selected = project.activePattern().length,
|
||||
onSelect = vm::setPatternLength,
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "SCALE",
|
||||
options = com.reactorcoremeltdown.sizzletracker.model.Scale.entries,
|
||||
selected = project.scale,
|
||||
optionLabel = { it.label },
|
||||
onSelect = { project.scale = it; vm.bumpForToolbar() },
|
||||
)
|
||||
RetroDropdown(
|
||||
label = "ROOT",
|
||||
options = (0..11).toList(),
|
||||
selected = project.rootNote,
|
||||
optionLabel = { com.reactorcoremeltdown.sizzletracker.model.Pitch.name(60 + it).dropLast(1) },
|
||||
onSelect = { project.rootNote = it; vm.bumpForToolbar() },
|
||||
)
|
||||
}
|
||||
// Row 3: selection + clipboard edit operations. SEL toggles rectangular
|
||||
// select mode (the cursor then drags out a region); the rest act on that
|
||||
// region, or on the single focused cell when nothing is selected.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RetroButton("SEL", active = vm.selectMode, onClick = vm::toggleSelectMode)
|
||||
RetroButton("CUT", onClick = vm::cutSelection)
|
||||
RetroButton("COPY", onClick = vm::copySelection)
|
||||
RetroButton("PASTE", active = vm.canPaste, onClick = vm::pasteClipboard)
|
||||
RetroButton("DEL", onClick = vm::deleteSelection)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Pixel-art launcher glyph: a chunky 1/8th note drawn on the 108x108 adaptive
|
||||
canvas (the safe zone is the centre 66x66). Colour is the retro amber that
|
||||
the app's default theme uses. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Note stem (a vertical run of "pixels") -->
|
||||
<path android:fillColor="#F2C14E"
|
||||
android:pathData="M60,30 h12 v42 h-12 z" />
|
||||
<!-- Note flag -->
|
||||
<path android:fillColor="#F2C14E"
|
||||
android:pathData="M72,30 h12 v12 h-12 z M72,42 h6 v12 h-6 z" />
|
||||
<!-- Note head (a fat pixel square) -->
|
||||
<path android:fillColor="#F2C14E"
|
||||
android:pathData="M36,60 h24 v24 h-24 z" />
|
||||
<!-- Two grid ticks to read as "tracker" -->
|
||||
<path android:fillColor="#2E7D5B"
|
||||
android:pathData="M24,30 h6 v6 h-6 z M24,48 h6 v6 h-6 z" />
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<!-- Only colors the Android framework needs before Compose starts. The full
|
||||
theming system lives in ui/theme/Theme.kt. -->
|
||||
<color name="window_background">#0B0D0E</color>
|
||||
<color name="ic_launcher_background">#0B0D0E</color>
|
||||
</resources>
|
||||
12
app/src/main/res/values/strings.xml
Normal file
12
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<resources>
|
||||
<string name="app_name">Sizzletracker</string>
|
||||
|
||||
<!-- Tab titles -->
|
||||
<string name="tab_tracker">TRACKER</string>
|
||||
<string name="tab_mixer">MIX</string>
|
||||
<string name="tab_toolbox">TOOLBOX</string>
|
||||
<string name="tab_settings">SETUP</string>
|
||||
|
||||
<!-- Media notification channel -->
|
||||
<string name="channel_playback">Playback</string>
|
||||
</resources>
|
||||
12
app/src/main/res/values/themes.xml
Normal file
12
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- The Android-XML theme only matters before Compose takes over (splash /
|
||||
window background). All real UI styling lives in Kotlin (ui/theme/*.kt).
|
||||
We inherit from a Material3 no-action-bar base and paint the window the
|
||||
same near-black the Compose theme uses, so there is no flash on launch. -->
|
||||
<style name="Theme.Sizzletracker" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:statusBarColor">@color/window_background</item>
|
||||
<item name="android:navigationBarColor">@color/window_background</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
app/src/main/res/xml/usb_device_filter.xml
Normal file
7
app/src/main/res/xml/usb_device_filter.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Match any USB device that exposes an Audio class interface (class 0x01,
|
||||
which covers USB-MIDI streaming and USB audio). The system uses this to
|
||||
offer our app when such a device is attached. -->
|
||||
<resources>
|
||||
<usb-device class="1" />
|
||||
</resources>
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.reactorcoremeltdown.sizzletracker.io
|
||||
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Arrangement
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Pitch
|
||||
import com.reactorcoremeltdown.sizzletracker.model.Project
|
||||
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Verifies the `.sng` reader/writer against the reference desktop format. */
|
||||
class SngFormatTest {
|
||||
|
||||
private val reference = """
|
||||
version 1
|
||||
bpm 120
|
||||
sig 4 4
|
||||
|
||||
block A 16 4
|
||||
roll ####
|
||||
track T1 0
|
||||
0 C-4 64 01
|
||||
4 E-4 .. ..
|
||||
8 OFF .. ..
|
||||
endblock
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun parsesReferenceExample() {
|
||||
val p = SngFormat.import(reference)
|
||||
|
||||
assertEquals(120f, p.tempoBpm, 0.001f)
|
||||
assertEquals(TimeSignature.FOUR_FOUR, p.timeSignature)
|
||||
|
||||
val block = p.patterns[0]
|
||||
assertEquals("A", block.name)
|
||||
assertEquals(16, block.length)
|
||||
|
||||
val c0 = block.cell(0, 0)
|
||||
assertEquals(Pitch.parse("C-4"), c0.note)
|
||||
assertEquals(0x64, c0.velocity) // velocity is hexadecimal
|
||||
assertEquals(1, c0.channel)
|
||||
|
||||
val c4 = block.cell(0, 4)
|
||||
assertEquals(Pitch.parse("E-4"), c4.note)
|
||||
assertEquals(0x7F, c4.velocity) // ".." -> default (full MIDI velocity)
|
||||
assertEquals(1, c4.channel) // ".." -> inherit track channel (0 -> 1)
|
||||
|
||||
assertTrue(block.cell(0, 8).isNoteOff)
|
||||
|
||||
// roll "####" enables block 0 on beats 0..3 of its own lane.
|
||||
for (beat in 0..3) assertNotEquals(Arrangement.EMPTY, p.arrangement.patternAt(0, beat))
|
||||
assertEquals(Arrangement.EMPTY, p.arrangement.patternAt(0, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripsNotesAndRoll() {
|
||||
val p = Project()
|
||||
p.tempoBpm = 140f
|
||||
p.timeSignature = TimeSignature.FIVE_FOUR
|
||||
p.patterns[0].cell(0, 0).apply { note = 60; velocity = 0x40; channel = 3 }
|
||||
p.patterns[0].cell(1, 5).apply { note = 67; velocity = 0x7F; channel = 2 }
|
||||
p.arrangement.set(0, 0, 0)
|
||||
p.arrangement.set(0, 1, 0)
|
||||
|
||||
val restored = SngFormat.import(SngFormat.export(p))
|
||||
|
||||
assertEquals(140f, restored.tempoBpm, 0.001f)
|
||||
assertEquals(TimeSignature.FIVE_FOUR, restored.timeSignature)
|
||||
restored.patterns[0].cell(0, 0).let {
|
||||
assertEquals(60, it.note); assertEquals(0x40, it.velocity); assertEquals(3, it.channel)
|
||||
}
|
||||
restored.patterns[0].cell(1, 5).let {
|
||||
assertEquals(67, it.note); assertEquals(0x7F, it.velocity); assertEquals(2, it.channel)
|
||||
}
|
||||
assertNotEquals(Arrangement.EMPTY, restored.arrangement.patternAt(0, 0))
|
||||
assertNotEquals(Arrangement.EMPTY, restored.arrangement.patternAt(0, 1))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user