Navigation and value-step actions (Nav Up/Down/Left/Right, Next/Prev
Column, Increment/Decrement) now auto-repeat while their control is held —
fire once on press, then accelerate from 320ms to 45ms per step — and stop
the instant it's released. Implemented ONCE as a shared repeater in
InputRouter (pressRepeat/releaseRepeat, keyed by the physical control id),
so all three hardware inputs get identical behaviour:
* Keyboard: the OS's own key auto-repeat is now ignored (held notes sustain
instead of retriggering) and holds are driven by the shared repeater;
key-up ends it.
* Gamepad: buttons, D-pad hat AND analog sticks repeat (they all funnel
through controlDown/controlUp); releasing the main control stops it,
releasing a chord modifier does not.
* MIDI: a momentary nav CC repeats between its press (value>=64) and
release (value<64).
One-shots (transport, tab switch, notes, clear) are unaffected — they fire
once even when held. InputRouter takes an injectable dispatcher so the new
InputRouterTest drives the accelerating schedule on a virtual clock
(verifies repeat-while-held, stop-on-release, one-shot-fires-once, and that
an unrelated release doesn't stop the active repeat). All unit tests pass;
on-device smoke confirms single-press nav + transport still work, no crash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Single tap now acts immediately instead of just selecting: an empty tile
opens the device picker, a filled tile opens its settings editor (this was
the double-tap behaviour; the highlight-only single-tap and the double-tap
are gone). Long-press clears the tile — but now via a confirmation dialog
("Clear tile") rather than wiping instantly, since clearing discards the
device and its settings with no undo (matches the preset DELETE confirm).
Long-pressing an empty tile does nothing.
Removes the now-unused per-tile `selected` state/highlight and onDoubleTap.
Verified on-device: filled→editor, empty→picker, long-press→confirm(Cancel
keeps the tile).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two changes that cut playback-time UI jank (measured on-device):
- Expose a deduped isPlaying StateFlow (maps the transport flow through
distinctUntilChanged) and collect THAT in the transport toolbar, so the
PLAY/PAUSE button recomposes on play↔stop instead of ~12×/second as the
playhead advances (the full transport emits every line).
- Precompute the pattern grid's fixed-width text (gutter hex, note names,
velocity/channel) into class-load lookup tables. The grid redraws on
every playhead line and scroll pixel, and the old toString/padStart
formatting allocated a String per cell per frame — steady draw-phase
garbage. The draw is now allocation-free (also improves GlyphCache hit
rate via stable string instances).
gfxinfo over ~12s of playback on the busy track (moto g84):
janky frames 47.2% → 32.0%; 99th-pct frame 129ms → 53ms;
missed vsync 40 → 10; 50th 18ms → 14ms.
No engine/audio change. Remaining jank is the auto-follow scroll
redrawing the whole grid each line (everything shifts), not addressed here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename the 3-osc synth's display name "NES Synth" → "Synth" (the enum
NES_SYNTH stays as the serialized id, so saved projects/presets are
unaffected).
- Knobs: replace absolute finger-position control (full range mapped onto
the 44dp knob → very touchy) with relative drag over ~280dp of travel,
so fine tweaks are easy and the control isn't sensitive.
- Add a darkwave/industrial preset family: Pluck Bass, Acid Pluck
(punchy plucky basses), Razor Lead, Cold Lead (cutting leads), and
Grit Sub, Detune Wall (harsh, detuned deep basses for layering under
bass pads).
- Preset seeding now records seeded names in the per-type marker instead
of a single flag, so factory presets added in a later version appear on
upgrade without resurrecting presets the user deleted (legacy "1"
markers upgrade cleanly).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Optimization B, superseding v0.4.6's per-bus skip. Instead of iterating
each active bus's whole POLYPHONY pool per sample (calling render() on
idle slots, which merely early-out), fillBlock now builds compact lists
of the voices that are actually sounding — once per block — and the
per-sample mix loop touches only those.
Correctness for mid-block starts: the sequencer can trigger a note part
way through a block, so the allocators queue the chosen voice into this
block's list (queueSeq/queueSmp), keeping note timing sample-accurate.
A per-voice generation stamp dedups the append so a voice that finishes
and is re-allocated within one block is never rendered twice (which would
double its amplitude). Live/bus-audition voices are snapshotted at block
start (a manual press mid-block sounds at the next block, ≤2 ms later —
imperceptible, and the pre-existing ~30 ms output buffer dwarfs it).
On-device (moto g84, 98% battery, real busy track, ~13 voices, 2.0 ms
budget), avg render callback:
baseline 2.00 ms (xruns +250..+515) → A 1.44 ms (+0) → B ~1.00 ms (+0)
B removes ~51 inactive-slot render() calls per sample; ART's virtual
dispatch makes each ~90 ns, so this is a real ~30% gain over A (not the
~1% first estimated from the sample track — measurement corrected it).
Fast-tapping audition during playback stays ~1.0-1.3 ms, xruns +0,
voices drain cleanly (no stuck notes, no double-render).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fillBlock rendered EVERY pooled voice every sample: 32+32 sequencer +
32+32 bus-audition + 16 live/audition voices, render() called ~144×
per frame × 96 frames per callback — even at idle. The bus-audition
pool (64 voices) is only used by the Toolbox MIDI piano yet ran every
sample during normal playback. This fixed cost, not active-voice count,
dominated the render budget.
Now the pools are scanned once per block into per-bus active flags, and
the per-sample mix loop skips whole idle buses and the entire bus-
audition pool when nothing is being auditioned. The sequencer sets the
flag the instant it triggers a note mid-block, so newly-started notes
still sound sample-accurately within the same block.
On-device profiling (moto g84, 98% battery, real busy track, ~11 sample
voices, 96-frame/2.0ms callback):
before: avg 2.00 ms, xruns +250..+515 per ~500 callbacks (audible drops)
after: avg 1.44 ms, xruns +0 (steady)
Fast-tapping the audition keyboard during playback: avg 1.1-1.4 ms,
xruns +0, voices peaked at 16 then drained cleanly — no stuck notes.
With this, under-load render time equals idle, so voice rendering is no
longer the bottleneck; the remaining ~1.4 ms is fixed JNI + per-channel
FX overhead. A block-based per-voice refactor was evaluated and dropped
as unnecessary (it would not reduce that fixed cost).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The subtractive synth's amplitude-envelope release decremented at
`(dt / r) * s` — the rate was multiplied by the sustain level, floored
at 0.001. For any percussive patch (sustain≈0: Square Bass, Pulse Pluck,
Noise Perc), release ran ~1000× too slow, so a 0.2 s tail took minutes.
Since a voice's isActive follows its amp envelope, the voice never freed
and kept rendering (3 osc + filter) indefinitely.
That one bug produced all three reported symptoms:
* missed note-offs / stuck notes needing panic — the note-off fires but
the voice takes minutes to go silent, reading as ignored;
* fast-tapping dropouts on every instrument — released-mid-envelope
voices pile up (whole pool + 8 live audition voices) and CPU climbs
until the audio callback overruns → xruns;
* audition-then-play dropouts — audition notes are already stuck on the
live pool before the sequencer stacks track voices on top.
Release now ramps to zero at a constant 1/r rate, reaching silence
within r seconds from any level (matching SampleVoice's behaviour). The
Sampler/SoundFont path already captured its release rate at note-off, so
it was less affected — but the shared synth voice pinned the CPU budget,
which is why the stutter showed across all instruments.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-device profiling (worst case: 4 channels x 3 oscillators + cutoff & pitch
modulation, dense pattern at 200 BPM) showed the render callback averaging 2.8ms
against a 2.0ms budget with continuous xruns — the hiccups. The cost was the
per-sample Math.pow used for pitch modulation across 3 oscillators x many voices.
Recompute modulated oscillator pitch at control rate (every 16 samples, like the
filter cutoff) instead of per sample; LFO/envelopes move slowly enough that this
is inaudible. Render dropped to ~1.27ms avg with zero new xruns. No crashes under
load (the ConcurrentHashMap + stable SVF filter fixes from 0.4.3 hold).
Patch bump to 0.4.4 (versionCode 11).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address three issues introduced with the 3-oscillator synth:
- Crashes: ToolboxSlot.values was a plain HashMap read on the audio thread while
the UI wrote it (knob drags vs. the synth's ~40-param-per-note reads). Made it a
ConcurrentHashMap so get/put are atomic and reads are lock-free (RT-safe).
- Filter cutoff not affecting the sound: (a) the cheap ladder went unstable at high
cutoff (f > 1 -> NaN -> silent voice) — replaced with a stable Cytomic TPT
state-variable low-pass (with NaN recovery); (b) a voice captured its patch only
at note-on, so live knob turns were inaudible — the engine now refreshes active
voices' patches every block, so cutoff/level/mod changes affect held & playing
notes.
- Hiccups: every note allocated a SynthPatch (+arrays) causing GC dropouts — the
patch is now cached per bus and reparsed only on param/tempo change, so steady
playback allocates nothing on the audio thread.
Patch bump to 0.4.3 (versionCode 10).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add fling/drag vertical scrolling to the pattern grid via Modifier.scrollable
(fractional pixel offset for smooth, non-snapping motion). The view still follows
the playhead during playback (and the cursor when stopped) by default; a finger
flick detaches it into free inertial scrolling, and switching to another block
re-attaches the follow (the scroll state is keyed on the active pattern).
Tap-to-focus is unchanged. Patch bump to 0.4.2 (versionCode 9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two changes that landed together (the note-off work was uncommitted when the synth
rewrite began):
Synth rewrite — replace the simple NES tone generator with a classic Minimoog-style
voice: 3 oscillators (+ noise) → mixer → resonant 4-pole ladder low-pass (own
contour envelope + key tracking) → amplifier (own envelope), plus a tempo-synced
LFO and a 4-slot modulation matrix.
- DSP: SynthVoice rewritten (PolyBLEP oscillators, ladder filter, two ADSRs, LFO,
mod matrix); new SynthPatch parses a slot into an immutable snapshot per note.
PolyBLEP + control-rate cutoff + precomputed osc tuning keep the RT cost down.
- Model: SynthDefs (shared option lists/defaults), rewritten NES_SYNTH params,
NesSynthPresets (8 chiptune factory presets) seeded into the preset library.
- Editor: NesSynthEditor — swipeable cards (Oscillators / Filter / Amp+LFO / Mod
Matrix) with dot pagination; new rotary Knob control (absolute, fader-like
vertical drag: bottom = empty, top = full); enums via RetroDropdown. The shared
slot-audition keyboard (renamed SoundFontKeyboard -> SlotAuditionKeyboard) is
pinned at the editor bottom.
- Engine builds a SynthPatch per note. The enum id stays NES_SYNTH so existing
projects still route; the old single `wave` param is dropped (amp envelope keys
carry over).
Track-tied note-offs — a note-off ("===") is now tied to its track, not a channel:
it has no velocity/channel of its own (shown as ".." and non-editable) and simply
releases whatever that track last played, on the bus(es) that note routed to
(AudioEngine.trackLastChannel). `.sng` export writes "OFF .. ..".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each transport-toolbar row now distributes its elements across the full width
(Arrangement.SpaceBetween) instead of clustering at the left: dropped the
per-row horizontalScroll (which gave the row infinite width and defeated any
distribution) and the manual landscape spacer. Rows no longer scroll — elements
fill the bar edge to edge. Patch bump to 0.3.2 (versionCode 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Configure the release variant's output filename (via the legacy applicationVariants
API) so the unsigned release APK is stamped with its versionCode — e.g.
space.rcmd.android.sizzle_4.apk — to simplify uploading to the private F-Droid
repo. Staging keeps its app-staging.apk name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lay out and place the SF2/XI loader's audition keyboard like the toolbox and
tracker keyboards: extract it into SoundFontKeyboard (an octave selector above two
weighted octaves) and pin it at the bottom of the device editor, with the controls
scrolling in a weighted region above (1.7 : 1, matching the toolbox tab) — so it's
the same height and always visible instead of scrolling away. Patch bump to 0.3.1
(versionCode 4).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- SF2/XI loader: add a Loop XFade control (ms, 0-500) next to the loop toggle.
SampleVoice crossfades the loop tail into its head for a click-free seam even
when the file's loop points don't match; 0 ms keeps the previous hard wrap.
Threaded through VoiceSample and all noteOn call sites.
- Add a reusable RepeatButton: fires on press then auto-repeats while held with
an exponentially shrinking interval (stops on release/scroll-off). Applied to
every +/- stepper (tempo, MIDI channel, arrangement repeats, audition/keyboard
octave, and the new crossfade).
- Bump version to 0.3.0 (versionCode 3) per the minor-per-change policy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- Add GPL-3.0 LICENSE and SPDX headers (GPL-3.0-or-later) to every source file
(58 Kotlin, the C++ file, and the sample-pack HTML tool).
- README: state the GPL-3.0-or-later license (+ badge); fix stale "256 beats"
-> "256 bars"; fix build instructions (the Gradle wrapper is committed);
mention master recording and the sample-pack tool.
- DEVELOPER_HANDOVER: resync with the codebase — corrected the wrapper note,
architecture diagram, full file map, the add-instrument/effect guide, Oboe
(now default with fallback, not an opt-in toggle), moved SF2/XI to implemented,
and added recently shipped features with an honest remaining/optional list.
- Fix a pre-existing broken unit test: SngFormatTest called the old
SngFormat.import(text); the API is importInto(project, text). Tests compile
and pass again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Denominate the canvas in bars (lengthBars, default 256) instead of a raw beat
count; canvasBeats(sig) = lengthBars * beatsPerBar, so it spans exactly 256
bars in any signature. Backing arrays sized for the widest case (5/4 -> 1280
beats). Playback still stops at lastFilledBeat, so the wider empty canvas adds
no trailing silence.
- Roll: draw with viewport culling (iterate only the beats scrolled into view)
so the ~16x-wider canvas stays cheap to redraw and scrolls smoothly.
- Persistence: store bars= (was len= in beats) and write cells only up to the
last filled beat; old files without bars= load the default 256-bar canvas with
their cells intact. .sng import grows the canvas in bars as needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Theme: pick a light or dark Material base scheme by palette luminance and map
the palette onto the container/variant/outline/secondary roles components
actually read, so cards and secondary text stay legible on bright themes.
- Audio: remove the experimental stereo-FX rendering and the tape-delay per-head
balance (both worked poorly); each channel runs a single mono FX chain again.
Master output and recording remain stereo (dual-mono content).
- Tools: add tools/samplepack-builder.html, a standalone page for assembling
Sampler .zip preset bundles (16 note-labelled drag-n-drop pads, audition,
16-bit mono WAV re-encode, duplicate-sample detection).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stereo effects (experimental), off by default and persisted:
- When on, each mono insert effect runs a second instance for the right
channel so upstream stereo (e.g. a delay's width) carries into later
effects like the reverb; the delay stays single-instance (nativeStereo).
- Settings toggle under Audio Devices; rebuilds the FX chains on change.
Seamless system bars:
- SizzleTheme tints the system navigation bar to the palette's surface
(matching the bottom tab bar) and the status bar to the background, with
light/dark icon appearance by luminance, re-applied on theme change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- fillBlock now outputs interleaved stereo; AudioTrack and native Oboe
configured for 2 channels; MasterRecorder writes a stereo WAV.
- AudioEffect gains processStereo() (default mono-collapse); the tape delay
overrides it to pan each of its 4 heads with a level-preserving linear pan
(feedback still uses the mono sum, so loop behaviour is unchanged).
- New per-head balance param + a horizontal balance slider under each head's
division fader (center-detented, L/C/R readout), with a revision read so it
redraws.
Note: only the delay produces stereo width; other insert effects stay mono
and collapse upstream stereo, so place the delay last to keep its panning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recording & audio:
- Master-bus WAV recorder: arm + FX-tail + SAF output folder on the mixer
toolbar; allocation-free capture (ring buffer -> writer thread).
- Oboe is now the default backend; falls back to AudioTrack only on init
error and shows a popup. Removed the manual engine toggle.
- Arpeggiator: fixed loop-boundary slip (phase-continuous), monophonic
(no chord/voice pile-up), and a per-bar reset toggle.
- AmbienceReverb: 8-line FDN (was 16) to fit the real-time budget; per-bus
soft-clip limiter; per-channel non-finite guard on the master sum.
UI / layout:
- Responsive landscape layouts: side nav rail; tracker grid beside the
arrangement/keyboard; toolbox tiles beside a wider keyboard; two-column
mixer strips; collapsed transport toolbar.
- Two-column instrument/effect editors; reverb controls paged with dot
pagination.
- Arrangement: per-lane mute toggles; center-follow scrolling.
- Tracker toolbar: BLK dropdown, SKIP moved by OFF/KBD, glyph edit buttons.
- Sampler: normalize button, per-pad volume snap, single CLEAR.
- Import error popups; content validation on all file loaders.
- App themes: import/export/delete/dropdown library (stock themes protected).
Persistence & QoL:
- Persist keyboard MIDI channel/octave; remember loaded project in the
settings dropdown; New Project button.
Cleanup:
- Removed dead code and fixed audio-thread allocation hot spots
(per-sample iterators, Math.random on the RT thread, etc.).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>