All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- CI now signs release APKs with a persistent keystore stored as a GitHub
Actions secret (
RELEASE_KEYSTORE_BASE64). Previously the debug keystore was regenerated on every runner, so each build produced a differently-signed APK that Android refused to install over an existing installation.
- Fixed audio quality degrading after prolonged playback, eventually causing
retrigger clicks even on simple 2-step patterns. Root cause: the fast trigger
path called
play(source)on every hit, which internally callssetSource()each time. audioplayers'SoundPoolManagerappends an entry to itsurlToPlayerscache on everysetSource()call — even on cache-hits — and never removes entries. After minutes of continuous playback the list grew to thousands of entries; lock contention on the synchronized cache block introduced timing jitter that causedstop()to arrive at SoundPool before the previous stream had fully decayed, producing a click. Fix: usesetVolume()+resume()in the fast path instead, relying on thesetSource()already called per slot ininit(). - Fixed click on the 6th+ consecutive Kick 808 hit. With 4 slots per track at
120 BPM, slot reuse occurred at exactly 500 ms — the same as the Kick 808
sample duration — creating a race between
stop()and SoundPool's own natural-completion cleanup at the WAV fade-out boundary. Increased_kSlotsPerTrackfrom 4 to 6; slot reuse now occurs at 750 ms, 250 ms past every preset's natural end, ensuringstop()is always a confirmed no-op on an already-silent stream.
- Eliminated retrigger click when the same sample is fired before the previous
hit has decayed to silence (e.g. two Kick 808s on adjacent steps). Root cause:
stop()on a SoundPool stream abruptly zeros the audio output at whatever amplitude the waveform is at, producing a sharp click transient. Fix: each track now uses two SoundPool players (ping-pong slots). On every trigger the engine advances to the next slot and stops only that slot's previous stream — from two or more triggers ago, so its amplitude is well into decay. The stream from the immediately preceding trigger is left to play out naturally; the waveform is never cut at peak amplitude. Four slots per track are used so that even long samples such as HH Open (600 ms) decay to ~5 % amplitude before their slot is reused at 120 BPM.
- Sequencer players switched from
PlayerMode.mediaPlayer(Android MediaPlayer) toPlayerMode.lowLatency(Android SoundPool). Preset WAV data is loaded into SoundPool memory once at startup; each trigger costs ~2 platform-channel calls with no per-hitprepare()overhead (~1 ms latency vs. the previous 30–100 ms). This eliminates the gap that caused crackling on consecutive hits of the same track (e.g. two kick 808s in a row). - A dedicated
_previewPlayerinmediaPlayermode is now the sole player that callsseek(). It handles trim preview, duration probing, and trimmed-track playback. This separation keeps seek latency out of the sequencer hot path entirely. - Tracks with trim points set are transparently switched to a
mediaPlayer-mode sequencer player (viasetTrim) and back tolowLatency(viaclearTrim) so that seek remains available for trimmed playback without affecting untrimmed tracks.
- Reduced audio crackling on synthesised presets by applying a 256-sample (~5.8 ms) linear fade-in and fade-out when encoding generated WAV files. Noise-based sounds (snare, hi-hats, clap) previously produced a non-zero first sample that caused an audible click each time the player restarted.
- Eliminated click when a long sample (open hi-hat, cowbell) is cut off by a rapid retrigger on the same track. The player is now silenced via
setVolume(0)immediately beforestop(), ensuring the output is already at zero by the time the MediaPlayer transitions to stopped state. - Removed
setSource()from the real-time trigger path. Sources are pre-loaded ininit()and reloaded only when the preset or file changes, cutting one platform-channel round-trip and a MediaPlayer re-preparation cycle from every hit.
- Unit test suite: 30+ tests covering constants,
SequencerModellogic (BPM clamping, step toggling, velocity, clear), and DSP utilities (WAV header, envelope, drum generators) mocktaildev dependency for mock-based testing without code generationscripts/pre-commit.shandscripts/install-hooks.sh— runsh scripts/install-hooks.shonce after cloning to block commits when tests failflutter teststep added to CI so tests must pass before every APK build
- DSP functions (
buildWav,dspEnv, drum generators) extracted fromaudio_engine.dartintolib/audio/dsp_utils.dartso they are importable in tests SequencerModelnow accepts an optionalAudioEngineconstructor parameter for dependency injection in tests; production behaviour is unchanged
- Per-pad velocity: long-press any pad to open a configuration sheet and set its velocity (0–100%); velocity scales the track volume for that hit
- Dot indicator appears at the bottom of any pad whose settings differ from the default (velocity < 100%), visible on both active and inactive pads
- Velocity persists across app restarts; clearing all steps also resets all velocities to default
- Trim editor progress bar is now always visible, sitting at the start position when idle rather than only appearing during playback
- Track muting: tap the speaker icon on any track label to instantly mute or unmute that track; muted tracks are silenced during sequencer playback and the track name dims to indicate mute state; mute state persists across app restarts
- Tapping the track name now opens the track settings sheet (previously any tap on the label area opened settings)
- Trim editor play button now shows a pause icon while previewing instead of a stop icon
- Trim editor shows a progress bar with a moving circular indicator while previewing, so you can see how far through the trimmed region playback has reached
- Export N loops of the sequence to a 44100 Hz 16-bit stereo WAV via the share icon in the app bar; the OS share sheet lets the user save it to files, send it, or open it in a DAW
- Export sheet shows loop count (1–16), total duration preview, and a progress bar during rendering
- Tracks with non-WAV custom samples are silenced and flagged with a warning in the sheet
- Recordings are now saved as WAV instead of M4A, making them directly usable in the exporter and compatible with any audio tool
share_plusadded as a dependency for sharing the exported file
- Sample library now persists display names across app restarts via an
index.jsonfile in the library directory - Recordings use timestamp-based filenames, eliminating name collision overwrites
- Rename no longer renames the file on disk — only the display name in the index is updated
- Existing libraries without an index are automatically migrated on first launch
- Stop button now silences all playing samples immediately; previously long samples would continue until they finished naturally
- Track sample and volume settings were not restored on app restart.
AudioEngine.init()was only called lazily on first play, so the restore loop inSequencerModel.init()threw aRangeErrorwhen trying to set volume on uninitialised players, aborting the loop before most tracks were processed and preventingnotifyListeners()from firing. The engine is now initialised eagerly at startup before state is restored.
- Track label now shows a single
tuneicon button instead of separate LOAD, TRIM, and volume controls; tapping it opens a track settings sheet containing volume slider, sound picker, and trim editor in one place - Settings icon is tinted in the track colour when a custom sound or trim is active, providing at-a-glance status
- Per-track non-destructive sample trimming: TRIM button opens an editor sheet with a range slider to set start and end points; the original file is never modified
- Trim state is persisted across app restarts
- Active trim is indicated by the TRIM button lighting up in the track colour; the × button clears it
- Audio players switched from SoundPool (
lowLatency) to MediaPlayer (mediaPlayer) mode to enable seek() support required for trim playback
- Per-track volume slider in the track label area (0–100%); volume is persisted across app restarts
- Track sample selections (preset choice, custom file path and name) are now persisted to
shared_preferencesand restored on next launch; previously only steps and BPM were saved
- Triggering a sample no longer stops samples playing on other tracks; root cause was each AudioPlayer independently requesting
AUDIOFOCUS_GAIN, causing Android to signal other in-app players to stop. Fixed by settingAudioFocus.noneon all players - Samples on the same track no longer overlap when re-triggered; a generation counter ensures a stale stop()→play() sequence is abandoned if a newer trigger has already started
- Sequence steps and BPM are now persisted to
shared_preferencesand restored on next launch; previously the sequence was lost when the app was killed in the background
- Record audio from the device microphone directly in the sound picker
- Persistent sample library — recordings are saved to app storage and survive restarts
- Rename saved samples via the pencil icon in the library list
- Delete saved samples via the trash icon
- Library samples can be loaded onto any track with the LOAD button
- Pulsing recording indicator while mic is active
- Built-in preset sound library: Kick 808, Kick Hard, Snare, Rim Shot, HH Closed, HH Open, Clap, Tom, Cowbell
- LOAD button opens a sound picker sheet — choose a preset or browse for a custom audio file
- Track label updates to show the active preset or loaded filename
- Tracks now always have a named preset active; custom files override the preset
- Clearing a custom file (×) reverts the track to its last selected preset
- Load custom audio samples per track via the system file picker
- Display loaded sample filename (truncated) in each track label
- Clear custom sample with the × button to revert to the synthesised default
- 4-track step sequencer with 16 steps per track
- Synthesised drum sounds (kick, snare, closed hi-hat, open hi-hat) generated in Dart — no bundled audio files required
- BPM control (40–300 BPM); long-press ±10 BPM shortcut
- Play/stop transport with immediate first-step trigger
- Clear all steps button
- GitHub Actions CI workflow producing a signed release APK artifact