Initial commit: Sizzletracker Android music tracker

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

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

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

342
docs/DEVELOPER_HANDOVER.md Normal file
View File

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