Files
sizzletracker-android/docs/DEVELOPER_HANDOVER.md
Reactorcoremeltdown 4bd8aac4d5 Rename application identifier to space.rcmd.android.sizzle
Global rename of the app/package identifier from
com.reactorcoremeltdown.sizzletracker to space.rcmd.android.sizzle:

- build.gradle.kts: applicationId + namespace.
- Move the Kotlin source tree (main + test) and rewrite every package/import.
- native_audio.cpp: JNI symbol names (Java_space_rcmd_android_sizzle_...) so the
  Oboe callback still links against the relocated NativeAudioBridge.
- docs: package/file-map header.

The desktop-project GitHub URLs (github.com/reactorcoremeltdown/sizzletracker)
and the Reactorcoremeltdown copyright/SPDX author identity are intentionally left
unchanged. Note: the new applicationId installs as a separate package (existing
installs won't upgrade in place).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:59:51 +02:00

21 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. Let it download the SDK for compileSdk 34.
  3. Pick a device/emulator running Android 8.0 (API 26) or newer and press Run.

The Gradle wrapper (gradle/wrapper/gradle-wrapper.jar) is committed (Gradle's recommended practice), so ./gradlew assembleDebug works on a fresh clone with no extra setup. A staging build type (./gradlew assembleStaging) gives release-like performance while still using the debug signing key.

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 ─────────┘  ──▶ Oboe / 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

space.rcmd.android.sizzle
├── 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; canvas denominated in bars, up to 256), LoopRegion A/B
│   ├── Mixer.kt            Mixer, MixerChannel (4 channels; volume/mute/solo/limiter)
│   ├── Toolbox.kt          ToolboxType (all instruments+effects), ParamSpec, ToolboxSlot, SamplerPads
│   ├── AmbiencePresets.kt  Factory reverb spaces seeded into the preset library
│   └── Project.kt          The whole song
│
├── input/                  The unified input layer
│   ├── InputAction.kt      The neutral action vocabulary
│   ├── InputActions.kt     Bindable-action catalogue (for the rebind/learn UI)
│   ├── InputRouter.kt      Flow-based message bus (+ learn mode)
│   ├── BindingStore.kt     Persists gamepad/MIDI bindings
│   ├── 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
│   ├── SampleVoice.kt      Pitched + sliced playback of a decoded sample
│   ├── SampleStore.kt      Process-wide decoded-PCM cache + WAV encode/decode
│   ├── SampleRecorder.kt   Ad-hoc mic/USB capture into a sample
│   ├── SoundFontLoader.kt  Extract PCM from an .sf2 / .xi into the SampleStore
│   ├── Effects.kt          Insert DSP: TapeDelay, Filter, Bitcrusher, GraphicEq (+ AudioEffect API)
│   ├── AmbienceReverb.kt   FDN reverb processor
│   ├── MasterRecorder.kt   Renders the master bus (with FX tail) to a WAV
│   ├── NativeAudioBridge.kt  JNI bridge to the Oboe callback (calls fillBlock)
│   └── AudioEngine.kt      Oboe/AudioTrack + sample-accurate sequencer + voice/FX mixing
│
├── cpp/native_audio.cpp    Low-latency Oboe stream; RT callback pulls audio via JNI
│
├── io/                     Human-readable text persistence
│   ├── PresetIo.kt         Instrument/effect preset  <->  KEY=VALUE text
│   ├── PresetLibrary.kt    On-disk preset library (+ .zip sample bundles)
│   ├── ProjectIo.kt        Whole song  <->  internal .sng text (full fidelity)
│   ├── ProjectStore.kt     Autosave / load of the current project
│   ├── SongLibrary.kt      Named project save/load browser backing
│   ├── SngFormat.kt        Desktop-compatible .sng interchange read/write
│   ├── SettingsStore.kt    App settings (theme, recording dir, keyboard) via DataStore
│   ├── ThemeIo.kt          RetroPalette  <->  .szt text
│   └── ThemeLibrary.kt     On-disk theme library (import/export/delete)
│
├── playback/
│   ├── EnginePlayer.kt     SimpleBasePlayer wrapping the engine for MediaSession
│   └── PlaybackService.kt  MediaSessionService: background playback + media notification
│
└── ui/
    ├── AppViewModel.kt     Screen state + THE single InputAction handler
    ├── App.kt              Tab shell
    ├── Responsive.kt       Portrait/landscape layout helpers (nav rail, two-column)
    ├── StackedKeyboard.kt  On-screen audition / punch-in piano
    ├── theme/Theme.kt      RetroPalette + monospace typography + SizzleTheme
    ├── components/         Widgets (RetroButton/Dropdown…), GlyphCache, Piano
    ├── tracker/            TrackerScreen, PatternGrid, ArrangementRoll
    ├── mixer/MixerScreen.kt
    ├── toolbox/            ToolboxScreen, ParamControl, Sampler/SoundFont/Delay/Lfo editors
    └── 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 the engine how to sound it:
    • Instrument: handle its type where the engine picks a voice per channel (see how NES_SYNTH, SAMPLER, and SOUNDFONT are dispatched in AudioEngine).
    • Signal effect: add a processor in audio/Effects.kt implementing AudioEffect and register it in AudioEffect.create(...); the per-channel insert chain picks it up automatically.
    • MIDI effect (arp/transpose/LFO-style): hook into the sequencer instead of the signal path (see advanceArps / applyLfos in AudioEngine). The Toolbox picker, parameter editor, and preset save/load all work automatically from the ParamSpec list. A device with many parameters can ship a bespoke editor (see ui/toolbox/DelayEditor.kt, SamplerEditor.kt).

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, and the feature set below is implemented and working; the short "Remaining / optional" list at the end is polish and interchange niceties, not functional gaps.

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 ✓ (default): 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. Oboe is the default engine; it falls back to the AudioTrack loop only if it fails to initialise (or the native lib is absent), surfacing a popup with the reason (there is no user toggle). 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.)
  • SF2 / XI loader instrument ✓: audio/SoundFontLoader.kt extracts PCM from an .sf2 / .xi into the SampleStore, played back through the same SampleVoice path as the Sampler; edited via ui/toolbox/SoundFontEditor.kt (root note, volume, ADSR).
  • Master recording ✓: audio/MasterRecorder.kt renders the stereo master bus (plus a configurable FX tail) to a WAV in a user-chosen folder (SAF).
  • Stereo output & recording ✓: fillBlock renders interleaved stereo and the recorder captures two channels (voices/inserts are mono, so both channels carry the same sum).
  • Responsive landscape layout ✓ (ui/Responsive.kt): nav rail, side-by-side screens, two-column mixer/editors, wider on-screen keyboards for tablets/handhelds.
  • Arrangement niceties ✓: 256-bar canvas with viewport-culled drawing, per-lane mutes, and centre-follow scrolling.
  • Per-bus soft-clip limiter ✓, sampler QoL (fire-on-tap, clear, normalize), theme import/export, and preset .zip bundle import/export.
  • Sample-pack builder ✓: tools/samplepack-builder.html, a standalone offline page that assembles importable Sampler .zip bundles.

Remaining / optional

  • Byte-exact desktop .sng compatibility. io/SngFormat.kt reads/writes the reference line format and round-trips musically; a field-for-field byte match with the CLI project layout is the remaining interchange nicety.
  • Full C++ audio port. The DSP currently runs in Kotlin behind the Oboe callback (via JNI). Porting the hot path to C++ would remove JVM/GC from the real-time thread — a performance hardening step, not a functional gap.
  • Route the Oboe backend through the chosen output device (Oboe setDeviceId), matching the AudioTrack path's device selection.

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.