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