Files
sizzletracker-android/docs/DEVELOPER_HANDOVER.md
Reactorcoremeltdown 3cef1b4e85 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>
2026-07-13 22:15:13 +02:00

17 KiB
Raw Blame History

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

  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, blockendblock 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.