Compare commits

...

7 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
15 changed files with 928 additions and 67 deletions

View File

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

View File

@@ -52,7 +52,7 @@ class MainActivity : ComponentActivity() {
app.project, app.audioEngine, app.inputRouter, app.project, app.audioEngine, app.inputRouter,
app.gamepadInput, app.midiInput, app.keyboardInput, app.gamepadInput, app.midiInput, app.keyboardInput,
app.bindingStore, app.presetLibrary, app.songLibrary, 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) SizzleAppUi(viewModel)
} }
} }
}
override fun onStart() { // Start MIDI listening (USB + BLE) and the MIDI clock here — bound to the
super.onStart() // 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() midi.start()
app.midiClock.start()
} }
override fun onResume() { override fun onResume() {
@@ -92,12 +96,21 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
// Autosave the project whenever we leave the foreground, so a later // 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) app.projectStore.save(app.project)
midi.stop()
super.onStop() 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 ---- // ---- Hardware input forwarding ----
// Gamepad events are intercepted at DISPATCH time — before Compose's focus // Gamepad events are intercepted at DISPATCH time — before Compose's focus
// system can consume D-pad / button keys — so USB *and* Bluetooth pads work // 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 gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
val midiInput: MidiInput by lazy { MidiInput(this, 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). */ /** Persists the input binding maps across restarts (DataStore-backed). */
val bindingStore: BindingStore by lazy { BindingStore(this) } 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 // single small read; blocking briefly at cold start keeps the input maps
// authoritative from the first keypress rather than racing an async load. // authoritative from the first keypress rather than racing an async load.
runBlocking { bindingStore.loadInto(keyboardInput, gamepadInput, midiInput) } runBlocking { bindingStore.loadInto(keyboardInput, gamepadInput, midiInput) }
// Feed incoming MIDI real-time (clock / start / stop) to the clock sync module.
midiInput.onRealtime = midiClock::handleRealtime
installCrashAutosave() installCrashAutosave()
} }

View File

@@ -88,6 +88,19 @@ class AudioEngine(
// track last started, on the bus(es) that channel routed to. -1 = nothing yet. // 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 } 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 // 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, // (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. // and active voices can be refreshed per block for live parameter feedback.
@@ -493,9 +506,34 @@ class AudioEngine(
} }
// --------------------------------------------------------------- transport // --------------------------------------------------------------- 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() rebuildArrangement()
rebuildFxChains() 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 pendingTrigger = true
playing = true playing = true
maybeStartRecording() maybeStartRecording()
@@ -520,6 +558,7 @@ class AudioEngine(
busSampleVoices.forEach { it.kill() } busSampleVoices.forEach { it.kill() }
arps.forEach { it.stop() } arps.forEach { it.stop() }
trackLastChannel.fill(-1) trackLastChannel.fill(-1)
allMidiNotesOff() // release any notes sent to external gear
} }
/** Stop playback. If already stopped, rewind to the very beginning. */ /** Stop playback. If already stopped, rewind to the very beginning. */
@@ -870,6 +909,8 @@ class AudioEngine(
private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) { private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
val trackKey = lane * Pattern.TRACK_COUNT + track val trackKey = lane * Pattern.TRACK_COUNT + track
if (cell.isNoteOff) { 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] val ch = trackLastChannel[trackKey]
if (ch < 0) return // this track hasn't played anything yet if (ch < 0) return // this track hasn't played anything yet
for (bus in 0 until Pattern.TRACK_COUNT) { for (bus in 0 until Pattern.TRACK_COUNT) {
@@ -882,6 +923,13 @@ class AudioEngine(
if (!cell.isPlayable) return if (!cell.isPlayable) return
// Remember the channel so a later note-off on this track releases it. // Remember the channel so a later note-off on this track releases it.
trackLastChannel[trackKey] = cell.channel 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, // 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 // 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 // explicit note-off cell needed. Only this track's own voices are released, so
@@ -1382,6 +1430,45 @@ class AudioEngine(
seqVoices.forEach { it.noteOff() } seqVoices.forEach { it.noteOff() }
sampleVoices.forEach { it.noteOff() } sampleVoices.forEach { it.noteOff() }
arps.forEach { it.stop() } 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() { private fun publish() {

View File

@@ -33,8 +33,12 @@ sealed interface InputAction {
data object NoteOffCell : InputAction data object NoteOffCell : InputAction
// ----- Live note entry (also used to audition instruments) ----- // ----- Live note entry (also used to audition instruments) -----
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction // [channel] is the 1-based MIDI channel (1..16) for MIDI input, so notes can be
data class NoteOff(val pitch: Int) : InputAction // 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 ----- // ----- Transport -----
data object PlayPause : InputAction 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). */ /** CC number -> action id, editable from Settings (MIDI bindings section). */
var ccBindings: MutableMap<Int, String> = mutableMapOf(), 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 midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val openPorts = mutableListOf<MidiOutputPort>() private val openPorts = mutableListOf<MidiOutputPort>()
@@ -67,17 +70,23 @@ class MidiInput(
while (i < end) { while (i < end) {
val status = data[i].toInt() and 0xFF val status = data[i].toInt() and 0xFF
if (status < 0x80) { i++; continue } // skip stray data bytes 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 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) { when (type) {
0x90 -> { // Note On 0x90 -> { // Note On
val note = data.getOrZero(i + 1) val note = data.getOrZero(i + 1)
val vel = data.getOrZero(i + 2) val vel = data.getOrZero(i + 2)
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel)) if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel, channel))
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off else router.dispatch(InputAction.NoteOff(note, channel)) // vel 0 == note off
i += 3 i += 3
} }
0x80 -> { // Note Off 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 0xB0 -> { // Control Change
val cc = data.getOrZero(i + 1) val cc = data.getOrZero(i + 1)

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, val kbdOctave: Int = 3,
/** Show the live VU meters inside the mixer volume faders. */ /** Show the live VU meters inside the mixer volume faders. */
val vuMeters: Boolean = true, 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, kbdChannel = prefs[KBD_CHANNEL] ?: 0,
kbdOctave = prefs[KBD_OCTAVE] ?: 3, kbdOctave = prefs[KBD_OCTAVE] ?: 3,
vuMeters = prefs[VU_METERS] ?: true, 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 } 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 { private companion object {
val PALETTE = stringPreferencesKey("palette") val PALETTE = stringPreferencesKey("palette")
val RECORD_DIR = stringPreferencesKey("recordDirUri") val RECORD_DIR = stringPreferencesKey("recordDirUri")
@@ -89,5 +103,7 @@ class SettingsStore(private val context: Context) {
val KBD_CHANNEL = intPreferencesKey("kbdChannel") val KBD_CHANNEL = intPreferencesKey("kbdChannel")
val KBD_OCTAVE = intPreferencesKey("kbdOctave") val KBD_OCTAVE = intPreferencesKey("kbdOctave")
val VU_METERS = booleanPreferencesKey("vuMeters") 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 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. */ /** Keep names safe as file names while staying human-readable. */
private fun sanitize(name: String): String = private fun sanitize(name: String): String =
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" } name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" }
private companion object { private companion object {
const val EXT = ".szg" // full project (ProjectIo) const val EXT = ".szg" // full project (ProjectIo)
const val BUNDLE_EXT = ".szgz" // full project + samples ZIP bundle (ProjectBundleIo)
const val EXPORT_DIR = ".export" const val EXPORT_DIR = ".export"
} }
} }

View File

@@ -15,10 +15,13 @@ import space.rcmd.android.sizzle.input.InputAction
import space.rcmd.android.sizzle.input.InputRouter import space.rcmd.android.sizzle.input.InputRouter
import space.rcmd.android.sizzle.model.Cell import space.rcmd.android.sizzle.model.Cell
import space.rcmd.android.sizzle.model.CellColumn 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.Pattern
import space.rcmd.android.sizzle.model.Pitch import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature 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.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,6 +41,14 @@ import kotlinx.coroutines.launch
* performance, so after we mutate the grid we bump [revision]. Compose reads that * 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. * 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( class AppViewModel(
val project: Project, val project: Project,
private val engine: AudioEngine, private val engine: AudioEngine,
@@ -50,6 +61,7 @@ class AppViewModel(
private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary, private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary,
private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore, private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore,
private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary, private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary,
private val midiClock: space.rcmd.android.sizzle.input.MidiClock,
private val appContext: android.content.Context, private val appContext: android.content.Context,
) : ViewModel() { ) : ViewModel() {
@@ -108,6 +120,26 @@ class AppViewModel(
touched() 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 updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) }
fun togglePunchIn() { punchInMode = !punchInMode } fun togglePunchIn() { punchInMode = !punchInMode }
fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); persistKeyboard(); touched() } fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); persistKeyboard(); touched() }
@@ -136,6 +168,13 @@ class AppViewModel(
var learnTarget by mutableStateOf<String?>(null); private set var learnTarget by mutableStateOf<String?>(null); private set
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null 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 { init {
// Collect neutral input actions from every non-touch device. // Collect neutral input actions from every non-touch device.
viewModelScope.launch { viewModelScope.launch {
@@ -186,15 +225,16 @@ class AppViewModel(
cursorColumn = CellColumn.entries[flat % cols] cursorColumn = CellColumn.entries[flat % cols]
} }
/** Punch a note into the focused cell (velocity/channel from the keyboard) and /** Punch a note into the focused cell and advance the cursor by [skipStep] rows,
* advance the cursor by [skipStep] rows, cycling past the pattern ends — the * cycling past the pattern ends. Velocity/channel default to the on-screen
* touch-keyboard entry path. */ * keyboard's (the touch-keyboard entry path); MIDI punch-in passes the incoming
fun punchNote(midi: Int) { * 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() val cell = focusedCell()
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST) cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY) cell.velocity = velocity.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = kbdChannel cell.channel = channel
lastNote = cell.note; lastChannel = kbdChannel lastNote = cell.note; lastChannel = channel
moveCursorVerticalWrap(skipStep) moveCursorVerticalWrap(skipStep)
touched() touched()
} }
@@ -357,13 +397,42 @@ class AppViewModel(
touched() 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 // ------------------------------------------------------- transport control
fun playPause() { fun playPause() {
val t = transport.value 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. */ /** MIDI panic: silence all sounding/stuck notes immediately. */
fun midiPanic() { heldNotes.clear(); engine.panic() } fun midiPanic() { heldNotes.clear(); engine.panic() }
@@ -478,13 +547,22 @@ class AppViewModel(
InputAction.NoteOffCell -> noteOffFocused() InputAction.NoteOffCell -> noteOffFocused()
is InputAction.NoteOn -> { is InputAction.NoteOn -> {
markNoteOn(action.pitch) markNoteOn(action.pitch)
engine.liveNoteOn(action.pitch, action.velocity) // MIDI notes route to the mixer track(s) listening on their channel;
// Live entry also writes the note into the focused NOTE cell. // a channel-less source (physical keyboard) plays the default synth.
if (cursorColumn == CellColumn.NOTE) { if (action.channel >= 1) engine.busNoteOn(action.channel, action.pitch, action.velocity)
focusedCell().note = action.pitch; lastNote = action.pitch; touched() 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.PlayPause -> playPause()
InputAction.Stop -> stop() InputAction.Stop -> stop()
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() } InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
@@ -512,11 +590,95 @@ class AppViewModel(
router.learnMode = false 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 /** 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 * the binding for [learnTarget]. Each action keeps at most one binding, so any
* previous one for this action (on the same device) is replaced. */ * previous one for this action (on the same device) is replaced. */
private fun onRawControl(action: InputAction.RawControl) { 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 if (action.source != learnSource) return
when (action.source) { when (action.source) {
space.rcmd.android.sizzle.input.InputSource.MIDI -> space.rcmd.android.sizzle.input.InputSource.MIDI ->
@@ -810,6 +972,24 @@ class AppViewModel(
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL) kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
kbdOctave = s.kbdOctave.coerceIn(0, 8) kbdOctave = s.kbdOctave.coerceIn(0, 8)
vuMeters = s.vuMeters 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.recTempDir = appContext.cacheDir
engine.setRecordTailBars(recordTailBars) engine.setRecordTailBars(recordTailBars)
engine.onRecordingStarted = { engine.onRecordingStarted = {
@@ -878,6 +1058,7 @@ class AppViewModel(
fun loadProjectText(text: String) { fun loadProjectText(text: String) {
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text) space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
cursorLine = 0; cursorTrack = 0 cursorLine = 0; cursorTrack = 0
clearArrangementSelection() // the old arrangement's selection is no longer meaningful
touched() touched()
} }
@@ -949,6 +1130,35 @@ class AppViewModel(
fun writeSngExport(): java.io.File = fun writeSngExport(): java.io.File =
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng()) 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 // ------------------------------------------------------------- full backup/restore
/** Result of the last backup/restore, shown in a dialog; null when dismissed. */ /** Result of the last backup/restore, shown in a dialog; null when dismissed. */
var backupMessage by mutableStateOf<String?>(null); private set var backupMessage by mutableStateOf<String?>(null); private set

View File

@@ -94,11 +94,11 @@ fun SettingsScreen(vm: AppViewModel) {
) { ) {
item { PanicButton(vm) } item { PanicButton(vm) }
item { ProjectCard(vm) } item { ProjectCard(vm) }
item { BackupCard(vm) }
item { HelpCard() } item { HelpCard() }
item { AudioDevicesCard(vm) } item { AudioDevicesCard(vm) }
item { InterfaceCard(vm) } item { InterfaceCard(vm) }
item { ThemeCard(vm) } item { ThemeCard(vm) }
item { MidiSyncCard(vm) }
item { item {
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) { NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
section = SettingsSection.GAMEPAD section = SettingsSection.GAMEPAD
@@ -114,6 +114,7 @@ fun SettingsScreen(vm: AppViewModel) {
section = SettingsSection.MIDI section = SettingsSection.MIDI
} }
} }
item { BackupCard(vm) }
} }
SettingsSection.GAMEPAD -> LazyColumn( SettingsSection.GAMEPAD -> LazyColumn(
@@ -236,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 // On-device full-project browser: the load dropdown sits ABOVE the action
// buttons (save / new / delete) so the selector is clear of them. // buttons (save / new / delete) so the selector is clear of them.
Box { Box {
@@ -271,10 +286,29 @@ private fun ProjectCard(vm: AppViewModel) {
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, 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() HorizontalDivider()
// Desktop interchange: .sng is only exported / imported, never the library. // Desktop interchange: .sng is only exported / imported, never the library.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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)) Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
Text("Export .sng") Text("Export .sng")
} }
@@ -348,15 +382,15 @@ private fun ProjectCard(vm: AppViewModel) {
} }
} }
/** Launch the system share sheet for an exported .sng file. */ /** Launch the system share sheet for an exported file (song, .sng, or bundle). */
private fun shareSongFile(context: Context, file: java.io.File) { private fun shareFile(context: Context, file: java.io.File, mime: String, title: String) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val send = Intent(Intent.ACTION_SEND).apply { val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = mime
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
context.startActivity(Intent.createChooser(send, "Share song")) context.startActivity(Intent.createChooser(send, title))
} }
@Composable @Composable
@@ -468,26 +502,53 @@ private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
} }
/** MIDI clock sync over USB + Bluetooth: drive external gear, or follow an external
* clock. Both off by default. */
@Composable
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(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. */ /** Interface options: currently the mixer VU-meter toggle. */
@Composable @Composable
private fun InterfaceCard(vm: AppViewModel) { private fun InterfaceCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
SettingsCard("Interface", subtitle = "On-screen display options") { SettingsCard("Interface", subtitle = "On-screen display options") {
Row( ToggleRow(
Modifier.fillMaxWidth(), "Mixer VU meters",
horizontalArrangement = Arrangement.SpaceBetween, "Show live level meters inside the mixer volume faders.",
verticalAlignment = Alignment.CenterVertically, vm.vuMeters,
) { ) { vm.updateVuMeters(it) }
Column(Modifier.weight(1f)) {
Text("Mixer VU meters", style = MaterialTheme.typography.bodyMedium)
Text(
"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) })
}
} }
} }

View File

@@ -39,6 +39,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -299,6 +300,8 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER
// Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree. // Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree.
var samplerPad by remember(slot.index) { mutableIntStateOf(0) } 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))) { Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -312,8 +315,13 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
) { ) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp) Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp, // Right-side toolbar: MIDI-learn (map controls to params) + close.
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } }) 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. // Preset browser: load / save / import / share for this device.
@@ -349,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) }
}
}
}
}
} }
} }

View File

@@ -100,14 +100,13 @@ fun ArrangementRoll(vm: AppViewModel) {
val beatWidthPx = with(density) { beatWidth.toPx() } val beatWidthPx = with(density) { beatWidth.toPx() }
val rulerHeightPx = with(density) { rulerHeight.toPx() } val rulerHeightPx = with(density) { rulerHeight.toPx() }
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none. // Rectangular cell selection lives on the ViewModel so the transport (Play/Stop)
// Unlike before, selecting does NOT change any cell; the toolbar toggle acts // can read and clear it — Play starts from the selection's first beat, Stop clears
// on the selection. Read in composition (for the toolbar's FILL/CLEAR label) // it. Selecting still does NOT change any cell; the toolbar toggle acts on it.
// and captured by the Canvas draw below. val selLane0 = vm.arrSelLane0
var selLane0 by remember { mutableIntStateOf(-1) } val selBeat0 = vm.arrSelBeat0
var selBeat0 by remember { mutableIntStateOf(-1) } val selLane1 = vm.arrSelLane1
var selLane1 by remember { mutableIntStateOf(-1) } val selBeat1 = vm.arrSelBeat1
var selBeat1 by remember { mutableIntStateOf(-1) }
val hasSelection = selBeat0 >= 0 val hasSelection = selBeat0 >= 0
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY 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() val lane = ((off.y - rulerHeightPx) / laneH).toInt()
.coerceIn(0, ArrModel.LANE_COUNT - 1) .coerceIn(0, ArrModel.LANE_COUNT - 1)
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1) val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
selLane0 = lane; selLane1 = lane vm.setArrangementSelection(lane, beat, lane, beat)
selBeat0 = beat; selBeat1 = beat
} }
} }
// Long-press then drag sweeps out a rectangular selection. // Long-press then drag sweeps out a rectangular selection.
@@ -240,14 +238,16 @@ fun ArrangementRoll(vm: AppViewModel) {
} }
fun beatAt(off: Offset): Int = fun beatAt(off: Offset): Int =
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1) (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
var dragLane0 = 0; var dragBeat0 = 0
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(
onDragStart = { off -> onDragStart = { off ->
selLane0 = laneAt(off); selLane1 = selLane0 dragLane0 = laneAt(off); dragBeat0 = beatAt(off)
selBeat0 = beatAt(off); selBeat1 = selBeat0 vm.setArrangementSelection(dragLane0, dragBeat0, dragLane0, dragBeat0)
}, },
onDrag = { change, _ -> onDrag = { change, _ ->
selLane1 = laneAt(change.position) vm.setArrangementSelection(
selBeat1 = beatAt(change.position) dragLane0, dragBeat0, laneAt(change.position), beatAt(change.position),
)
}, },
) )
}, },

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