Compare commits

...

16 Commits

Author SHA1 Message Date
Reactorcoremeltdown
924b84a4d5 Keep MIDI input alive with the screen off / app backgrounded (v0.21.1)
MIDI (and the MIDI clock) were started in onStart and stopped in onStop, so
turning the screen off — which drives the Activity to onStop — closed the MIDI
ports and dropped controller input. Move their lifecycle to onCreate/onDestroy so
input persists for the Activity's whole lifetime; onStop keeps only the project
autosave. The audio engine already runs a continuous output stream via the started
PlaybackService, which keeps the process alive (verified at PERSISTENT_SERVICE_ADJ
when backgrounded) to receive events with the screen off. USB MIDI needs no runtime
permission, so it works unconditionally.

Trade-off: the controller stays claimed by the app while backgrounded, and is
released only when the Activity is torn down (task removed / finishing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:52:14 +02:00
Reactorcoremeltdown
7296a3a72b MIDI-learn: map controller knobs to any toolbox device parameter (v0.21.0)
Every instrument/effect editor gains a "⇄ MIDI" button in its toolbar that opens
a MIDI-learn popup listing the device's adjustable parameters. Tap LEARN on a row,
move a knob/fader on a connected MIDI controller, and its CC binds to that
parameter; the CC's 0..127 sweep then drives the parameter's full range live
(scaled to the spec range, rounded for integer params, nearest choice for enums).

Reuses the existing RawControl path — MidiInput already forwards unbound CCs — so
no parser change was needed. Bindings are stored in the slot's value map under a
reserved `midicc@<param>` key, so they travel with the project/preset and are
thread-safe via the slot's ConcurrentHashMap; one physical control drives one
parameter. Learn is auto-disarmed when the popup or editor closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:43:50 +02:00
Reactorcoremeltdown
dfb6ee9c9b Route MIDI notes by channel; punch-in from MIDI on the Tracker tab (v0.20.0)
Incoming USB/BLE MIDI notes were dropping their channel and always playing the
generic default-synth pool, ignoring the mixer routing. Now the parser keeps the
channel nibble (exposed 1-based, 1..16) on InputAction.NoteOn/NoteOff, and the
ViewModel routes a channelled note through engine.busNoteOn/busNoteOff — sounding
the mixer track(s) whose midiChannel matches, through their instrument and full
insert chain, exactly like the on-screen keyboard. Channel-less sources (physical
keyboard) keep the previous audition path.

On the Tracker tab with punch-in armed, a MIDI note now also punches into the
focused cell, recording its own velocity and channel and advancing by the skip
step (punchNote gained velocity/channel parameters). Off the tracker tab or with
punch-in off, MIDI only auditions.

Note: routing is strict, so a note is audible only on a track whose MIDI channel
matches and that has an instrument assigned (new projects start with buses on the
empty channel 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:26:50 +02:00
Reactorcoremeltdown
9fdef7fee7 Play starts from the arrangement selection; Stop clears it (v0.19.0)
Highlight a cell on the arrangement roll and Play now begins at that selection's
first beat instead of always from the top. Stop clears the highlight, so the next
Play resumes from the beginning. The rectangular selection is hoisted from
ArrangementRoll to the ViewModel (four ints) so the transport can read/clear it;
engine.play() gains a startBeat that seeks playlistIndex in the built playlist
(A/B expansion respected; falls back to the top if the beat isn't in it) or
currentLine in pattern-loop mode. Loading/importing a project also clears the
selection so a stale one can't apply to a new arrangement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:04:47 +02:00
Reactorcoremeltdown
96bbc5a4cc MIDI clock sync + note output over USB/BLE (v0.18.0)
Add a MIDI Sync settings card (before the binding cards) with Send and Receive
MIDI clock toggles, both off by default and persisted.

Send: a dedicated thread emits clock (0xF8) at 24 PPQN plus Start/Stop on the
transport edges to every attached MIDI device (USB + Bluetooth), and — because a
sequencer should drive gear, not just its tempo — also forwards the tracks'
note-on/off on each cell's MIDI channel. Notes are queued from the audio thread
through a lock-free ring and drained by the send thread, mirroring the mono-per-
track cell lifecycle; stop/pause/panic release everything so external notes never
hang. (Per-channel Arpeggiator/Transposer are internal FX and aren't reflected.)

Receive: MidiInput now forwards real-time bytes to the clock module, which follows
an external clock — Start/Continue starts the transport, Stop pauses it, and the
pulse rate drives the tempo (a tempo + transport follow, not a sample-locked slave).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:10:40 +02:00
Reactorcoremeltdown
682418d69e Move the Backup & Restore card to the bottom of Settings (v0.16.1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:45:42 +02:00
Reactorcoremeltdown
3c4c30bf1f Share full projects as .szgz bundles between instances (v0.16.0)
Add Export/Import project buttons to Settings → Project. A bundle is a ZIP holding
the full .szg project plus every sample WAV it references (Sampler pads, SoundFont),
so unlike a plain .szg — whose sample ids only resolve on the originating device —
it opens complete on another Sizzletracker instance.

Export shares the bundle via the system share sheet. Import replaces the current
project, extracts the WAVs to a persistent content-addressed samples dir, re-points
each slot's padNFile/sfFile at the extracted file, then rehydrates — so the audio is
restored for the session and survives a restart. Invalid files are rejected cleanly.
Covered by ProjectBundleIoTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:45:18 +02:00
Reactorcoremeltdown
b305f0a2b5 Arpeggiate the whole held chord, not just the last note (v0.15.1)
Holding a chord on an arpeggiated channel used to collapse to the last (rightmost)
note and retrigger only that — the other notes were killed by the per-note
releaseBus + single-note arp.start. ChannelArp now holds the whole chord (sorted,
de-duped) and cycles all its notes across the octave range in the chosen mode.

Notes entered on the same line accumulate into one chord via a per-line generation
stamp; a new line starts a fresh chord, and a looping chord keeps its running phase
so it doesn't glitch at the loop point. ChannelArp is made internal to cover the
sequencing with ChannelArpTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:07:28 +02:00
Reactorcoremeltdown
79a1844512 Tap tempo by tapping the BPM readout (v0.15.0)
Tapping the "### BPM" label sets the tempo from the average interval of recent
taps (up to 8, refining as you go); a pause over 2 s starts a fresh count. The
−/+ nudge buttons are unchanged. Purely UI-side — a small remembered list of tap
timestamps, no model/engine changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:07:06 +02:00
Reactorcoremeltdown
8c44ed5151 Add full backup & restore to Settings (v0.14.0)
A new Backup & Restore card saves everything the app persists — songs, presets
(with their sample WAVs), themes, the autosaved project, and the settings +
input-binding DataStore blobs — into one .zip via the system file picker, and
restores from one.

Restore is defensive: it extracts to a staging folder and only swaps it into
place once the archive validates, so a corrupt/wrong file can't destroy current
data; it's zip-slip guarded and runs off the main thread. Since DataStore caches
in memory, restore prompts to reopen the app to finish applying. Covered by
BackupIoTest (round-trip replace, bad-file safety, empty-zip rejection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:28:24 +02:00
Reactorcoremeltdown
3488bac1ca Group and enlarge the toolbox device picker (v0.13.4)
Split the SELECT DEVICE list into Instruments / MIDI effects / Audio effects,
each with a gap and a small header, and bump the device labels 13→18 sp with more
tap padding. The old [INS]/[EFF] prefix is dropped since the grouping conveys it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:11:30 +02:00
Reactorcoremeltdown
5dfbda7c15 Tape bobbin holes: rounded trapezoids, narrow side outward (v0.13.3)
Replace the inward-pointing triangular reel cutouts with rounded trapezoids —
wide toward the hub, narrow toward the rim. Generalize the corner-rounding helper
(roundedTri → roundedPoly) to handle the four-corner shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:54:14 +02:00
Reactorcoremeltdown
d43112f296 Redraw Tape Engine as a real tape transport (v0.13.2)
Reels are now NAB-style: a metal flange with three rotating rounded-triangular
bobbin holes and a hub, instead of chariot-wheel spokes. Tape runs under tension
as straight lines from each reel down to a rounded-rectangle playhead between and
below them (the top tape line is gone). The four controls are vintage round knobs
in a row — wow, flutter, hiss, wow-sync — replacing the sliders/stepper.

Each knob is weighted so all four are visible (the shared Knob's internal
fillMaxWidth text had let the first one swallow the row), and Knob gains an
optional knobSize (default 44 dp, so other editors are unchanged) which the tape
knobs set to 58 dp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:48:31 +02:00
Reactorcoremeltdown
71bb50c775 Add Tape Engine and Granular Glitch visual effects (v0.13.0)
Tape Engine: a varispeed reel-to-reel. Audio runs through a ring buffer read at a
glided 'speed'; the editor draws two spinning reels you press-and-hold to brake,
diving the pitch like a finger on a slowing tape. Wow (slow, tempo-synced via
division) and flutter (fast) wobble the rate; hiss adds tape noise.

Granular Glitch: records the signal and, driven by Intensity, jumps the read head
back to replay short grains (stutter/repeat/octave glitch). A new VizEffect
interface lets the processor publish a decimated scope + playhead; the engine
exposes it per slot (effectScope/effectPlayhead), and the editor draws a dotted
oscilloscope with the jumping playhead over it plus a horizontal Intensity slider.
The scope reads in the Canvas draw phase (redraw only, no recomposition), mirroring
the VU-meter path.

Also make the toolbox device picker scrollable so all types stay reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:20:36 +02:00
Reactorcoremeltdown
ef13a46026 Widen arrangement mute toggles and block headers ~1.5× (v0.12.1)
Bigger, easier thumb targets on the arrangement roll's left column: the mute
toggle 34→51 dp and the block-number header 34→51 dp (laneColWidth 68→102). The
scrollable ruler/roll to the right is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:20:11 +02:00
Reactorcoremeltdown
abdcf77ce5 Stacked keyboards go chromatic when routed to a Sampler (v0.12.0)
A Sampler maps a pad to each MIDI note, so scale filtering left out-of-scale
pads greyed-out and unreachable. When the punch-in / audition keyboard's channel
routes to a bus with a Sampler instrument, bypass the scale filter and enable all
twelve keys; otherwise scale filtering is unchanged. Non-destructive - the
project's scale/root setting is untouched, and switching the channel back to a
synth/SF2 restores filtering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:59:36 +02:00
25 changed files with 2102 additions and 121 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 30
versionName = "0.11.2"
versionCode = 48
versionName = "0.21.1"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -52,7 +52,7 @@ class MainActivity : ComponentActivity() {
app.project, app.audioEngine, app.inputRouter,
app.gamepadInput, app.midiInput, app.keyboardInput,
app.bindingStore, app.presetLibrary, app.songLibrary,
app.settingsStore, app.themeLibrary, app.applicationContext,
app.settingsStore, app.themeLibrary, app.midiClock, app.applicationContext,
)
}
},
@@ -75,11 +75,15 @@ class MainActivity : ComponentActivity() {
SizzleAppUi(viewModel)
}
}
}
override fun onStart() {
super.onStart()
// Start MIDI listening (USB + BLE) and the MIDI clock here — bound to the
// Activity's WHOLE lifetime (onCreate..onDestroy), not onStart/onStop — so a
// connected controller keeps driving the app while the screen is off. The
// audio engine already runs a continuous output stream via the started
// PlaybackService, which keeps the process alive to receive those events in
// the background; closing the ports on onStop was the only reason input died.
midi.start()
app.midiClock.start()
}
override fun onResume() {
@@ -92,12 +96,21 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
// Autosave the project whenever we leave the foreground, so a later
// system-kill (or crash) resumes from here on next launch.
// system-kill (or crash) resumes from here on next launch. MIDI is
// deliberately NOT stopped here (see onCreate / onDestroy) so controllers
// keep working with the screen off or the app backgrounded.
app.projectStore.save(app.project)
midi.stop()
super.onStop()
}
override fun onDestroy() {
// Release MIDI only when the Activity is actually torn down (task removed or
// finishing), not merely backgrounded / screen-off.
midi.stop()
app.midiClock.stop()
super.onDestroy()
}
// ---- Hardware input forwarding ----
// Gamepad events are intercepted at DISPATCH time — before Compose's focus
// system can consume D-pad / button keys — so USB *and* Bluetooth pads work

View File

@@ -45,6 +45,11 @@ class SizzleApp : Application() {
val gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
val midiInput: MidiInput by lazy { MidiInput(this, inputRouter) }
/** MIDI clock sync (send/receive) over USB + Bluetooth MIDI. */
val midiClock: space.rcmd.android.sizzle.input.MidiClock by lazy {
space.rcmd.android.sizzle.input.MidiClock(this)
}
/** Persists the input binding maps across restarts (DataStore-backed). */
val bindingStore: BindingStore by lazy { BindingStore(this) }
@@ -80,6 +85,8 @@ class SizzleApp : Application() {
// single small read; blocking briefly at cold start keeps the input maps
// authoritative from the first keypress rather than racing an async load.
runBlocking { bindingStore.loadInto(keyboardInput, gamepadInput, midiInput) }
// Feed incoming MIDI real-time (clock / start / stop) to the clock sync module.
midiInput.onRealtime = midiClock::handleRealtime
installCrashAutosave()
}

View File

@@ -78,11 +78,29 @@ class AudioEngine(
private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
// Per-(lane,track) arpeggiator state, driven by advanceArps.
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
// Bumped once per triggered line. Notes reaching an arpeggiator with the same
// stamp are the same line's chord (accumulated into one arp); a new stamp starts
// a fresh chord. Lets a held chord arpeggiate all its notes instead of collapsing
// to the last one entered. Audio-thread only.
private var lineTriggerGen = 0
// Per-(lane,track) MIDI channel the track last played a note on. A note-off cell
// is tied to its track (it has no channel of its own) and releases whatever the
// track last started, on the bus(es) that channel routed to. -1 = nothing yet.
private val trackLastChannel = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }
// ---- outgoing MIDI (send the sequenced notes to external gear) ----
// The sequencer runs on the audio thread, which must not do MIDI I/O; note events
// are packed into a lock-free single-producer/single-consumer ring that the MIDI
// clock thread drains via [pollMidiOut]. Only filled when [midiOutEnabled]. Each
// slot packs (status<<16)|(data1<<8)|data2. Note-on/off mirror the tracks' cells
// (mono per track, matching the internal note lifecycle) on the cell's MIDI channel.
@Volatile var midiOutEnabled = false
private val moutRing = IntArray(256)
@Volatile private var moutHead = 0 // producer (audio thread)
@Volatile private var moutTail = 0 // consumer (MIDI clock thread)
private val moutNote = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }
private val moutCh = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT)
// Cached subtractive-synth patch per bus, reparsed only when the slot changes
// (version) or tempo changes — so note triggers don't allocate a patch each time,
// and active voices can be refreshed per block for live parameter feedback.
@@ -488,9 +506,34 @@ class AudioEngine(
}
// --------------------------------------------------------------- transport
fun play() {
/**
* Start playback. When [startBeat] is >= 0, seek to that beat in the arrangement
* (or that line in pattern-loop mode) so Play resumes from a marker the user
* placed on the arrangement, instead of always from the top. Ignored if it doesn't
* land inside the current playlist / pattern.
*/
fun play(startBeat: Int = -1) {
rebuildArrangement()
rebuildFxChains()
if (startBeat >= 0) {
if (arrangementMode) {
val list = playlist
// Find the first occurrence of that beat in the (possibly A/B-expanded)
// playlist; if it isn't in there just start from the top.
var seek = -1
for (i in list.indices) if (list[i] == startBeat) { seek = i; break }
if (seek >= 0) {
playlistIndex = seek; lineInBeat = 0; arrPlayLine = 0; samplesIntoLine = 0.0
}
} else {
val proj = project
if (proj != null) {
val pat = proj.activePattern()
val line = (startBeat * proj.timeSignature.linesPerBeat).coerceIn(0, pat.length - 1)
currentLine = line; samplesIntoLine = 0.0
}
}
}
pendingTrigger = true
playing = true
maybeStartRecording()
@@ -515,6 +558,7 @@ class AudioEngine(
busSampleVoices.forEach { it.kill() }
arps.forEach { it.stop() }
trackLastChannel.fill(-1)
allMidiNotesOff() // release any notes sent to external gear
}
/** Stop playback. If already stopped, rewind to the very beginning. */
@@ -659,6 +703,24 @@ class AudioEngine(
/** True while bus [ch] is (or recently was) clipping past 0 dBFS — drives the red VU. */
fun channelClipped(ch: Int): Boolean = ch in chClipHold.indices && chClipHold[ch] > 0
/** The live visualization source for toolbox [slotIndex], if that slot is an effect
* in some channel's insert chain and produces one (first match wins). Read on the
* UI thread; the returned processor's viz fields are written by the audio thread. */
private fun vizFor(slotIndex: Int): VizEffect? {
val chains = channelChains
for (ch in chains) for (u in ch) {
if (u.slot.index == slotIndex) { val e = u.effect; if (e is VizEffect) return e }
}
return null
}
/** Copy effect [slotIndex]'s scope samples into [out]; -1 if it isn't a live,
* routed viz effect (nothing to draw). */
fun effectScope(slotIndex: Int, out: FloatArray): Int = vizFor(slotIndex)?.copyScope(out) ?: -1
/** Effect [slotIndex]'s playhead (0..1), or -1 if no live viz source. */
fun effectPlayhead(slotIndex: Int): Float = vizFor(slotIndex)?.playhead() ?: -1f
/**
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.
@@ -815,6 +877,7 @@ class AudioEngine(
}
private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
lineTriggerGen++ // new line — notes below form one chord per arpeggiator
// Pattern-loop plays on lane 0's voices; each cell is routed by its channel.
// Apply note-offs FIRST, then note-ons, so a note-off on one track never
// cancels a note that begins on the same line/bus (a note-off releases the
@@ -846,6 +909,8 @@ class AudioEngine(
private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
val trackKey = lane * Pattern.TRACK_COUNT + track
if (cell.isNoteOff) {
// Release this track's outgoing MIDI note first (external gear).
if (moutNote[trackKey] >= 0) { emitNoteOff(moutCh[trackKey], moutNote[trackKey]); moutNote[trackKey] = -1 }
val ch = trackLastChannel[trackKey]
if (ch < 0) return // this track hasn't played anything yet
for (bus in 0 until Pattern.TRACK_COUNT) {
@@ -858,6 +923,13 @@ class AudioEngine(
if (!cell.isPlayable) return
// Remember the channel so a later note-off on this track releases it.
trackLastChannel[trackKey] = cell.channel
// Send this track's note out to external gear (mono per track): off the note it
// was holding, then on the new one, on the cell's MIDI channel. Mirrors the
// tracks' cells — a channel's Arpeggiator/Transposer are internal FX and aren't
// reflected here.
if (moutNote[trackKey] >= 0) emitNoteOff(moutCh[trackKey], moutNote[trackKey])
emitNoteOn(cell.channel, cell.note, cell.velocity)
moutNote[trackKey] = cell.note; moutCh[trackKey] = cell.channel
// Mono-per-track: a new note releases whatever this track was still holding,
// so a held note is stopped when the next one appears under the playhead — no
// explicit note-off cell needed. Only this track's own voices are released, so
@@ -875,22 +947,29 @@ class AudioEngine(
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
val mode = arpSlot.string("mode", "Up")
val resetBar = arpSlot.string("resetbar", "Off") == "On"
if (arp.active && arp.base == note) {
// The same note is already arpeggiating — this trigger is the
// pattern looping back over a still-held note. Keep the running
// phase (just refresh params) so the pulse stays steady across
// the loop point instead of slipping when the loop length isn't
// an exact multiple of the arp step (dotted/triplet divisions).
when {
arp.active && arp.genStamp == lineTriggerGen -> {
// Another note of the SAME line's chord — accumulate it so the
// arp cycles the whole chord (not just the last note entered).
arp.addNote(note)
arp.refresh(octaves, mode, stepSamples, resetBar)
} else {
arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
// The arpeggiator is monophonic (it cycles octaves of ONE note),
// so it must not stack notes. Releasing the bus first means a
// chord entered on the same line collapses to a single
// arpeggiated note (the last one), not all of them at once.
}
arp.active -> {
// A new line while still arpeggiating (the pattern looping back
// over a held chord): rebuild the chord from this line but keep
// the running phase, so a looping chord doesn't hard-restart or
// slip when the loop length isn't a multiple of the arp step.
arp.resetChord(note, velocity, octaves, mode, stepSamples, resetBar)
arp.genStamp = lineTriggerGen
}
else -> {
// Fresh arp: start on this note and sound it immediately.
arp.startChord(note, velocity, octaves, mode, stepSamples, resetBar)
arp.genStamp = lineTriggerGen
releaseBus(bus)
playNote(proj, bus, arp.currentNote(), velocity)
}
}
} else {
arp.stop()
playNote(proj, bus, note, velocity, ownerTrack = trackKey)
@@ -1032,17 +1111,22 @@ class AudioEngine(
}
}
/** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */
private class ChannelArp {
var active = false; var base = -1; var velocity = 100
/** Per-(lane,track) arpeggiator: cycles through the held chord's notes across the
* octave range. Notes are kept sorted ascending; the linear step sequence is
* octave-major (all chord notes in octave 0, then octave 1, …), walked up / down /
* up-down / randomly by [advance]. [genStamp] identifies the line whose chord this
* holds (see [lineTriggerGen]). Internal (not private) only so a unit test can
* exercise its pure note-sequencing. */
internal class ChannelArp {
private val notes = IntArray(MAX_CHORD)
var noteCount = 0; private set
var velocity = 100
private var octaves = 1; private var mode = 0
var stepSamples = 0.0; var samplesLeft = 0.0
private var step = 0; private var dir = 1
/** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */
var resetOnBar = false
// Set by [resetToBar] so the next fire plays step 0 without first advancing.
var genStamp = -1
private var pendingReset = false
// Per-arp lock-free RNG (Random mode) — no Math.random() on the audio thread.
private var rng = 0x2545F491.toInt()
private fun rnd(bound: Int): Int {
var s = rng
@@ -1051,16 +1135,37 @@ class AudioEngine(
return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt()
}
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
/** Active while it holds at least one note. */
val active: Boolean get() = noteCount > 0
/** Start a fresh chord on [note], resetting the phase to step 0. */
fun startChord(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
noteCount = 0; addNote(note); velocity = vel; octaves = octs.coerceIn(1, 4)
mode = modeOf(modeStr)
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
resetOnBar = resetBar; pendingReset = false
}
/** Update params WITHOUT resetting the running phase — used when the pattern
* loops over a still-arpeggiating note so the steady pulse continues
* seamlessly across the loop boundary. */
/** Replace the chord with [note] (the next same-line notes re-accumulate) while
* KEEPING the running phase — used when the pattern loops back over a held
* chord so the pulse stays steady instead of restarting. */
fun resetChord(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
noteCount = 0; addNote(note); velocity = vel
refresh(octs, modeStr, stepSamp, resetBar)
}
/** Add a note to the held chord (sorted ascending, de-duplicated). */
fun addNote(note: Int) {
val n = note.coerceIn(0, 127)
var i = 0
while (i < noteCount && notes[i] < n) i++
if (i < noteCount && notes[i] == n) return // already held
if (noteCount >= MAX_CHORD) return
for (j in noteCount downTo i + 1) notes[j] = notes[j - 1]
notes[i] = n; noteCount++
}
/** Update params WITHOUT resetting the running phase. */
fun refresh(octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
octaves = octs.coerceIn(1, 4)
mode = modeOf(modeStr)
@@ -1068,31 +1173,38 @@ class AudioEngine(
resetOnBar = resetBar
}
/** Realign the pattern to step 0 at a bar downbeat: the next [advanceArps]
* fire (scheduled immediately) plays step 0 without advancing first. */
fun resetToBar() { step = 0; dir = 1; samplesLeft = 0.0; pendingReset = true }
/** True (once) if a bar reset is pending, so the caller skips [advance]. */
fun consumePendingReset(): Boolean { val p = pendingReset; pendingReset = false; return p }
private fun modeOf(modeStr: String): Int =
when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
fun stop() { active = false; base = -1 }
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
fun stop() { noteCount = 0; genStamp = -1 }
private fun total(): Int = (noteCount * octaves).coerceAtLeast(1)
fun currentNote(): Int {
if (noteCount <= 0) return 0
val idx = step.coerceIn(0, total() - 1)
val oct = idx / noteCount
return (notes[idx % noteCount] + 12 * oct).coerceIn(0, 127)
}
fun advance() {
val total = total()
when (mode) {
1 -> step = (step - 1 + octaves) % octaves
2 -> if (octaves > 1) {
1 -> step = (step - 1 + total) % total
2 -> if (total > 1) {
step += dir
if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
if (step >= total - 1) { step = total - 1; dir = -1 }
else if (step <= 0) { step = 0; dir = 1 }
}
3 -> step = if (octaves > 1) rnd(octaves) else 0
else -> step = (step + 1) % octaves
3 -> step = if (total > 1) rnd(total) else 0
else -> step = (step + 1) % total
}
}
private companion object { const val MAX_CHORD = 8 }
}
/** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
@@ -1241,6 +1353,7 @@ class AudioEngine(
private fun triggerArrangementLine(
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
) {
lineTriggerGen++ // new line — notes below form one chord per arpeggiator
// Two passes across all lanes: note-offs first, then note-ons, so a note-off
// never cancels a note that begins on the same line/bus (a note-off releases
// the whole bus). See [triggerPatternLine].
@@ -1317,6 +1430,45 @@ class AudioEngine(
seqVoices.forEach { it.noteOff() }
sampleVoices.forEach { it.noteOff() }
arps.forEach { it.stop() }
allMidiNotesOff() // release any notes we sent to external gear
}
// ------------------------------------------------------------- outgoing MIDI
/** Enqueue one MIDI message for the send thread (audio-thread producer, no alloc). */
private fun emitMidi(status: Int, d1: Int, d2: Int) {
if (!midiOutEnabled) return
val h = moutHead
val next = (h + 1) and (moutRing.size - 1)
if (next == moutTail) return // ring full — drop rather than block the audio thread
moutRing[h] = (status shl 16) or ((d1 and 0x7F) shl 8) or (d2 and 0x7F)
moutHead = next
}
/** Send a note-on for [note] on app channel [ch] (1..16 → MIDI wire 0..15). */
private fun emitNoteOn(ch: Int, note: Int, vel: Int) {
if (ch < 1) return
emitMidi(0x90 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), vel.coerceIn(1, 127))
}
private fun emitNoteOff(ch: Int, note: Int) {
if (ch < 1) return
emitMidi(0x80 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), 0)
}
/** Release every note we've sent out (on transport stop / pause / panic) so an
* external instrument never hangs after Sizzle stops. */
private fun allMidiNotesOff() {
for (k in moutNote.indices) if (moutNote[k] >= 0) { emitNoteOff(moutCh[k], moutNote[k]); moutNote[k] = -1 }
}
/** Drain one queued outgoing MIDI message, or -1 if the queue is empty. Called by
* the MIDI clock thread (single consumer). */
fun pollMidiOut(): Int {
val t = moutTail
if (t == moutHead) return -1
val v = moutRing[t]
moutTail = (t + 1) and (moutRing.size - 1)
return v
}
private fun publish() {

View File

@@ -4,6 +4,7 @@
package space.rcmd.android.sizzle.audio
import space.rcmd.android.sizzle.model.DelayDivisions
import space.rcmd.android.sizzle.model.TimeDivision
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.model.ToolboxType
import kotlin.math.PI
@@ -42,11 +43,27 @@ interface AudioEffect {
ToolboxType.BITCRUSHER -> Bitcrusher()
ToolboxType.EQ10 -> GraphicEq(sampleRate)
ToolboxType.MIX_TIGHTENER -> MixTightener(sampleRate)
ToolboxType.TAPE_ENGINE -> TapeEngine(sampleRate)
ToolboxType.GRANULAR -> GranularGlitch(sampleRate)
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
}
}
}
/**
* An effect that publishes a small visualization snapshot for its editor to draw.
* The audio thread writes into plain fields/arrays; the UI reads them at frame rate
* (a stale value for one frame is harmless, so no locking is used). Read only through
* the engine, which hands back the live processor for the edited slot.
*/
interface VizEffect {
/** Copy the most recent scope samples (oldest → newest) into [out]; return the
* count written (0 if nothing yet). */
fun copyScope(out: FloatArray): Int
/** Current read/playhead position within the scope window, 0 (oldest) … 1 (newest). */
fun playhead(): Float
}
// ---------------------------------------------------------------------------
// Multi-tap tape delay: one shared delay line read by up to four independent
// "tape heads", each with its own tempo-synced division and on/off switch. A tape
@@ -519,3 +536,195 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
const val GAIN_SMOOTH = 0.05f // per-sample gain ramp toward the control-rate target
}
}
// ---------------------------------------------------------------------------
// Tape Engine: a varispeed reel-to-reel. Audio is written into a ring buffer at
// rate 1 and read back at `speed` (< 1 = slower = lower pitch), interpolated — a
// classic tape brake / tape-stop. `speed` is driven by the editor's reel gesture
// (press to brake); the engine GLIDES toward it so pitch dives and recovers with
// tape-like inertia rather than jumping. wow (slow, tempo-synced) and flutter (fast)
// wobble the read rate; hiss adds constant tape noise. Fully wet — the whole signal
// runs through the tape — so at speed 1 / no wobble it's just a short constant delay.
// ---------------------------------------------------------------------------
private class TapeEngine(private val sampleRate: Int) : AudioEffect {
private val buf = FloatArray(sampleRate * 2) // 2 s ring (headroom for long brakes)
private var writePos = NOMINAL_DELAY
private var readPos = 0.0
private var targetSpeed = 1.0
private var curSpeed = 1.0
private var wowInc = 0.0; private var wowPhase = 0.0; private var wowDepth = 0f
private var flutInc = FLUTTER_HZ / sampleRate; private var flutPhase = 0.0; private var flutDepth = 0f
private var hiss = 0f
private var rng = 0x51ED2A19
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
targetSpeed = slot.float("speed", 1f).coerceIn(MIN_SPEED, 1f).toDouble()
wowDepth = slot.float("wow", 0.15f).coerceIn(0f, 1f) * MAX_WOW
flutDepth = slot.float("flutter", 0.15f).coerceIn(0f, 1f) * MAX_FLUT
hiss = slot.float("hiss", 0.10f).coerceIn(0f, 1f) * MAX_HISS
val secPerBeat = 60.0 / tempoBpm
val div = TimeDivision.fromIndex(slot.float("division", 2f).toInt())
val wowHz = 1.0 / (div.beatFraction * secPerBeat).coerceAtLeast(1e-4)
wowInc = wowHz / sampleRate
flutInc = FLUTTER_HZ / sampleRate
}
override fun process(x: Float): Float {
buf[writePos] = x
writePos++; if (writePos >= buf.size) writePos = 0
curSpeed += (targetSpeed - curSpeed) * SPEED_GLIDE // tape inertia
wowPhase += wowInc; if (wowPhase >= 1.0) wowPhase -= 1.0
flutPhase += flutInc; if (flutPhase >= 1.0) flutPhase -= 1.0
val wow = sin(2.0 * PI * wowPhase) * wowDepth
val flut = sin(2.0 * PI * flutPhase) * flutDepth
var rate = curSpeed * (1.0 + wow + flut)
if (rate < 0.01) rate = 0.01
val i0 = readPos.toInt() % buf.size
val i1 = (i0 + 1) % buf.size
val frac = (readPos - readPos.toInt()).toFloat()
var out = buf[i0] + (buf[i1] - buf[i0]) * frac
var rp = readPos + rate
if (rp >= buf.size) rp -= buf.size
// Keep the read head a valid distance behind the write head: never read the
// just-written/future region (min gap), and never lag past the buffer (max gap
// — a hard brake that would fall off snaps forward with a small glitch).
var gap = writePos - rp; if (gap < 0) gap += buf.size
val maxGap = buf.size - 2.0
if (gap < MIN_GAP) { rp = writePos - MIN_GAP; if (rp < 0) rp += buf.size }
else if (gap > maxGap) { rp = writePos - maxGap; if (rp < 0) rp += buf.size }
readPos = rp
if (hiss > 0f) out += nextRand() * hiss
return out
}
private fun nextRand(): Float {
var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
return s * 4.656613e-10f // -1 … 1
}
private companion object {
const val NOMINAL_DELAY = 128 // baseline read latency (samples) for interp headroom
const val MIN_SPEED = 0.25f
const val MAX_WOW = 0.03f // ±3 % rate at full wow
const val MAX_FLUT = 0.008f // ±0.8 % rate at full flutter
const val MAX_HISS = 0.02f
const val FLUTTER_HZ = 11.0
const val SPEED_GLIDE = 0.00012f // per-sample glide (~0.15 s) toward target speed
const val MIN_GAP = 2.0
}
}
// ---------------------------------------------------------------------------
// Granular Glitch: records the incoming signal into a rolling buffer and, driven by
// Intensity, periodically "jumps" the read head back to a random earlier point and
// replays a short grain (stutter / repeat / octave glitch). Between grains it passes
// the signal through dry, so Intensity 0 is transparent and higher = more frequent,
// more mangled glitches. Publishes a decimated scope of the incoming sound plus the
// jumping read-head position ([VizEffect]) for the editor to draw.
// ---------------------------------------------------------------------------
private class GranularGlitch(private val sampleRate: Int) : AudioEffect, VizEffect {
private val buf = FloatArray(sampleRate) // 1 s history
private var writePos = 0
private var readPos = 0.0
private var grainLeft = 0
private var grainRate = 1.0
private var intensity = 0f
private var rng = 0x2F6E1DB7
// Visualization: a ring of decimated INPUT samples (the sound passing through) plus
// the normalized read-head position within that window.
private val scope = FloatArray(SCOPE_N)
private var scopeWrite = 0
private var decCount = 0
@Volatile private var playheadNorm = 1f
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
intensity = slot.float("intensity", 0.3f).coerceIn(0f, 1f)
}
override fun process(x: Float): Float {
val wp = writePos
buf[wp] = x
writePos++; if (writePos >= buf.size) writePos = 0
val out: Float
if (grainLeft <= 0 && (intensity < 0.001f || !maybeStartGrain(wp))) {
// Dry passthrough — keep the read head trailing the write head so the next
// grain can start from "now" and jump back from there.
readPos = wp.toDouble()
out = x
} else {
val i0 = readPos.toInt() % buf.size
val i1 = (i0 + 1) % buf.size
val frac = (readPos - readPos.toInt()).toFloat()
out = buf[i0] + (buf[i1] - buf[i0]) * frac
var rp = readPos + grainRate
if (rp >= buf.size) rp -= buf.size
if (rp < 0) rp += buf.size
var gap = wp - rp; if (gap < 0) gap += buf.size
if (gap < 2.0) { rp = wp - 2.0; if (rp < 0) rp += buf.size } // don't read the future
readPos = rp
grainLeft--
}
// Scope: decimate the incoming signal into the ring.
if (++decCount >= SCOPE_DECIM) {
decCount = 0
scope[scopeWrite] = x
scopeWrite++; if (scopeWrite >= SCOPE_N) scopeWrite = 0
}
// Playhead: where the read head sits within the displayed window (0 oldest … 1 now).
var behind = wp - readPos; if (behind < 0) behind += buf.size
playheadNorm = (1f - behind.toFloat() / (SCOPE_N * SCOPE_DECIM)).coerceIn(0f, 1f)
return out
}
/** Maybe (probability scales with Intensity) start a new grain, jumping the read
* head back to a random earlier point. Returns true if a grain was started. */
private fun maybeStartGrain(wp: Int): Boolean {
if (nextUnit() > intensity * intensity * MAX_TRIGGER) return false
val minJ = MIN_JUMP_S * sampleRate
val maxJ = (MIN_JUMP_S + intensity * (MAX_JUMP_S - MIN_JUMP_S)) * sampleRate
val jump = minJ + nextUnit() * (maxJ - minJ)
var rp = wp.toDouble() - jump
while (rp < 0) rp += buf.size
readPos = rp
val lenSec = GRAIN_MAX_S - intensity * (GRAIN_MAX_S - GRAIN_MIN_S)
grainLeft = (lenSec * sampleRate * (0.5f + nextUnit())).toInt().coerceAtLeast(64)
grainRate = when { // occasional octave glitch, likelier when hot
nextUnit() < intensity * 0.3f -> 2.0
nextUnit() < intensity * 0.2f -> 0.5
else -> 1.0
}
return true
}
override fun copyScope(out: FloatArray): Int {
val n = if (out.size < SCOPE_N) out.size else SCOPE_N
val start = scopeWrite // oldest sample in the ring
for (k in 0 until n) out[k] = scope[(start + k) % SCOPE_N]
return n
}
override fun playhead(): Float = playheadNorm
private fun nextUnit(): Float {
var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
return (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f // 0 … 1
}
private companion object {
const val SCOPE_N = 96 // scope points drawn
const val SCOPE_DECIM = 96 // input samples per point → ~192 ms window @ 48 kHz
const val MAX_TRIGGER = 0.0009f // per-sample grain-start probability at full intensity
const val MIN_JUMP_S = 0.03f // shortest jump-back
const val MAX_JUMP_S = 0.5f // longest jump-back at full intensity
const val GRAIN_MIN_S = 0.02f // shortest grain (hot = stutter)
const val GRAIN_MAX_S = 0.15f // longest grain (gentle)
}
}

View File

@@ -33,8 +33,12 @@ sealed interface InputAction {
data object NoteOffCell : InputAction
// ----- Live note entry (also used to audition instruments) -----
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction
data class NoteOff(val pitch: Int) : InputAction
// [channel] is the 1-based MIDI channel (1..16) for MIDI input, so notes can be
// routed to the mixer track(s) listening on it; it is -1 when the source has no
// channel of its own (physical / on-screen keyboard), which falls back to the
// on-screen keyboard's channel.
data class NoteOn(val pitch: Int, val velocity: Int, val channel: Int = -1) : InputAction
data class NoteOff(val pitch: Int, val channel: Int = -1) : InputAction
// ----- Transport -----
data object PlayPause : InputAction

View File

@@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.input
import android.content.Context
import android.media.midi.MidiDeviceInfo
import android.media.midi.MidiInputPort
import android.media.midi.MidiManager
import android.os.Handler
import android.os.Looper
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.locks.LockSupport
/**
* MIDI clock sync over every attached MIDI device (USB *and* Bluetooth — Android
* surfaces both through the same [MidiManager]).
*
* SEND (drive external gear): while [sendEnabled] and the transport is playing, a
* dedicated thread emits the MIDI real-time clock (0xF8) at 24 PPQN from the current
* tempo, plus Start (0xFA) / Stop (0xFC) on the play/stop edges, to every device we
* can write to. RECEIVE (be driven): [handleRealtime] — fed the real-time status
* bytes by [MidiInput] — follows an external clock, calling back Start / Stop and a
* tempo derived from the incoming pulse rate. Receive is a tempo + transport follow,
* not a sample-locked slave.
*
* Both directions are off until enabled; nothing is sent or acted on otherwise.
*/
class MidiClock(context: Context) {
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
private val handler = Handler(Looper.getMainLooper())
private val outPorts = CopyOnWriteArrayList<MidiInputPort>() // device input ports we WRITE to
@Volatile var sendEnabled = false
@Volatile var receiveEnabled = false
/** Live transport state for the send thread, supplied by the ViewModel. */
var playingProvider: () -> Boolean = { false }
var bpmProvider: () -> Float = { 120f }
/** Source of queued outgoing note messages (packed (status<<16)|(d1<<8)|d2), or -1
* when empty — drained by the send thread while [sendEnabled]. Set by the ViewModel
* to the audio engine's queue, so Sizzle's sequenced notes reach external gear. */
var noteSource: (() -> Int)? = null
/** Receive callbacks (invoked on the MIDI handler thread; the ViewModel hops to
* the main thread as needed). */
var onReceiveStart: (() -> Unit)? = null
var onReceiveStop: (() -> Unit)? = null
var onReceiveTempo: ((Float) -> Unit)? = null
private var thread: Thread? = null
@Volatile private var running = false
fun start() {
val mm = midiManager ?: return
mm.devices.forEach(::openDevice)
mm.registerDeviceCallback(object : MidiManager.DeviceCallback() {
override fun onDeviceAdded(device: MidiDeviceInfo) = openDevice(device)
}, handler)
running = true
thread = Thread({ sendLoop() }, "sizzle-midi-clock").apply {
priority = Thread.MAX_PRIORITY; isDaemon = true; start()
}
}
fun stop() {
running = false
thread?.join(200); thread = null
outPorts.forEach { runCatching { it.close() } }
outPorts.clear()
}
private fun openDevice(info: MidiDeviceInfo) {
if (info.inputPortCount == 0) return // need a port we can send TO
midiManager?.openDevice(info, { device ->
device?.openInputPort(0)?.let { outPorts += it }
}, handler)
}
private fun sendAll(status: Byte) {
val msg = byteArrayOf(status)
for (p in outPorts) runCatching { p.send(msg, 0, 1) }
}
/** Send one packed 3-byte channel message (note-on/off) to every device. */
private fun sendPacked(m: Int) {
val msg = byteArrayOf(
((m shr 16) and 0xFF).toByte(), ((m shr 8) and 0x7F).toByte(), (m and 0x7F).toByte(),
)
for (p in outPorts) runCatching { p.send(msg, 0, 3) }
}
/** Drain and send any queued sequenced notes (bounded so a flood can't stall). */
private fun drainNotes() {
val src = noteSource ?: return
var guard = 0
var m = src()
while (m >= 0 && guard++ < moutDrainMax) { sendPacked(m); m = src() }
}
/** Generates the outgoing clock. Polls at 0.5 ms so pulse timing is steady enough
* for typical gear; sends Start/Stop on the transport's play/stop edges. */
private fun sendLoop() {
var lastPlaying = false
var nextPulse = 0L
while (running) {
if (sendEnabled) {
val playing = playingProvider()
val now = System.nanoTime()
if (playing && !lastPlaying) { sendAll(START); nextPulse = now; lastPlaying = true }
else if (!playing && lastPlaying) { sendAll(STOP); lastPlaying = false }
if (playing && now >= nextPulse) {
sendAll(CLOCK)
val bpm = bpmProvider().coerceIn(20f, 300f)
val interval = (60_000_000_000.0 / (bpm * 24.0)).toLong()
nextPulse += interval
if (nextPulse < now) nextPulse = now + interval // recover from any lag
}
drainNotes() // forward Sizzle's sequenced notes to external gear
} else if (lastPlaying) {
sendAll(STOP); lastPlaying = false // stop cleanly if send is switched off mid-play
}
// 0.5 ms when sending (steady pulse timing); relax to 10 ms when idle.
LockSupport.parkNanos(if (sendEnabled) 500_000L else 10_000_000L)
}
if (lastPlaying) sendAll(STOP)
}
// ---- receive: fed the real-time status bytes by MidiInput ----
private var lastPulseNs = 0L
private var pulseAccum = 0.0
private var pulseCount = 0
fun handleRealtime(status: Int) {
if (!receiveEnabled) return
when (status) {
0xFA, 0xFB -> { resetTempoEstimate(); onReceiveStart?.invoke() } // Start / Continue
0xFC -> onReceiveStop?.invoke() // Stop
0xF8 -> onClockPulse() // Clock
}
}
private fun resetTempoEstimate() { lastPulseNs = 0L; pulseAccum = 0.0; pulseCount = 0 }
/** Derive tempo from the incoming pulse rate, averaged over a quarter note (24
* pulses) to smooth jitter. */
private fun onClockPulse() {
val now = System.nanoTime()
if (lastPulseNs != 0L) {
pulseAccum += (now - lastPulseNs)
if (++pulseCount >= 24) {
val avgNs = pulseAccum / pulseCount
val bpm = (60_000_000_000.0 / (avgNs * 24.0)).toFloat()
if (bpm.isFinite() && bpm in 20f..300f) onReceiveTempo?.invoke(bpm)
pulseAccum = 0.0; pulseCount = 0
}
}
lastPulseNs = now
}
private val moutDrainMax = 256 // cap notes sent per poll so a flood can't stall the loop
private companion object {
val CLOCK: Byte = 0xF8.toByte()
val START: Byte = 0xFA.toByte()
val STOP: Byte = 0xFC.toByte()
}
}

View File

@@ -29,6 +29,9 @@ class MidiInput(
/** CC number -> action id, editable from Settings (MIDI bindings section). */
var ccBindings: MutableMap<Int, String> = mutableMapOf(),
) {
/** Sink for MIDI System Real-Time status bytes (0xF8..0xFF), notably clock (0xF8)
* and Start/Continue/Stop (0xFA/0xFB/0xFC) — wired to [MidiClock] for clock sync. */
var onRealtime: ((Int) -> Unit)? = null
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
private val handler = Handler(Looper.getMainLooper())
private val openPorts = mutableListOf<MidiOutputPort>()
@@ -67,17 +70,23 @@ class MidiInput(
while (i < end) {
val status = data[i].toInt() and 0xFF
if (status < 0x80) { i++; continue } // skip stray data bytes
// System Real-Time (0xF8..0xFF) are single-byte and may be interleaved
// anywhere; forward them (clock / start / stop) and move on one byte.
if (status >= 0xF8) { onRealtime?.invoke(status); i++; continue }
val type = status and 0xF0
// Low nibble is the MIDI channel (0..15); expose it 1-based (1..16) so
// it matches the mixer channels' `midiChannel` for routing.
val channel = (status and 0x0F) + 1
when (type) {
0x90 -> { // Note On
val note = data.getOrZero(i + 1)
val vel = data.getOrZero(i + 2)
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel, channel))
else router.dispatch(InputAction.NoteOff(note, channel)) // vel 0 == note off
i += 3
}
0x80 -> { // Note Off
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1), channel)); i += 3
}
0xB0 -> { // Control Change
val cc = data.getOrZero(i + 1)

View File

@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.io
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* Whole-app backup: a single ZIP of everything the app persists under its private
* files dir — saved songs, themes, instrument/effect presets (with their sample
* WAVs), the autosaved project, and the DataStore blobs holding settings + input
* bindings. Restore extracts into a staging folder first and only swaps it into
* place once the archive looks valid, so a corrupt or wrong file can never destroy
* the current data. Because the DataStore prefs are cached in memory while the app
* runs, a restore only takes full effect after the app is restarted.
*/
object BackupIo {
// Top-level content areas under filesDir. "datastore" holds the settings +
// bindings Preferences files (see SettingsStore / BindingStore).
private val CONTENT_DIRS = listOf("songs", "themes", "presets", "datastore")
private const val AUTOSAVE = "autosave.sng"
private const val STAGING = ".restore_tmp"
/** Write a full backup of [filesDir] to [out] as a ZIP. */
fun write(filesDir: File, out: OutputStream) {
ZipOutputStream(BufferedOutputStream(out)).use { zip ->
for (dir in CONTENT_DIRS) addTree(zip, File(filesDir, dir), dir)
File(filesDir, AUTOSAVE).takeIf { it.isFile }?.let { addFile(zip, it, AUTOSAVE) }
}
}
/**
* Restore a backup ZIP from [input] into [filesDir], replacing the current data.
* Returns false (leaving current data untouched) if the archive is empty or holds
* none of the expected areas. Settings/bindings apply after an app restart.
*/
fun read(filesDir: File, input: InputStream): Boolean {
val staging = File(filesDir, STAGING)
staging.deleteRecursively(); staging.mkdirs()
val stagingRoot = staging.canonicalPath + File.separator
var any = false
try {
ZipInputStream(BufferedInputStream(input)).use { zin ->
var entry: ZipEntry? = zin.nextEntry
while (entry != null) {
val name = entry.name
if (!entry.isDirectory && !name.contains("..")) {
val target = File(staging, name)
// Zip-slip guard: the resolved path must stay inside staging.
if (target.canonicalPath.startsWith(stagingRoot)) {
target.parentFile?.mkdirs()
target.outputStream().use { zin.copyTo(it) }
any = true
}
}
zin.closeEntry()
entry = zin.nextEntry
}
}
} catch (e: Exception) {
// A truncated / non-ZIP file: bail out without touching the live data.
staging.deleteRecursively(); return false
}
val looksValid = any &&
(CONTENT_DIRS.any { File(staging, it).exists() } || File(staging, AUTOSAVE).isFile)
if (!looksValid) { staging.deleteRecursively(); return false }
// Swap each restored area into place (delete the live one first so a rename
// over it succeeds; fall back to a copy if rename can't cross the boundary).
for (dir in CONTENT_DIRS) {
val src = File(staging, dir)
if (!src.exists()) continue
val dst = File(filesDir, dir)
dst.deleteRecursively()
if (!src.renameTo(dst)) src.copyRecursively(dst, overwrite = true)
}
File(staging, AUTOSAVE).takeIf { it.isFile }?.let { src ->
val dst = File(filesDir, AUTOSAVE)
dst.delete()
if (!src.renameTo(dst)) src.copyTo(dst, overwrite = true)
}
staging.deleteRecursively()
return true
}
private fun addTree(zip: ZipOutputStream, dir: File, prefix: String) {
if (!dir.isDirectory) return
dir.walkTopDown().filter { it.isFile }.forEach { f ->
addFile(zip, f, prefix + "/" + f.relativeTo(dir).path.replace(File.separatorChar, '/'))
}
}
private fun addFile(zip: ZipOutputStream, f: File, entryName: String) {
zip.putNextEntry(ZipEntry(entryName))
f.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}

View File

@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.io
import space.rcmd.android.sizzle.audio.SampleStore
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.SamplerPads
import space.rcmd.android.sizzle.model.ToolboxType
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* A portable, self-contained project bundle for sharing whole projects between
* Sizzletracker instances: a ZIP holding the full `.szg` project plus every sample
* WAV it references (Sampler pads and SoundFont slots). Unlike a plain `.szg` — whose
* sample ids only resolve against the originating device's stores — a bundle carries
* the audio, so importing on another device rebuilds the project *and* its sounds.
*/
object ProjectBundleIo {
private const val PROJECT_ENTRY = "project.szg"
private const val MANIFEST = "samples/manifest.txt"
private const val SAMPLE_DIR = "samples/"
/** Write [project] and all its referenced samples to [out] as a ZIP bundle. */
fun write(project: Project, out: OutputStream) {
ZipOutputStream(BufferedOutputStream(out)).use { zip ->
zip.putNextEntry(ZipEntry(PROJECT_ENTRY))
zip.write(ProjectIo.save(project).toByteArray(Charsets.UTF_8))
zip.closeEntry()
val manifest = StringBuilder()
collectSampleIds(project).forEachIndexed { i, id ->
val sample = SampleStore.get(id) ?: return@forEachIndexed
zip.putNextEntry(ZipEntry("$SAMPLE_DIR$i.wav"))
zip.write(SampleStore.encodeWav(sample))
zip.closeEntry()
manifest.append(i).append('\t').append(id).append('\n')
}
zip.putNextEntry(ZipEntry(MANIFEST))
zip.write(manifest.toString().toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
}
/**
* Load a project bundle from [input] into [project] (replacing it), extracting its
* sample WAVs into [samplesDir] and pointing each sample-referencing slot at the
* on-disk copy (so the caller's `rehydrate` — and every later startup — resolves
* the audio). Returns false, leaving the project untouched, if the ZIP holds no
* project. The caller should rehydrate + rebuild FX routing afterwards, as for a
* normal load.
*/
fun read(project: Project, input: InputStream, samplesDir: File): Boolean {
var projectText: String? = null
val manifest = HashMap<String, String>() // sample index -> original id
val wavByIndex = HashMap<String, ByteArray>() // sample index -> WAV bytes
try {
ZipInputStream(BufferedInputStream(input)).use { zin ->
var entry: ZipEntry? = zin.nextEntry
while (entry != null) {
val name = entry.name
if (!entry.isDirectory && !name.contains("..")) {
val bytes = zin.readBytes()
when {
name == PROJECT_ENTRY -> projectText = String(bytes, Charsets.UTF_8)
name == MANIFEST -> String(bytes, Charsets.UTF_8).lineSequence().forEach { ln ->
val tab = ln.indexOf('\t')
if (tab > 0) manifest[ln.substring(0, tab)] = ln.substring(tab + 1)
}
name.startsWith(SAMPLE_DIR) && name.endsWith(".wav") ->
wavByIndex[name.removePrefix(SAMPLE_DIR).removeSuffix(".wav")] = bytes
}
}
zin.closeEntry()
entry = zin.nextEntry
}
}
} catch (e: Exception) {
return false
}
val text = projectText ?: return false
// Persist each sample and map its original id to the extracted file. Content-
// addressed names dedup re-imports and never collide.
samplesDir.mkdirs()
val idToPath = HashMap<String, String>()
for ((idx, originalId) in manifest) {
val bytes = wavByIndex[idx] ?: continue
val f = File(samplesDir, "s" + Integer.toHexString(bytes.contentHashCode()) + ".wav")
if (!f.exists()) f.writeBytes(bytes)
idToPath[originalId] = f.absolutePath
}
ProjectIo.loadInto(project, text)
// Re-point sample-referencing slots at the extracted WAVs (absolute paths), so
// rehydrate resolves them here and after any future restart.
project.toolbox.forEach { slot ->
when (slot.type) {
ToolboxType.SAMPLER -> for (p in 0 until SamplerPads.COUNT) {
idToPath[slot.string(SamplerPads.key(p))]?.let { slot.set("pad${p}File", it) }
}
ToolboxType.SOUNDFONT -> idToPath[slot.string("sfSample")]?.let { slot.set("sfFile", it) }
else -> {}
}
}
return true
}
/** Every non-blank sample id referenced by the project's Sampler / SoundFont slots. */
private fun collectSampleIds(project: Project): List<String> {
val ids = LinkedHashSet<String>()
project.toolbox.forEach { slot ->
when (slot.type) {
ToolboxType.SAMPLER -> for (p in 0 until SamplerPads.COUNT) {
slot.string(SamplerPads.key(p)).takeIf { it.isNotBlank() }?.let { ids.add(it) }
}
ToolboxType.SOUNDFONT -> slot.string("sfSample").takeIf { it.isNotBlank() }?.let { ids.add(it) }
else -> {}
}
}
return ids.toList()
}
}

View File

@@ -29,6 +29,10 @@ data class AppSettings(
val kbdOctave: Int = 3,
/** Show the live VU meters inside the mixer volume faders. */
val vuMeters: Boolean = true,
/** Send MIDI clock + start/stop to attached MIDI devices (drive external gear). */
val sendMidiClock: Boolean = false,
/** Follow an external MIDI clock's tempo + start/stop. */
val receiveMidiClock: Boolean = false,
)
/**
@@ -51,6 +55,8 @@ class SettingsStore(private val context: Context) {
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
vuMeters = prefs[VU_METERS] ?: true,
sendMidiClock = prefs[MIDI_CLOCK_SEND] ?: false,
receiveMidiClock = prefs[MIDI_CLOCK_RECV] ?: false,
)
}
@@ -82,6 +88,14 @@ class SettingsStore(private val context: Context) {
context.settingsDataStore.edit { prefs -> prefs[VU_METERS] = enabled }
}
/** Persist the MIDI-clock send / receive toggles. */
suspend fun saveMidiClock(send: Boolean, receive: Boolean) {
context.settingsDataStore.edit { prefs ->
prefs[MIDI_CLOCK_SEND] = send
prefs[MIDI_CLOCK_RECV] = receive
}
}
private companion object {
val PALETTE = stringPreferencesKey("palette")
val RECORD_DIR = stringPreferencesKey("recordDirUri")
@@ -89,5 +103,7 @@ class SettingsStore(private val context: Context) {
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
val VU_METERS = booleanPreferencesKey("vuMeters")
val MIDI_CLOCK_SEND = booleanPreferencesKey("midiClockSend")
val MIDI_CLOCK_RECV = booleanPreferencesKey("midiClockRecv")
}
}

View File

@@ -54,12 +54,20 @@ class SongLibrary(private val baseDir: File) {
return f
}
/** A shareable file path in the export sub-dir for a full project bundle ([BUNDLE_EXT]);
* the caller writes the ZIP into it via [ProjectBundleIo]. */
fun bundleExportFile(name: String): File {
val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
return File(dir, sanitize(name) + BUNDLE_EXT)
}
/** Keep names safe as file names while staying human-readable. */
private fun sanitize(name: String): String =
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" }
private companion object {
const val EXT = ".szg" // full project (ProjectIo)
const val BUNDLE_EXT = ".szgz" // full project + samples ZIP bundle (ProjectBundleIo)
const val EXPORT_DIR = ".export"
}
}

View File

@@ -226,6 +226,30 @@ enum class ToolboxType(
// (see MixTightenerPresets); only Strength is user-facing. Rendered by
// MixTightener in Effects.kt, edited by ui/toolbox/MixTightenerEditor.
listOf(ParamSpec("strength", "Strength", 1f, 0f, 1f)),
),
TAPE_ENGINE(
ToolboxKind.EFFECT, "Tape Engine",
// A reel-to-reel varispeed tape: press a reel in the editor to "brake" it and
// the pitch dives like a slowing tape (the 'speed' param is driven by that reel
// gesture, not a slider). wow/flutter add slow + fast pitch wobble, hiss adds
// tape noise, and the wow wobble rate is tempo-synced via 'division'. Rendered
// by TapeEngine in Effects.kt, edited by ui/toolbox/TapeEngineEditor.
listOf(
ParamSpec("speed", "Speed", 1f, 0.25f, 1f),
ParamSpec("wow", "Wow", 0.15f, 0f, 1f),
ParamSpec("flutter", "Flutter", 0.15f, 0f, 1f),
ParamSpec("hiss", "Hiss", 0.10f, 0f, 1f),
ParamSpec("division", "Wow Sync", 2f, 0f, 6f, isInt = true), // index into TimeDivision
),
),
GRANULAR(
ToolboxKind.EFFECT, "Granular Glitch",
// Stutters/repeats short grains read from a rolling buffer of the incoming
// sound; the read head jumps around, and the editor draws it as a moving
// playhead over a dotted scope of the signal. One control: Intensity (how often
// it glitches + how mangled). Rendered by GranularGlitch in Effects.kt, edited
// by ui/toolbox/GranularEditor.
listOf(ParamSpec("intensity", "Intensity", 0.3f, 0f, 1f)),
);
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT

View File

@@ -15,10 +15,14 @@ import space.rcmd.android.sizzle.input.InputAction
import space.rcmd.android.sizzle.input.InputRouter
import space.rcmd.android.sizzle.model.Cell
import space.rcmd.android.sizzle.model.CellColumn
import space.rcmd.android.sizzle.model.ParamSpec
import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature
import space.rcmd.android.sizzle.model.ToolboxSlot
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -37,6 +41,14 @@ import kotlinx.coroutines.launch
* performance, so after we mutate the grid we bump [revision]. Compose reads that
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
*/
/** Tab index of the Tracker tab (see [App]'s tab order). */
private const val TAB_TRACKER = 0
/** Reserved prefix for a toolbox slot's MIDI-CC parameter bindings, stored in the
* slot's value map (`midicc@<paramKey>` -> CC number) so they persist with the
* project / preset like any other parameter and never collide with a real key. */
private const val CC_PREFIX = "midicc@"
class AppViewModel(
val project: Project,
private val engine: AudioEngine,
@@ -49,6 +61,7 @@ class AppViewModel(
private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary,
private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore,
private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary,
private val midiClock: space.rcmd.android.sizzle.input.MidiClock,
private val appContext: android.content.Context,
) : ViewModel() {
@@ -107,6 +120,26 @@ class AppViewModel(
touched()
}
/** Send MIDI clock + start/stop to attached devices; follow an external clock's
* tempo + transport. Both off by default; toggled from Settings and persisted. */
var sendMidiClock by mutableStateOf(false); private set
var receiveMidiClock by mutableStateOf(false); private set
fun updateSendMidiClock(enabled: Boolean) {
sendMidiClock = enabled; midiClock.sendEnabled = enabled
engine.midiOutEnabled = enabled // also forward the tracks' notes out
persistMidiClock(); touched()
}
fun updateReceiveMidiClock(enabled: Boolean) {
receiveMidiClock = enabled; midiClock.receiveEnabled = enabled
persistMidiClock(); touched()
}
private fun persistMidiClock() {
viewModelScope.launch { settingsStore.saveMidiClock(sendMidiClock, receiveMidiClock) }
}
fun updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) }
fun togglePunchIn() { punchInMode = !punchInMode }
fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); persistKeyboard(); touched() }
@@ -135,6 +168,13 @@ class AppViewModel(
var learnTarget by mutableStateOf<String?>(null); private set
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null
// ----- Toolbox parameter MIDI-learn state (device editors) -----
/** The toolbox slot + parameter key currently waiting to capture a MIDI control,
* or (-1 / null) when nothing is arming. The device editor's MIDI-learn popup
* reads these to highlight the armed row. */
var paramLearnSlot by mutableIntStateOf(-1); private set
var paramLearnKey by mutableStateOf<String?>(null); private set
init {
// Collect neutral input actions from every non-touch device.
viewModelScope.launch {
@@ -185,15 +225,16 @@ class AppViewModel(
cursorColumn = CellColumn.entries[flat % cols]
}
/** Punch a note into the focused cell (velocity/channel from the keyboard) and
* advance the cursor by [skipStep] rows, cycling past the pattern ends — the
* touch-keyboard entry path. */
fun punchNote(midi: Int) {
/** Punch a note into the focused cell and advance the cursor by [skipStep] rows,
* cycling past the pattern ends. Velocity/channel default to the on-screen
* keyboard's (the touch-keyboard entry path); MIDI punch-in passes the incoming
* note's own velocity and channel so the recorded cell plays back correctly. */
fun punchNote(midi: Int, velocity: Int = kbdVelocity, channel: Int = kbdChannel) {
val cell = focusedCell()
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = kbdChannel
lastNote = cell.note; lastChannel = kbdChannel
cell.velocity = velocity.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = channel
lastNote = cell.note; lastChannel = channel
moveCursorVerticalWrap(skipStep)
touched()
}
@@ -221,12 +262,31 @@ class AppViewModel(
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
/** True if the on-screen keyboard's MIDI channel routes to any bus whose instrument
* is a Sampler. A Sampler maps a pad to each MIDI note, so scale filtering would
* hide pads — the stacked keyboards go fully chromatic in that case. */
fun kbdRoutesToSampler(): Boolean {
for (ch in project.mixer.channels) {
if (ch.midiChannel != kbdChannel) continue
val slot = project.toolbox.getOrNull(ch.instrumentSlot) ?: continue
if (slot.type == space.rcmd.android.sizzle.model.ToolboxType.SAMPLER) return true
}
return false
}
/** Live VU level (peak, ~0..1+) for mixer bus [ch] — polled at frame rate by the
* mixer's fader VU meters. */
fun channelMeter(ch: Int): Float = engine.channelMeter(ch)
/** True while mixer bus [ch] is (or just was) clipping past 0 dBFS. */
fun channelClipped(ch: Int): Boolean = engine.channelClipped(ch)
/** Copy the live scope samples for the effect in toolbox [slotIndex] into [out];
* returns the count, or -1 if it isn't a routed, running visual effect. Polled at
* frame rate by the Granular editor's oscilloscope. */
fun effectScope(slotIndex: Int, out: FloatArray): Int = engine.effectScope(slotIndex, out)
/** The effect [slotIndex]'s jumping playhead position (0..1), or -1 if none live. */
fun effectPlayhead(slotIndex: Int): Float = engine.effectPlayhead(slotIndex)
/**
* The core value-editing operation, shared by drag gestures and +/- input.
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
@@ -337,13 +397,42 @@ class AppViewModel(
touched()
}
// ---- Arrangement selection (hoisted from ArrangementRoll so Play/Stop can read it).
// Rectangular selection anchor (…0) and live corner (…1); beat < 0 = no selection.
// Play starts from [arrSelBeat0]'s beat if set; Stop clears the selection so the
// highlight disappears and the next Play resumes from the top.
var arrSelLane0 by mutableIntStateOf(-1); private set
var arrSelBeat0 by mutableIntStateOf(-1); private set
var arrSelLane1 by mutableIntStateOf(-1); private set
var arrSelBeat1 by mutableIntStateOf(-1); private set
/** Set/update the arrangement selection rectangle. Pass all corners at once so the
* ArrangementRoll's tap / drag gestures update it atomically. */
fun setArrangementSelection(lane0: Int, beat0: Int, lane1: Int, beat1: Int) {
arrSelLane0 = lane0; arrSelBeat0 = beat0; arrSelLane1 = lane1; arrSelBeat1 = beat1
}
fun clearArrangementSelection() {
arrSelLane0 = -1; arrSelBeat0 = -1; arrSelLane1 = -1; arrSelBeat1 = -1
}
// ------------------------------------------------------- transport control
fun playPause() {
val t = transport.value
if (t.isPlaying) engine.pause() else engine.play()
if (t.isPlaying) {
engine.pause()
} else {
// Start from the earliest beat of the arrangement selection if there is
// one; otherwise from wherever the engine last was (top after a Stop).
val start = if (arrSelBeat0 >= 0) minOf(arrSelBeat0, arrSelBeat1) else -1
engine.play(start)
}
}
fun stop() = engine.stop()
fun stop() {
clearArrangementSelection() // resets the highlight so the next Play starts from the top
engine.stop()
}
/** MIDI panic: silence all sounding/stuck notes immediately. */
fun midiPanic() { heldNotes.clear(); engine.panic() }
@@ -458,13 +547,22 @@ class AppViewModel(
InputAction.NoteOffCell -> noteOffFocused()
is InputAction.NoteOn -> {
markNoteOn(action.pitch)
engine.liveNoteOn(action.pitch, action.velocity)
// Live entry also writes the note into the focused NOTE cell.
if (cursorColumn == CellColumn.NOTE) {
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
// MIDI notes route to the mixer track(s) listening on their channel;
// a channel-less source (physical keyboard) plays the default synth.
if (action.channel >= 1) engine.busNoteOn(action.channel, action.pitch, action.velocity)
else engine.liveNoteOn(action.pitch, action.velocity)
// On the Tracker tab with punch-in armed, also enter the note into the
// grid — recording its own velocity/channel so it plays back correctly.
if (selectedTab == TAB_TRACKER && punchInMode) {
val ch = if (action.channel >= 1) action.channel else kbdChannel
punchNote(action.pitch, action.velocity, ch)
}
}
is InputAction.NoteOff -> { markNoteOff(action.pitch); engine.liveNoteOff(action.pitch) }
is InputAction.NoteOff -> {
markNoteOff(action.pitch)
if (action.channel >= 1) engine.busNoteOff(action.channel, action.pitch)
else engine.liveNoteOff(action.pitch)
}
InputAction.PlayPause -> playPause()
InputAction.Stop -> stop()
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
@@ -492,11 +590,95 @@ class AppViewModel(
router.learnMode = false
}
// ---------------------------------------------- toolbox parameter MIDI-learn
private fun ccKey(paramKey: String) = "$CC_PREFIX$paramKey"
/** The CC number currently bound to [slotIndex]'s [paramKey], or null. */
fun paramCcFor(slotIndex: Int, paramKey: String): Int? =
project.toolbox.getOrNull(slotIndex)?.string(ccKey(paramKey))?.toIntOrNull()
/** Arm MIDI-learn for a toolbox parameter: the next MIDI control moved binds to it. */
fun beginParamLearn(slotIndex: Int, paramKey: String) {
cancelLearn() // don't clash with a Settings nav/transport learn
paramLearnSlot = slotIndex
paramLearnKey = paramKey
router.learnSource = space.rcmd.android.sizzle.input.InputSource.MIDI
router.learnMode = true
touched()
}
fun cancelParamLearn() {
paramLearnSlot = -1
paramLearnKey = null
router.learnSource = null
router.learnMode = false
touched()
}
/** Remove the MIDI-CC binding for [slotIndex]'s [paramKey]. */
fun clearParamCc(slotIndex: Int, paramKey: String) {
project.toolbox.getOrNull(slotIndex)?.values?.remove(ccKey(paramKey))
touched()
}
/** Bind [cc] to [slotIndex]'s [paramKey]. A physical control drives one parameter,
* so the CC is first removed from any parameter it was previously bound to. */
private fun bindParamCc(slotIndex: Int, paramKey: String, cc: Int) {
for (s in project.toolbox) {
s.values.entries.removeAll { it.key.startsWith(CC_PREFIX) && it.value.toIntOrNull() == cc }
}
project.toolbox.getOrNull(slotIndex)?.set(ccKey(paramKey), cc.toString())
touched()
}
/** Drive every toolbox parameter bound to [cc] from a normalized ([0,1]) value. */
private fun applyParamCc(cc: Int, norm: Float) {
var changed = false
for (slot in project.toolbox) {
val type = slot.type ?: continue
for (spec in type.params) {
if (slot.values[ccKey(spec.key)]?.toIntOrNull() != cc) continue
applyNormToParam(slot, spec, norm)
changed = true
}
}
if (changed) touched()
}
private fun applyNormToParam(slot: ToolboxSlot, spec: ParamSpec, norm: Float) {
val n = norm.coerceIn(0f, 1f)
if (spec.isEnum) {
val idx = (n * (spec.choices.size - 1)).roundToInt().coerceIn(0, spec.choices.lastIndex)
slot.set(spec.key, spec.choices[idx])
} else {
var v = spec.min + n * (spec.max - spec.min)
if (spec.isInt) v = v.roundToInt().toFloat()
slot.set(spec.key, v)
}
}
/** A control arrived during learn. If it matches the armed source, store it as
* the binding for [learnTarget]. Each action keeps at most one binding, so any
* previous one for this action (on the same device) is replaced. */
private fun onRawControl(action: InputAction.RawControl) {
val target = learnTarget ?: return
// Toolbox parameter MIDI-learn takes priority: the first MIDI control captured
// binds to the armed parameter.
val plKey = paramLearnKey
if (plKey != null) {
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
bindParamCc(paramLearnSlot, plKey, action.code)
cancelParamLearn()
}
return
}
val target = learnTarget
if (target == null) {
// Not learning: a MIDI CC bound to a toolbox parameter drives it live.
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
applyParamCc(action.code, action.value)
}
return
}
if (action.source != learnSource) return
when (action.source) {
space.rcmd.android.sizzle.input.InputSource.MIDI ->
@@ -790,6 +972,24 @@ class AppViewModel(
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
kbdOctave = s.kbdOctave.coerceIn(0, 8)
vuMeters = s.vuMeters
// MIDI clock sync: feed the send thread live transport, follow an external
// clock into the transport/tempo, and restore the persisted toggles.
midiClock.playingProvider = { engine.transport.value.isPlaying }
midiClock.bpmProvider = { project.tempoBpm }
midiClock.noteSource = { engine.pollMidiOut() } // forward sequenced notes out
midiClock.onReceiveStart = {
viewModelScope.launch(Dispatchers.Main) { if (!engine.transport.value.isPlaying) engine.play() }
}
midiClock.onReceiveStop = {
viewModelScope.launch(Dispatchers.Main) { engine.pause() }
}
midiClock.onReceiveTempo = { bpm ->
viewModelScope.launch(Dispatchers.Main) { setTempo(bpm) }
}
sendMidiClock = s.sendMidiClock
midiClock.sendEnabled = s.sendMidiClock; engine.midiOutEnabled = s.sendMidiClock
receiveMidiClock = s.receiveMidiClock; midiClock.receiveEnabled = s.receiveMidiClock
engine.recTempDir = appContext.cacheDir
engine.setRecordTailBars(recordTailBars)
engine.onRecordingStarted = {
@@ -858,6 +1058,7 @@ class AppViewModel(
fun loadProjectText(text: String) {
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
cursorLine = 0; cursorTrack = 0
clearArrangementSelection() // the old arrangement's selection is no longer meaningful
touched()
}
@@ -929,6 +1130,70 @@ class AppViewModel(
fun writeSngExport(): java.io.File =
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
/** Write the current project as a full, self-contained `.szgz` bundle (project +
* all referenced sample WAVs) to a shareable file, for moving whole projects
* between Sizzletracker instances. */
fun writeProjectBundle(): java.io.File {
val f = songLibrary.bundleExportFile(project.name.ifBlank { "project" })
f.outputStream().use { space.rcmd.android.sizzle.io.ProjectBundleIo.write(project, it) }
return f
}
/** Import a full project bundle (in place), extracting its samples and restoring
* the audio. Returns false (leaving the current project) if it isn't a valid
* bundle. Replaces the current project, like [loadSong]. */
fun importProjectBundle(bytes: ByteArray): Boolean {
engine.stop()
val samplesDir = java.io.File(appContext.filesDir, "projectsamples")
val ok = runCatching {
space.rcmd.android.sizzle.io.ProjectBundleIo.read(
project, java.io.ByteArrayInputStream(bytes), samplesDir,
)
}.getOrDefault(false)
if (ok) {
presetLibrary.rehydrate(project) // decode the extracted WAVs into the SampleStore
engine.rebuildFxChains() // mixer FX routing may have changed
cursorLine = 0; cursorTrack = 0
touched()
}
return ok
}
// ------------------------------------------------------------- full backup/restore
/** Result of the last backup/restore, shown in a dialog; null when dismissed. */
var backupMessage by mutableStateOf<String?>(null); private set
fun dismissBackupMessage() { backupMessage = null }
/** Write a full backup (songs, presets, themes, autosave, settings + bindings) to
* the SAF [uri] the user picked. Runs off the main thread. */
fun backupTo(uri: android.net.Uri) {
viewModelScope.launch(Dispatchers.IO) {
val ok = runCatching {
appContext.contentResolver.openOutputStream(uri)?.use {
space.rcmd.android.sizzle.io.BackupIo.write(appContext.filesDir, it)
} != null
}.getOrDefault(false)
backupMessage = if (ok) "Backup saved." else "Couldn't write the backup."
}
}
/** Restore a full backup from the SAF [uri], replacing all current data. Settings
* and bindings apply after the app is restarted. Runs off the main thread. */
fun restoreFrom(uri: android.net.Uri) {
viewModelScope.launch(Dispatchers.IO) {
val ok = runCatching {
appContext.contentResolver.openInputStream(uri)?.use {
space.rcmd.android.sizzle.io.BackupIo.read(appContext.filesDir, it)
} ?: false
}.getOrDefault(false)
backupMessage = if (ok) {
"Backup restored. Close and reopen the app to finish applying it."
} else {
"Restore failed — that doesn't look like a Sizzletracker backup."
}
}
}
// ------------------------------------------------------ instrument/effect presets
/** Preset names available for a device type (for the editor's browser dropdown). */
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =

View File

@@ -28,6 +28,9 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
private const val VEL_STEP = 8
/** All twelve pitch classes — used when scale filtering is bypassed (Sampler routing). */
private val CHROMATIC: Set<Int> = (0..11).toSet()
/**
* A stacked two-octave keyboard shared by the Toolbox (audition) and Tracker
* (punch-in) tabs. Its channel, octave range and velocity live on the
@@ -41,9 +44,14 @@ fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
// Only in-scale keys are playable on BOTH the tracker punch-in and the toolbox
// audition keyboard (Chromatic yields all twelve = every key stays enabled).
// audition keyboard (Chromatic yields all twelve = every key stays enabled). But a
// Sampler maps a pad to each MIDI note, so scale filtering would hide pads — when
// the keyboard's channel routes to a Sampler, go fully chromatic so every pad is
// reachable.
val root = vm.project.rootNote
val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
val inKey: Set<Int> =
if (vm.kbdRoutesToSampler()) CHROMATIC
else vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
Column(
modifier.fillMaxWidth().background(c.background).padding(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.ui.theme.LocalRetro
@@ -51,6 +52,7 @@ fun Knob(
valueText: String,
modifier: Modifier = Modifier,
default: Float = min,
knobSize: Dp = 44.dp,
onChange: (Float) -> Unit,
) {
val c = LocalRetro.current
@@ -71,7 +73,7 @@ fun Knob(
)
Canvas(
Modifier
.size(44.dp)
.size(knobSize)
.pointerInput(min, max) {
// Relative: slide up to raise, down to lower. A full min..max sweep
// takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height),

View File

@@ -98,6 +98,7 @@ fun SettingsScreen(vm: AppViewModel) {
item { AudioDevicesCard(vm) }
item { InterfaceCard(vm) }
item { ThemeCard(vm) }
item { MidiSyncCard(vm) }
item {
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
section = SettingsSection.GAMEPAD
@@ -113,6 +114,7 @@ fun SettingsScreen(vm: AppViewModel) {
section = SettingsSection.MIDI
}
}
item { BackupCard(vm) }
}
SettingsSection.GAMEPAD -> LazyColumn(
@@ -235,7 +237,21 @@ private fun ProjectCard(vm: AppViewModel) {
}
}
SettingsCard("Project", subtitle = "Full projects saved on this device; .sng for desktop interchange") {
// Import a full project bundle (.szgz: project + its sample WAVs) from another
// Sizzletracker instance, replacing the current project.
val bundleImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
when {
bytes == null -> importError = "Couldn't read the selected file."
!vm.importProjectBundle(bytes) -> importError =
"Couldn't import this file. It doesn't look like a Sizzletracker project bundle."
}
}
SettingsCard("Project", subtitle = "Full projects saved on this device; share bundles between instances") {
// On-device full-project browser: the load dropdown sits ABOVE the action
// buttons (save / new / delete) so the selector is clear of them.
Box {
@@ -270,10 +286,29 @@ private fun ProjectCard(vm: AppViewModel) {
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
// Full project bundle (.szgz): project + all sample WAVs, for sharing whole
// projects between Sizzletracker instances.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { shareFile(context, vm.writeProjectBundle(), "application/zip", "Share project") }) {
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
Text("Export project")
}
OutlinedButton(onClick = { bundleImporter.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) {
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
Text("Import project")
}
}
Text(
"A bundle carries everything — patterns, mixer, FX, instruments and samples — " +
"so it opens complete on another device. Import replaces the current project.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
// Desktop interchange: .sng is only exported / imported, never the library.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { shareSongFile(context, vm.writeSngExport()) }) {
OutlinedButton(onClick = { shareFile(context, vm.writeSngExport(), "text/plain", "Share song") }) {
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
Text("Export .sng")
}
@@ -347,15 +382,15 @@ private fun ProjectCard(vm: AppViewModel) {
}
}
/** Launch the system share sheet for an exported .sng file. */
private fun shareSongFile(context: Context, file: java.io.File) {
/** Launch the system share sheet for an exported file (song, .sng, or bundle). */
private fun shareFile(context: Context, file: java.io.File, mime: String, title: String) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
type = mime
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, "Share song"))
context.startActivity(Intent.createChooser(send, title))
}
@Composable
@@ -372,6 +407,57 @@ private fun AudioDevicesCard(vm: AppViewModel) {
}
}
/** Whole-app backup to a single ZIP (songs, presets, themes, settings + bindings)
* and restore from one. Restore is destructive and needs an app restart. */
@Composable
private fun BackupCard(vm: AppViewModel) {
var confirmRestore by remember { mutableStateOf<Uri?>(null) }
val backupPicker = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/zip"),
) { uri -> uri?.let { vm.backupTo(it) } }
val restorePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> if (uri != null) confirmRestore = uri }
SettingsCard("Backup & Restore", subtitle = "Save or restore all songs, presets, themes & settings") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { backupPicker.launch("sizzletracker-backup.zip") }) { Text("Back up all") }
OutlinedButton(onClick = { restorePicker.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) {
Text("Restore")
}
}
Text(
"Backup writes one .zip. Restore replaces ALL current data and takes effect " +
"after you reopen the app.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
confirmRestore?.let { uri ->
AlertDialog(
onDismissRequest = { confirmRestore = null },
title = { Text("Restore backup?") },
text = {
Text(
"This replaces all current songs, presets, themes and settings with the " +
"backup's contents. This can't be undone.",
)
},
confirmButton = { TextButton(onClick = { vm.restoreFrom(uri); confirmRestore = null }) { Text("Restore") } },
dismissButton = { TextButton(onClick = { confirmRestore = null }) { Text("Cancel") } },
)
}
vm.backupMessage?.let { msg ->
AlertDialog(
onDismissRequest = { vm.dismissBackupMessage() },
title = { Text("Backup & Restore") },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { vm.dismissBackupMessage() }) { Text("OK") } },
)
}
}
/** About + external links: app version, source, user guide, and support. */
@Composable
private fun HelpCard() {
@@ -416,26 +502,53 @@ private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
}
/** Interface options: currently the mixer VU-meter toggle. */
/** MIDI clock sync over USB + Bluetooth: drive external gear, or follow an external
* clock. Both off by default. */
@Composable
private fun InterfaceCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
SettingsCard("Interface", subtitle = "On-screen display options") {
private fun MidiSyncCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
SettingsCard("MIDI Sync", subtitle = "MIDI clock over USB & Bluetooth") {
ToggleRow(
"Send MIDI clock",
"Send clock, start/stop and the tracks' notes to attached MIDI devices, so " +
"Sizzle drives them.",
vm.sendMidiClock,
) { vm.updateSendMidiClock(it) }
HorizontalDivider()
ToggleRow(
"Receive MIDI clock",
"Follow an external clock's tempo, and start/stop with it.",
vm.receiveMidiClock,
) { vm.updateReceiveMidiClock(it) }
}
}
/** A labelled switch row (title + supporting text + trailing Switch). */
@Composable
private fun ToggleRow(title: String, subtitle: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Mixer VU meters", style = MaterialTheme.typography.bodyMedium)
Text(
Text(title, style = MaterialTheme.typography.bodyMedium)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
/** Interface options: currently the mixer VU-meter toggle. */
@Composable
private fun InterfaceCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
SettingsCard("Interface", subtitle = "On-screen display options") {
ToggleRow(
"Mixer VU meters",
"Show live level meters inside the mixer volume faders.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = vm.vuMeters, onCheckedChange = { vm.updateVuMeters(it) })
}
vm.vuMeters,
) { vm.updateVuMeters(it) }
}
}

View File

@@ -0,0 +1,155 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.ui.toolbox
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.ui.AppViewModel
import space.rcmd.android.sizzle.ui.theme.LocalRetro
import kotlin.math.roundToInt
/**
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.GRANULAR] effect: a
* dotted oscilloscope of the sound passing through with the granular read head drawn
* as a jumping playhead, and one horizontal Intensity slider below. The scope only
* shows data while the effect is placed on a mixer channel that is receiving signal.
*/
@Composable
fun GranularEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
val mono = FontFamily.Monospace
// Polled once per frame from the audio engine: the scope samples, the playhead
// position, and how many samples are live (-1 = not routed / no signal).
val scope = remember { FloatArray(SCOPE_CAP) }
var count by remember { mutableIntStateOf(-1) }
var playhead by remember { mutableFloatStateOf(-1f) }
var tick by remember { mutableIntStateOf(0) }
LaunchedEffect(slot.index) {
while (true) {
withFrameNanos { }
count = vm.effectScope(slot.index, scope)
playhead = vm.effectPlayhead(slot.index)
tick++
}
}
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier.fillMaxWidth().height(160.dp).background(c.background).border(1.dp, c.grid, RectangleShape),
) {
Canvas(Modifier.fillMaxWidth().height(160.dp)) {
@Suppress("UNUSED_EXPRESSION") tick // subscribe the draw to the per-frame updates
val w = size.width
val h = size.height
val cy = h * 0.5f
val amp = h * 0.42f
drawLine(c.grid, Offset(0f, cy), Offset(w, cy), strokeWidth = 1f) // baseline
val n = count
if (n > 1) {
// Dotted waveform of the signal passing through.
val step = w / (n - 1)
for (k in 0 until n) {
val y = cy - scope[k].coerceIn(-1f, 1f) * amp
drawCircle(c.textDim, radius = 2f, center = Offset(k * step, y))
}
// Jumping playhead over the scope.
val ph = playhead
if (ph in 0f..1f) {
val px = ph * w
drawLine(c.accent, Offset(px, 0f), Offset(px, h), strokeWidth = 2f)
drawCircle(c.accent, radius = 4f, center = Offset(px, cy))
}
}
}
if (count < 0) {
Text(
"Add to a mixer channel's FX and play to see the scope.",
color = c.textDim, fontFamily = mono, fontSize = 11.sp,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
)
}
}
val intensity = slot.float("intensity", 0.3f).coerceIn(0f, 1f)
Text("Intensity ${(intensity * 100).roundToInt()}%",
color = c.accent, fontFamily = mono, fontSize = 12.sp)
HorizontalSlider(
value = intensity,
onValueChange = { slot.set("intensity", it.coerceIn(0f, 1f)); vm.bumpForToolbar() },
modifier = Modifier.fillMaxWidth().height(40.dp),
)
Text(
"Low = occasional stutters · High = constant glitch & octave jumps.",
color = c.textDim, fontFamily = mono, fontSize = 10.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
}
}
/** A horizontal fill-from-left slider matching the app's retro faders. */
@Composable
private fun HorizontalSlider(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
val c = LocalRetro.current
Box(
modifier
.border(1.dp, c.grid, RectangleShape)
.background(c.background)
.pointerInput(Unit) {
detectTapGestures { off -> onValueChange(off.x / size.width) }
}
.pointerInput(Unit) {
detectDragGestures { change, _ -> change.consume(); onValueChange(change.position.x / size.width) }
},
contentAlignment = Alignment.CenterStart,
) {
Box(
Modifier
.fillMaxHeight()
.fillMaxWidth(value.coerceIn(0.001f, 1f))
.background(c.accent.copy(alpha = 0.30f)),
contentAlignment = Alignment.CenterEnd,
) {
Box(Modifier.fillMaxHeight().width(3.dp).background(c.accent)) // thumb
}
}
}
private const val SCOPE_CAP = 128 // UI buffer; the engine fills up to its own scope size

View File

@@ -0,0 +1,254 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.ui.toolbox
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.TimeDivision
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.ui.AppViewModel
import space.rcmd.android.sizzle.ui.components.Knob
import space.rcmd.android.sizzle.ui.theme.LocalRetro
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
/**
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.TAPE_ENGINE] effect,
* drawn as a reel-to-reel tape transport: two NAB reels (metal flange with three
* rounded-triangular bobbin holes) feeding tape down to a playhead block between them
* under tension. **Press and hold a reel to brake it** — the tape (and its pitch)
* dives, then springs back when released. Below is a row of vintage round knobs: wow,
* flutter, hiss, and the tempo-sync division for the wow wobble.
*/
@Composable
fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
val mono = FontFamily.Monospace
// Rotation + a display speed eased toward the slot's target, so the reels dive and
// recover in step with the audio's own glide. Advanced once per frame.
var angle by remember { mutableFloatStateOf(0f) }
var displaySpeed by remember { mutableFloatStateOf(1f) }
LaunchedEffect(slot.index) {
while (true) {
withFrameNanos { }
val target = slot.float("speed", 1f).coerceIn(0.25f, 1f)
displaySpeed += (target - displaySpeed) * 0.10f
angle = (angle + displaySpeed * DEG_PER_FRAME) % 360f
}
}
fun setSpeed(v: Float) { slot.set("speed", v.coerceIn(0.25f, 1f)); vm.bumpForToolbar() }
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.fillMaxWidth()
.height(180.dp)
.background(c.surface)
// Press a reel to brake the tape; release to let it spin back up.
.pointerInput(slot.index) {
detectTapGestures(
onPress = { off ->
if (overReel(off, size.width.toFloat(), size.height.toFloat())) {
setSpeed(BRAKE_SPEED)
tryAwaitRelease()
setSpeed(1f)
}
},
)
},
) {
Canvas(Modifier.fillMaxWidth().height(180.dp)) {
val cx0 = size.width * REEL_L_X
val cx1 = size.width * REEL_R_X
val cy = size.height * REEL_CY
val r = min(size.height * 0.30f, size.width * 0.20f)
// Playhead block between + below the reels.
val hw = (cx1 - cx0) * 0.46f
val hh = size.height * 0.13f
val hx = size.width * 0.5f
val hy = cy + r + size.height * 0.14f
val tl = Offset(hx - hw / 2f, hy - hh / 2f)
val tr = Offset(hx + hw / 2f, hy - hh / 2f)
// Tensioned tape: from each reel's bottom straight down to the playhead
// and across its top. No line over the top of the reels.
val p0 = Offset(cx0, cy + r)
val p1 = Offset(cx1, cy + r)
val tape = c.textDim
drawLine(tape, p0, tl, strokeWidth = 2.5f)
drawLine(tape, tl, tr, strokeWidth = 2.5f)
drawLine(tape, tr, p1, strokeWidth = 2.5f)
// Playhead (rounded rectangle) drawn over the tape it tensions.
drawRoundRect(
color = c.grid, topLeft = Offset(hx - hw / 2f, hy - hh / 2f),
size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f),
)
drawRoundRect(
color = c.accent, topLeft = Offset(hx - hw / 2f, hy - hh / 2f),
size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f),
style = Stroke(width = 2f),
)
// Head gap slit in the middle of the playhead.
drawLine(c.accent, Offset(hx, hy - hh / 2f + 3f), Offset(hx, hy + hh / 2f - 3f), strokeWidth = 2f)
drawReel(cx0, cy, r, angle, c.accent, c.grid, c.surface, c.textDim)
drawReel(cx1, cy, r, angle, c.accent, c.grid, c.surface, c.textDim)
}
}
Text(
"Hold a reel to brake the tape — pitch dives, then springs back.",
color = c.textDim, fontFamily = mono, fontSize = 11.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
// Vintage round knobs, all in a row (each weighted so all four fit).
val divIdx = slot.float("division", 2f).toInt().coerceIn(0, TimeDivision.entries.lastIndex)
Row(
Modifier.fillMaxWidth().padding(top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Top,
) {
PercentKnob(vm, slot, "wow", "WOW", 0.15f, Modifier.weight(1f))
PercentKnob(vm, slot, "flutter", "FLUTTER", 0.15f, Modifier.weight(1f))
PercentKnob(vm, slot, "hiss", "HISS", 0.10f, Modifier.weight(1f))
Knob(
label = "WOW SYNC",
value = divIdx.toFloat(), min = 0f, max = TimeDivision.entries.lastIndex.toFloat(),
valueText = TimeDivision.fromIndex(divIdx).label, default = 2f,
modifier = Modifier.weight(1f), knobSize = KNOB_SIZE,
onChange = { v ->
slot.set("division", v.roundToInt().coerceIn(0, TimeDivision.entries.lastIndex).toFloat())
vm.bumpForToolbar()
},
)
}
}
}
/** A 0..1 knob showing its value as a percentage, writing [key] on the slot. */
@Composable
private fun PercentKnob(vm: AppViewModel, slot: ToolboxSlot, key: String, label: String, default: Float, modifier: Modifier) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
val value = slot.float(key, default).coerceIn(0f, 1f)
Knob(
label = label, value = value, min = 0f, max = 1f,
valueText = "${(value * 100).roundToInt()}%", default = default,
modifier = modifier, knobSize = KNOB_SIZE,
onChange = { slot.set(key, it.coerceIn(0f, 1f)); vm.bumpForToolbar() },
)
}
/** True if [off] is within either reel's circle (used to gate the brake gesture). */
private fun overReel(off: Offset, w: Float, h: Float): Boolean {
val cy = h * REEL_CY
val r = min(h * 0.30f, w * 0.20f)
return hypot(off.x - w * REEL_L_X, off.y - cy) <= r ||
hypot(off.x - w * REEL_R_X, off.y - cy) <= r
}
/** Draw one NAB reel: metal flange, three rotating rounded-triangular bobbin holes,
* and the hub in the centre. */
private fun DrawScope.drawReel(
cx: Float, cy: Float, r: Float, angle: Float,
accent: Color, flange: Color, hole: Color, rim: Color,
) {
val center = Offset(cx, cy)
drawCircle(flange, radius = r, center = center) // metal flange
for (k in 0 until 3) drawPath(bobbinHole(cx, cy, r, angle + k * 120f), hole) // trapezoidal cutouts
drawCircle(hole, radius = r * 0.26f, center = center) // bobbin centre (open)
drawCircle(accent, radius = r * 0.26f, center = center, style = Stroke(width = 3f))
drawCircle(accent, radius = r * 0.14f, center = center, style = Stroke(width = 2f))
drawCircle(rim, radius = r, center = center, style = Stroke(width = 2.5f)) // flange edge
}
/** One rounded-trapezoidal bobbin hole at [thetaDeg] around the reel: wide toward the
* hub, narrow toward the rim (the narrow side faces outward). */
private fun bobbinHole(cx: Float, cy: Float, r: Float, thetaDeg: Float): Path {
val rIn = r * 0.40f; val rOut = r * 0.86f
val inner = 44f // angular half-spread at the hub (wide side)
val outer = 13f // angular half-spread at the rim (narrow side)
val innerL = polar(cx, cy, rIn, thetaDeg - inner)
val outerL = polar(cx, cy, rOut, thetaDeg - outer)
val outerR = polar(cx, cy, rOut, thetaDeg + outer)
val innerR = polar(cx, cy, rIn, thetaDeg + inner)
return roundedPoly(arrayOf(innerL, outerL, outerR, innerR), r * 0.09f)
}
/** A closed polygon through [v] with every corner rounded to radius [corner]. */
private fun roundedPoly(v: Array<Offset>, corner: Float): Path {
val n = v.size
val path = Path()
for (i in 0 until n) {
val curr = v[i]
val prev = v[(i + n - 1) % n]
val next = v[(i + 1) % n]
val a = curr + toward(curr, prev, corner) // approach point on the incoming edge
val b = curr + toward(curr, next, corner) // leave point on the outgoing edge
if (i == 0) path.moveTo(a.x, a.y) else path.lineTo(a.x, a.y)
path.quadraticBezierTo(curr.x, curr.y, b.x, b.y)
}
path.close()
return path
}
/** Vector of length [len] from [from] toward [to]. */
private fun toward(from: Offset, to: Offset, len: Float): Offset {
val dx = to.x - from.x; val dy = to.y - from.y
val d = hypot(dx, dy).coerceAtLeast(1e-3f)
return Offset(dx / d * len, dy / d * len)
}
private fun polar(cx: Float, cy: Float, rad: Float, deg: Float): Offset {
val a = Math.toRadians(deg.toDouble())
return Offset(cx + rad * cos(a).toFloat(), cy + rad * sin(a).toFloat())
}
private val KNOB_SIZE = 58.dp // a touch bigger than the default 44 dp knob
private const val DEG_PER_FRAME = 6f // ~1 rev/sec at 60 fps and full speed
private const val BRAKE_SPEED = 0.5f // target while a reel is held (≈ one octave down)
private const val REEL_L_X = 0.30f
private const val REEL_R_X = 0.70f
private const val REEL_CY = 0.38f // reel centre as a fraction of the canvas height

View File

@@ -39,6 +39,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -242,30 +243,46 @@ private fun SlotTile(
}
}
/** A simple overlay list of all available instruments and effects to load. */
/** A simple overlay list of all available devices, grouped into instruments, MIDI
* effects and audio effects (separated by a gap + header). */
@Composable
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
val c = LocalRetro.current
// MIDI effects alter note generation (handled by the sequencer); every other
// effect processes audio. Split the two so the list reads in three clear groups.
val midiEffects = setOf(ToolboxType.ARPEGGIATOR, ToolboxType.TRANSPOSER, ToolboxType.LFO)
val groups = listOf(
"INSTRUMENTS" to ToolboxType.entries.filter { it.isInstrument },
"MIDI EFFECTS" to ToolboxType.entries.filter { !it.isInstrument && it in midiEffects },
"AUDIO EFFECTS" to ToolboxType.entries.filter { !it.isInstrument && it !in midiEffects },
)
Box(
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
contentAlignment = Alignment.Center,
) {
Column(Modifier.padding(16.dp)) {
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
ToolboxType.entries.forEach { type ->
Column(Modifier.fillMaxHeight().padding(16.dp).verticalScroll(rememberScrollState())) {
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 16.sp)
groups.forEach { (header, types) ->
Spacer(Modifier.height(16.dp)) // visual gap between groups
Text(
"[${type.kind.name.take(3)}] ${type.displayName}",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
header, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
modifier = Modifier.padding(bottom = 2.dp),
)
types.forEach { type ->
Text(
type.displayName,
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 18.sp,
modifier = Modifier
.fillMaxWidth()
.pointerInput(type) { detectTapGestures { onPick(type) } }
.padding(vertical = 6.dp),
.padding(vertical = 8.dp),
)
}
}
}
}
}
/** Auto-generated parameter editor. Presets are managed by the [PresetBar]. */
@Composable
@@ -283,6 +300,8 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER
// Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree.
var samplerPad by remember(slot.index) { mutableIntStateOf(0) }
// Whether the MIDI-learn popup (map controls to this device's parameters) is open.
var showMidiLearn by remember(slot.index) { mutableStateOf(false) }
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
Column(Modifier.fillMaxSize()) {
@@ -296,9 +315,14 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
// Right-side toolbar: MIDI-learn (map controls to params) + close.
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Text("⇄ MIDI", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { showMidiLearn = true } })
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
}
}
// Preset browser: load / save / import / share for this device.
PresetBar(vm, slot)
@@ -310,6 +334,8 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
when (type) {
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(vm, slot)
ToolboxType.TAPE_ENGINE -> TapeEngineEditor(vm, slot)
ToolboxType.GRANULAR -> GranularEditor(vm, slot)
ToolboxType.SAMPLER -> SamplerEditor(vm, slot, samplerPad, onSelectPad = { samplerPad = it })
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
ToolboxType.DELAY -> DelayEditor(vm, slot)
@@ -331,6 +357,85 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
}
}
}
if (showMidiLearn) {
MidiLearnPopup(vm, slot, onClose = { showMidiLearn = false })
}
}
}
/**
* Maps a MIDI controller's knobs/faders to this device's adjustable parameters.
* Lists every [ParamSpec] of the slot's device; tap LEARN on a row, then move a
* control on the connected MIDI device and its CC binds to that parameter (the CC's
* 0..127 sweep then drives the parameter's full range live). The binding is stored
* in the slot (so it travels with the project / preset); ✕ clears it.
*/
@Composable
private fun MidiLearnPopup(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh CC readouts + armed row
val type = slot.type ?: return
// Always disarm learn when the popup leaves the composition (closed / editor exited).
DisposableEffect(slot.index) { onDispose { vm.cancelParamLearn() } }
Box(
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
.pointerInput(Unit) { detectTapGestures { onClose() } },
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.fillMaxWidth(0.96f)
.fillMaxHeight(0.9f)
.border(1.dp, c.accent, RectangleShape)
.background(c.surface)
.pointerInput(Unit) { detectTapGestures { } } // swallow taps inside the card
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("MIDI LEARN — ${slot.name}", color = c.accent,
fontFamily = FontFamily.Monospace, fontSize = 14.sp)
Text("DONE", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
}
Text(
"Tap LEARN, then move a knob/fader on your MIDI controller.",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
)
Column(
Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
type.params.forEach { spec ->
val arming = vm.paramLearnSlot == slot.index && vm.paramLearnKey == spec.key
val cc = vm.paramCcFor(slot.index, spec.key)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(spec.label, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
modifier = Modifier.weight(1f))
Text(
when {
arming -> "waiting…"
cc != null -> "CC $cc"
else -> ""
},
color = if (arming) c.accent else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
modifier = Modifier.width(72.dp),
)
RetroButton(if (arming) "ARMING" else "LEARN", active = arming, width = 78.dp) {
if (arming) vm.cancelParamLearn() else vm.beginParamLearn(slot.index, spec.key)
}
RetroButton("") { vm.clearParamCc(slot.index, spec.key) }
}
}
}
}
}
}
@@ -338,7 +443,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last
* param gets an empty cell beside it). */
@Composable
private fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
internal fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
var i = 0
while (i < params.size) {

View File

@@ -94,20 +94,19 @@ fun ArrangementRoll(vm: AppViewModel) {
val glyphs = remember(measurer) { GlyphCache(measurer) }
val beatWidth = 20.dp
val muteColWidth = 34.dp // wide enough to hit comfortably from the screen edge
val laneColWidth = 68.dp // mute toggle + block number
val muteColWidth = 51.dp // ~1.5× — wider, easy to hit from the screen edge
val laneColWidth = 102.dp // mute toggle (51) + block number header (51), both ~1.5×
val rulerHeight = 18.dp
val beatWidthPx = with(density) { beatWidth.toPx() }
val rulerHeightPx = with(density) { rulerHeight.toPx() }
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none.
// Unlike before, selecting does NOT change any cell; the toolbar toggle acts
// on the selection. Read in composition (for the toolbar's FILL/CLEAR label)
// and captured by the Canvas draw below.
var selLane0 by remember { mutableIntStateOf(-1) }
var selBeat0 by remember { mutableIntStateOf(-1) }
var selLane1 by remember { mutableIntStateOf(-1) }
var selBeat1 by remember { mutableIntStateOf(-1) }
// Rectangular cell selection lives on the ViewModel so the transport (Play/Stop)
// can read and clear it — Play starts from the selection's first beat, Stop clears
// it. Selecting still does NOT change any cell; the toolbar toggle acts on it.
val selLane0 = vm.arrSelLane0
val selBeat0 = vm.arrSelBeat0
val selLane1 = vm.arrSelLane1
val selBeat1 = vm.arrSelBeat1
val hasSelection = selBeat0 >= 0
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
@@ -227,8 +226,7 @@ fun ArrangementRoll(vm: AppViewModel) {
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
.coerceIn(0, ArrModel.LANE_COUNT - 1)
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
selLane0 = lane; selLane1 = lane
selBeat0 = beat; selBeat1 = beat
vm.setArrangementSelection(lane, beat, lane, beat)
}
}
// Long-press then drag sweeps out a rectangular selection.
@@ -240,14 +238,16 @@ fun ArrangementRoll(vm: AppViewModel) {
}
fun beatAt(off: Offset): Int =
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
var dragLane0 = 0; var dragBeat0 = 0
detectDragGesturesAfterLongPress(
onDragStart = { off ->
selLane0 = laneAt(off); selLane1 = selLane0
selBeat0 = beatAt(off); selBeat1 = selBeat0
dragLane0 = laneAt(off); dragBeat0 = beatAt(off)
vm.setArrangementSelection(dragLane0, dragBeat0, dragLane0, dragBeat0)
},
onDrag = { change, _ ->
selLane1 = laneAt(change.position)
selBeat1 = beatAt(change.position)
vm.setArrangementSelection(
dragLane0, dragBeat0, laneAt(change.position), beatAt(change.position),
)
},
)
},

View File

@@ -4,6 +4,7 @@
package space.rcmd.android.sizzle.ui.tracker
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -19,8 +20,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -147,14 +150,31 @@ private fun TransportButtons(vm: AppViewModel) {
// Fixed width so the button doesn't resize as the label toggles.
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
active = isPlaying, width = 112.dp, onClick = vm::playPause)
// Tempo nudger: tap -/+ around a fixed-width readout.
// Tempo nudger: tap -/+ around a fixed-width readout. Tapping the readout itself
// is TAP TEMPO — two or more taps in time set the tempo from the average interval.
val tapTimes = remember { ArrayList<Long>() }
Row(verticalAlignment = Alignment.CenterVertically) {
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
Text(
"${project.tempoBpm.toInt()} BPM",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
textAlign = TextAlign.Center, maxLines = 1,
modifier = Modifier.width(72.dp).padding(vertical = 6.dp),
modifier = Modifier
.width(72.dp)
.pointerInput(Unit) {
detectTapGestures {
val now = System.currentTimeMillis()
// A long pause starts a fresh count rather than averaging across it.
if (tapTimes.isNotEmpty() && now - tapTimes.last() > TAP_RESET_MS) tapTimes.clear()
tapTimes.add(now)
while (tapTimes.size > TAP_MAX) tapTimes.removeAt(0)
if (tapTimes.size >= 2) {
val span = tapTimes.last() - tapTimes.first()
if (span > 0) vm.setTempo(60_000f * (tapTimes.size - 1) / span)
}
}
}
.padding(vertical = 6.dp),
)
RepeatButton("+", onStep = { vm.setTempo(project.tempoBpm + 1) })
}
@@ -224,3 +244,8 @@ private fun EditButtons(vm: AppViewModel) {
// Swap the bottom half between the arranger and the punch-in keyboard.
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
}
// Tap-tempo tuning: a gap longer than this starts a fresh count; keep at most this
// many recent taps in the averaging window.
private const val TAP_RESET_MS = 2000L
private const val TAP_MAX = 8

View File

@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.audio
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The arpeggiator must cycle through ALL notes of a held chord (across the octave
* range), not collapse to the last one entered — the fix for a held chord ringing
* only its rightmost note.
*/
class ChannelArpTest {
private fun arp() = AudioEngine.ChannelArp()
/** The Up sequence over a chord × octaves is octave-major over the sorted notes. */
@Test fun upCyclesWholeChordAcrossOctaves() {
val a = arp()
// Enter the chord out of order; it should sort ascending.
a.startChord(67, 100, octs = 2, modeStr = "Up", stepSamp = 100.0, resetBar = false)
a.addNote(60)
a.addNote(64)
assertEquals(3, a.noteCount)
// step 0 is the first note before any advance.
val seq = ArrayList<Int>()
seq.add(a.currentNote())
repeat(5) { a.advance(); seq.add(a.currentNote()) }
// 3 notes × 2 octaves = C4 E4 G4 C5 E5 G5, then wraps to C4.
assertEquals(listOf(60, 64, 67, 72, 76, 79), seq)
a.advance()
assertEquals("wraps back to the start", 60, a.currentNote())
}
@Test fun downStartsHandledAndStaysInRange() {
val a = arp()
a.startChord(60, 100, octs = 1, modeStr = "Down", stepSamp = 100.0, resetBar = false)
a.addNote(64); a.addNote(67)
val seen = HashSet<Int>()
repeat(9) { seen.add(a.currentNote()); a.advance() }
// Only the three chord notes (one octave) ever sound.
assertEquals(setOf(60, 64, 67), seen)
}
@Test fun addNoteDeDuplicates() {
val a = arp()
a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
a.addNote(60); a.addNote(60)
assertEquals(1, a.noteCount)
}
@Test fun resetChordKeepsGoingWithNewChord() {
val a = arp()
a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
a.addNote(64)
a.advance() // move off step 0
// A loop-back rebuilds the chord but keeps arpeggiating.
a.resetChord(48, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
a.addNote(55)
assertTrue(a.active)
val seen = HashSet<Int>()
repeat(4) { seen.add(a.currentNote()); a.advance() }
assertEquals(setOf(48, 55), seen)
}
@Test fun stopClearsChord() {
val a = arp()
a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
a.stop()
assertEquals(0, a.noteCount)
assertTrue(!a.active)
}
}

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.io
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
/** Round-trip + safety coverage for the whole-app [BackupIo]. */
class BackupIoTest {
private fun tempDir(): File = Files.createTempDirectory("backup-test").toFile()
private fun seed(dir: File) {
File(dir, "songs").mkdirs()
File(dir, "songs/tune.szg").writeText("SONG-DATA")
File(dir, "presets/DELAY").mkdirs()
File(dir, "presets/DELAY/slap.szp").writeText("PRESET-DATA")
File(dir, "datastore").mkdirs()
File(dir, "datastore/app_settings.preferences_pb").writeBytes(byteArrayOf(1, 2, 3, 4))
File(dir, "autosave.sng").writeText("AUTO-DATA")
}
@Test fun backupRestoreRoundTripReplacesContent() {
val source = tempDir().also { seed(it) }
val zip = ByteArrayOutputStream().also { BackupIo.write(source, it) }.toByteArray()
// Restore into a dir that already holds different + stale data.
val target = tempDir()
File(target, "songs").mkdirs()
File(target, "songs/stale.szg").writeText("STALE") // not in the backup → must go
assertTrue(BackupIo.read(target, ByteArrayInputStream(zip)))
assertEquals("SONG-DATA", File(target, "songs/tune.szg").readText())
assertFalse("a song absent from the backup is removed", File(target, "songs/stale.szg").exists())
assertEquals("PRESET-DATA", File(target, "presets/DELAY/slap.szp").readText())
assertEquals("AUTO-DATA", File(target, "autosave.sng").readText())
assertArrayEquals(byteArrayOf(1, 2, 3, 4), File(target, "datastore/app_settings.preferences_pb").readBytes())
assertFalse("staging is cleaned up", File(target, ".restore_tmp").exists())
}
@Test fun invalidArchiveLeavesDataIntact() {
val target = tempDir()
File(target, "songs").mkdirs()
File(target, "songs/keep.szg").writeText("KEEP")
assertFalse(BackupIo.read(target, ByteArrayInputStream("this is not a zip".toByteArray())))
assertEquals("existing data survives a bad restore file", "KEEP", File(target, "songs/keep.szg").readText())
}
@Test fun emptyZipIsRejected() {
val emptyZip = ByteArrayOutputStream().also { java.util.zip.ZipOutputStream(it).close() }.toByteArray()
val target = tempDir()
File(target, "songs").mkdirs()
File(target, "songs/keep.szg").writeText("KEEP")
assertFalse(BackupIo.read(target, ByteArrayInputStream(emptyZip)))
assertEquals("KEEP", File(target, "songs/keep.szg").readText())
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) =
assertTrue("byte arrays differ", a.contentEquals(b))
}

View File

@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.io
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import space.rcmd.android.sizzle.audio.SampleStore
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.SamplerPads
import space.rcmd.android.sizzle.model.ToolboxType
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
/** A project bundle must carry the referenced sample audio and rebuild it on import. */
class ProjectBundleIoTest {
@Test fun roundTripCarriesTheSampleAndRepointsTheSlot() {
// A source project with a Sampler pad referencing a sample in the store.
val id = "src-sample-id"
SampleStore.put(id, SampleStore.Sample(FloatArray(256) { it / 256f - 0.5f }, 48_000))
val src = Project().apply {
name = "Shared Tune"
toolbox[3].fill(ToolboxType.SAMPLER)
toolbox[3].set(SamplerPads.key(0), id)
}
val bytes = ByteArrayOutputStream().also { ProjectBundleIo.write(src, it) }.toByteArray()
// Import into a fresh project + samples dir (simulating another device).
val samplesDir = Files.createTempDirectory("bundle-samples").toFile()
val dst = Project()
assertTrue(ProjectBundleIo.read(dst, ByteArrayInputStream(bytes), samplesDir))
assertEquals("Shared Tune", dst.name)
assertEquals(ToolboxType.SAMPLER, dst.toolbox[3].type)
// The pad still references the original id (resolved by rehydrate at load time)…
assertEquals(id, dst.toolbox[3].string(SamplerPads.key(0)))
// …and now carries a portable file ref to the extracted WAV, which exists and decodes.
val wavPath = dst.toolbox[3].string("pad0File")
assertTrue("pad0File points at an extracted WAV", wavPath.isNotBlank())
val wav = File(wavPath)
assertTrue("extracted WAV exists on disk", wav.exists())
val decoded = SampleStore.decodeWav(wav.readBytes())
assertNotNull("the carried sample decodes", decoded)
assertTrue(decoded!!.data.isNotEmpty())
}
@Test fun invalidBundleIsRejected() {
val dst = Project()
val samplesDir = Files.createTempDirectory("bundle-samples2").toFile()
assertFalse(ProjectBundleIo.read(dst, ByteArrayInputStream("not a zip".toByteArray()), samplesDir))
}
}