Compare commits
3 Commits
b305f0a2b5
...
96bbc5a4cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96bbc5a4cc | ||
|
|
682418d69e | ||
|
|
3c4c30bf1f |
@@ -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 = 44
|
||||||
versionName = "0.15.1"
|
versionName = "0.18.0"
|
||||||
|
|
||||||
// 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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -80,6 +80,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
midi.start()
|
midi.start()
|
||||||
|
app.midiClock.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -95,6 +96,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
// system-kill (or crash) resumes from here on next launch.
|
// system-kill (or crash) resumes from here on next launch.
|
||||||
app.projectStore.save(app.project)
|
app.projectStore.save(app.project)
|
||||||
midi.stop()
|
midi.stop()
|
||||||
|
app.midiClock.stop()
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -520,6 +533,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 +884,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 +898,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 +1405,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() {
|
||||||
|
|||||||
169
app/src/main/java/space/rcmd/android/sizzle/input/MidiClock.kt
Normal file
169
app/src/main/java/space/rcmd/android/sizzle/input/MidiClock.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +70,9 @@ 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
|
||||||
when (type) {
|
when (type) {
|
||||||
0x90 -> { // Note On
|
0x90 -> { // Note On
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,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 +109,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() }
|
||||||
@@ -810,6 +831,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 = {
|
||||||
@@ -949,6 +988,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
|
||||||
|
|||||||
@@ -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) })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user