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>
17 KiB
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.
- Open the project root in Android Studio. It will detect the Gradle build.
- If prompted, let it create the Gradle wrapper (or run
gradle wrapper --gradle-version 8.9from a shell that has Gradle). - Let it download the SDK for
compileSdk 34. - 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:
-
One neutral action type. Every non-touch device is translated into an
InputAction(seeinput/InputAction.kt) and pushed throughInputRouter. 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. -
One song object.
model/Project.ktholds the entire song in memory. The UI edits it; the audio engine reads it; the file format serializes it. -
Two threads. The UI runs on the main thread (Jetpack Compose). The sound runs on a dedicated high-priority thread inside
AudioEngine. They share theProjectobject. Because the audio thread only ever reads simpleInt/Floatfields, 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
editFocusedruns 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 ParamSpecs. 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
- Add an entry to
ToolboxType(model/Toolbox.kt) with itsParamSpeclist. - Teach
AudioEnginehow to render/process it (today onlyNES_SYNTHis rendered; seesynthParamsForTrack). 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
- Add a case to
InputAction. - Emit it from each source (
KeyboardInput,GamepadInput,MidiInput) and/or a Compose gesture. - 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 aMAX_PRIORITYthread. 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 ONECanvaswith a cachedrememberTextMeasurer, 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 ofTextnodes 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.patternsholds 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 byaudio/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 intoMidiInput.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
AudioTrackand the recorder'sAudioRecordviasetPreferredDevice(API 28+), so a USB DAC / USB audio interface can be used for playback and recording. - Full MediaSession ✓:
playback/PlaybackServiceis now a media3MediaSessionServicehosting a session that wraps the engine viaEnginePlayer(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.sngread/write (io/SngFormat.kt) matching the reference sizzletracker line format (version/bpm/sig,block…endblockwithroll/track/steps) — covered by passing JVM unit tests inapp/src/test. - Background playback foreground service with Play/Pause/Stop notification.
- Oboe / AAudio native output ✓ (opt-in):
src/main/cpp/native_audio.cppopens a low-latency Oboe stream whose real-time callback pulls audio via JNI (audio/NativeAudioBridge.kt) from the SAMEAudioEngine.fillBlockthe 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 (ndkVersioninapp/build.gradle.kts) + CMake + thecom.google.oboe:oboe:1.10.0prefab AAR. All packaged.sofiles are 16 KB page-aligned (Android 15 / Google Play requirement): NDK r27 aligns ELF load segments to 16 KB and ships a 16 KB-alignedlibc++_shared.so, Oboe 1.10.0's lib is aligned, ourCMakeLists.txtalso passes-Wl,-z,max-page-size=16384, and AGP stores each.soat a 16 KB-aligned offset in the APK. (Verify withllvm-readelf -l <lib>.so→ LOADAlign 0x4000.)
Scaffolded — remaining
- 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 existingSampleStore/SampleVoicepath. (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,ProjectIoround-trip,PresetIoround-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.