Compare commits
9 Commits
8c44ed5151
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924b84a4d5 | ||
|
|
7296a3a72b | ||
|
|
dfb6ee9c9b | ||
|
|
9fdef7fee7 | ||
|
|
96bbc5a4cc | ||
|
|
682418d69e | ||
|
|
3c4c30bf1f | ||
|
|
b305f0a2b5 | ||
|
|
79a1844512 |
@@ -22,8 +22,8 @@ android {
|
||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 38
|
||||
versionName = "0.14.0"
|
||||
versionCode = 48
|
||||
versionName = "0.21.1"
|
||||
|
||||
// We provide our own instrumentation runner if/when tests are added.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -52,7 +52,7 @@ class MainActivity : ComponentActivity() {
|
||||
app.project, app.audioEngine, app.inputRouter,
|
||||
app.gamepadInput, app.midiInput, app.keyboardInput,
|
||||
app.bindingStore, app.presetLibrary, app.songLibrary,
|
||||
app.settingsStore, app.themeLibrary, app.applicationContext,
|
||||
app.settingsStore, app.themeLibrary, app.midiClock, app.applicationContext,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -75,11 +75,15 @@ class MainActivity : ComponentActivity() {
|
||||
SizzleAppUi(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Start MIDI listening (USB + BLE) and the MIDI clock here — bound to the
|
||||
// Activity's WHOLE lifetime (onCreate..onDestroy), not onStart/onStop — so a
|
||||
// connected controller keeps driving the app while the screen is off. The
|
||||
// audio engine already runs a continuous output stream via the started
|
||||
// PlaybackService, which keeps the process alive to receive those events in
|
||||
// the background; closing the ports on onStop was the only reason input died.
|
||||
midi.start()
|
||||
app.midiClock.start()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -92,12 +96,21 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
// Autosave the project whenever we leave the foreground, so a later
|
||||
// system-kill (or crash) resumes from here on next launch.
|
||||
// system-kill (or crash) resumes from here on next launch. MIDI is
|
||||
// deliberately NOT stopped here (see onCreate / onDestroy) so controllers
|
||||
// keep working with the screen off or the app backgrounded.
|
||||
app.projectStore.save(app.project)
|
||||
midi.stop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// Release MIDI only when the Activity is actually torn down (task removed or
|
||||
// finishing), not merely backgrounded / screen-off.
|
||||
midi.stop()
|
||||
app.midiClock.stop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ---- Hardware input forwarding ----
|
||||
// Gamepad events are intercepted at DISPATCH time — before Compose's focus
|
||||
// system can consume D-pad / button keys — so USB *and* Bluetooth pads work
|
||||
|
||||
@@ -45,6 +45,11 @@ class SizzleApp : Application() {
|
||||
val gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
|
||||
val midiInput: MidiInput by lazy { MidiInput(this, inputRouter) }
|
||||
|
||||
/** MIDI clock sync (send/receive) over USB + Bluetooth MIDI. */
|
||||
val midiClock: space.rcmd.android.sizzle.input.MidiClock by lazy {
|
||||
space.rcmd.android.sizzle.input.MidiClock(this)
|
||||
}
|
||||
|
||||
/** Persists the input binding maps across restarts (DataStore-backed). */
|
||||
val bindingStore: BindingStore by lazy { BindingStore(this) }
|
||||
|
||||
@@ -80,6 +85,8 @@ class SizzleApp : Application() {
|
||||
// single small read; blocking briefly at cold start keeps the input maps
|
||||
// authoritative from the first keypress rather than racing an async load.
|
||||
runBlocking { bindingStore.loadInto(keyboardInput, gamepadInput, midiInput) }
|
||||
// Feed incoming MIDI real-time (clock / start / stop) to the clock sync module.
|
||||
midiInput.onRealtime = midiClock::handleRealtime
|
||||
installCrashAutosave()
|
||||
}
|
||||
|
||||
|
||||
@@ -78,11 +78,29 @@ class AudioEngine(
|
||||
private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
|
||||
// Per-(lane,track) arpeggiator state, driven by advanceArps.
|
||||
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
|
||||
// Bumped once per triggered line. Notes reaching an arpeggiator with the same
|
||||
// stamp are the same line's chord (accumulated into one arp); a new stamp starts
|
||||
// a fresh chord. Lets a held chord arpeggiate all its notes instead of collapsing
|
||||
// to the last one entered. Audio-thread only.
|
||||
private var lineTriggerGen = 0
|
||||
// Per-(lane,track) MIDI channel the track last played a note on. A note-off cell
|
||||
// is tied to its track (it has no channel of its own) and releases whatever the
|
||||
// track last started, on the bus(es) that channel routed to. -1 = nothing yet.
|
||||
private val trackLastChannel = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }
|
||||
|
||||
// ---- outgoing MIDI (send the sequenced notes to external gear) ----
|
||||
// The sequencer runs on the audio thread, which must not do MIDI I/O; note events
|
||||
// are packed into a lock-free single-producer/single-consumer ring that the MIDI
|
||||
// clock thread drains via [pollMidiOut]. Only filled when [midiOutEnabled]. Each
|
||||
// slot packs (status<<16)|(data1<<8)|data2. Note-on/off mirror the tracks' cells
|
||||
// (mono per track, matching the internal note lifecycle) on the cell's MIDI channel.
|
||||
@Volatile var midiOutEnabled = false
|
||||
private val moutRing = IntArray(256)
|
||||
@Volatile private var moutHead = 0 // producer (audio thread)
|
||||
@Volatile private var moutTail = 0 // consumer (MIDI clock thread)
|
||||
private val moutNote = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }
|
||||
private val moutCh = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT)
|
||||
|
||||
// Cached subtractive-synth patch per bus, reparsed only when the slot changes
|
||||
// (version) or tempo changes — so note triggers don't allocate a patch each time,
|
||||
// and active voices can be refreshed per block for live parameter feedback.
|
||||
@@ -488,9 +506,34 @@ class AudioEngine(
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- transport
|
||||
fun play() {
|
||||
/**
|
||||
* Start playback. When [startBeat] is >= 0, seek to that beat in the arrangement
|
||||
* (or that line in pattern-loop mode) so Play resumes from a marker the user
|
||||
* placed on the arrangement, instead of always from the top. Ignored if it doesn't
|
||||
* land inside the current playlist / pattern.
|
||||
*/
|
||||
fun play(startBeat: Int = -1) {
|
||||
rebuildArrangement()
|
||||
rebuildFxChains()
|
||||
if (startBeat >= 0) {
|
||||
if (arrangementMode) {
|
||||
val list = playlist
|
||||
// Find the first occurrence of that beat in the (possibly A/B-expanded)
|
||||
// playlist; if it isn't in there just start from the top.
|
||||
var seek = -1
|
||||
for (i in list.indices) if (list[i] == startBeat) { seek = i; break }
|
||||
if (seek >= 0) {
|
||||
playlistIndex = seek; lineInBeat = 0; arrPlayLine = 0; samplesIntoLine = 0.0
|
||||
}
|
||||
} else {
|
||||
val proj = project
|
||||
if (proj != null) {
|
||||
val pat = proj.activePattern()
|
||||
val line = (startBeat * proj.timeSignature.linesPerBeat).coerceIn(0, pat.length - 1)
|
||||
currentLine = line; samplesIntoLine = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingTrigger = true
|
||||
playing = true
|
||||
maybeStartRecording()
|
||||
@@ -515,6 +558,7 @@ class AudioEngine(
|
||||
busSampleVoices.forEach { it.kill() }
|
||||
arps.forEach { it.stop() }
|
||||
trackLastChannel.fill(-1)
|
||||
allMidiNotesOff() // release any notes sent to external gear
|
||||
}
|
||||
|
||||
/** Stop playback. If already stopped, rewind to the very beginning. */
|
||||
@@ -833,6 +877,7 @@ class AudioEngine(
|
||||
}
|
||||
|
||||
private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
|
||||
lineTriggerGen++ // new line — notes below form one chord per arpeggiator
|
||||
// Pattern-loop plays on lane 0's voices; each cell is routed by its channel.
|
||||
// Apply note-offs FIRST, then note-ons, so a note-off on one track never
|
||||
// cancels a note that begins on the same line/bus (a note-off releases the
|
||||
@@ -864,6 +909,8 @@ class AudioEngine(
|
||||
private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
|
||||
val trackKey = lane * Pattern.TRACK_COUNT + track
|
||||
if (cell.isNoteOff) {
|
||||
// Release this track's outgoing MIDI note first (external gear).
|
||||
if (moutNote[trackKey] >= 0) { emitNoteOff(moutCh[trackKey], moutNote[trackKey]); moutNote[trackKey] = -1 }
|
||||
val ch = trackLastChannel[trackKey]
|
||||
if (ch < 0) return // this track hasn't played anything yet
|
||||
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||
@@ -876,6 +923,13 @@ class AudioEngine(
|
||||
if (!cell.isPlayable) return
|
||||
// Remember the channel so a later note-off on this track releases it.
|
||||
trackLastChannel[trackKey] = cell.channel
|
||||
// Send this track's note out to external gear (mono per track): off the note it
|
||||
// was holding, then on the new one, on the cell's MIDI channel. Mirrors the
|
||||
// tracks' cells — a channel's Arpeggiator/Transposer are internal FX and aren't
|
||||
// reflected here.
|
||||
if (moutNote[trackKey] >= 0) emitNoteOff(moutCh[trackKey], moutNote[trackKey])
|
||||
emitNoteOn(cell.channel, cell.note, cell.velocity)
|
||||
moutNote[trackKey] = cell.note; moutCh[trackKey] = cell.channel
|
||||
// Mono-per-track: a new note releases whatever this track was still holding,
|
||||
// so a held note is stopped when the next one appears under the playhead — no
|
||||
// explicit note-off cell needed. Only this track's own voices are released, so
|
||||
@@ -893,21 +947,28 @@ class AudioEngine(
|
||||
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||
val mode = arpSlot.string("mode", "Up")
|
||||
val resetBar = arpSlot.string("resetbar", "Off") == "On"
|
||||
if (arp.active && arp.base == note) {
|
||||
// The same note is already arpeggiating — this trigger is the
|
||||
// pattern looping back over a still-held note. Keep the running
|
||||
// phase (just refresh params) so the pulse stays steady across
|
||||
// the loop point instead of slipping when the loop length isn't
|
||||
// an exact multiple of the arp step (dotted/triplet divisions).
|
||||
arp.refresh(octaves, mode, stepSamples, resetBar)
|
||||
} else {
|
||||
arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||
// The arpeggiator is monophonic (it cycles octaves of ONE note),
|
||||
// so it must not stack notes. Releasing the bus first means a
|
||||
// chord entered on the same line collapses to a single
|
||||
// arpeggiated note (the last one), not all of them at once.
|
||||
releaseBus(bus)
|
||||
playNote(proj, bus, arp.currentNote(), velocity)
|
||||
when {
|
||||
arp.active && arp.genStamp == lineTriggerGen -> {
|
||||
// Another note of the SAME line's chord — accumulate it so the
|
||||
// arp cycles the whole chord (not just the last note entered).
|
||||
arp.addNote(note)
|
||||
arp.refresh(octaves, mode, stepSamples, resetBar)
|
||||
}
|
||||
arp.active -> {
|
||||
// A new line while still arpeggiating (the pattern looping back
|
||||
// over a held chord): rebuild the chord from this line but keep
|
||||
// the running phase, so a looping chord doesn't hard-restart or
|
||||
// slip when the loop length isn't a multiple of the arp step.
|
||||
arp.resetChord(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||
arp.genStamp = lineTriggerGen
|
||||
}
|
||||
else -> {
|
||||
// Fresh arp: start on this note and sound it immediately.
|
||||
arp.startChord(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||
arp.genStamp = lineTriggerGen
|
||||
releaseBus(bus)
|
||||
playNote(proj, bus, arp.currentNote(), velocity)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
arp.stop()
|
||||
@@ -1050,17 +1111,22 @@ class AudioEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */
|
||||
private class ChannelArp {
|
||||
var active = false; var base = -1; var velocity = 100
|
||||
/** Per-(lane,track) arpeggiator: cycles through the held chord's notes across the
|
||||
* octave range. Notes are kept sorted ascending; the linear step sequence is
|
||||
* octave-major (all chord notes in octave 0, then octave 1, …), walked up / down /
|
||||
* up-down / randomly by [advance]. [genStamp] identifies the line whose chord this
|
||||
* holds (see [lineTriggerGen]). Internal (not private) only so a unit test can
|
||||
* exercise its pure note-sequencing. */
|
||||
internal class ChannelArp {
|
||||
private val notes = IntArray(MAX_CHORD)
|
||||
var noteCount = 0; private set
|
||||
var velocity = 100
|
||||
private var octaves = 1; private var mode = 0
|
||||
var stepSamples = 0.0; var samplesLeft = 0.0
|
||||
private var step = 0; private var dir = 1
|
||||
/** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */
|
||||
var resetOnBar = false
|
||||
// Set by [resetToBar] so the next fire plays step 0 without first advancing.
|
||||
var genStamp = -1
|
||||
private var pendingReset = false
|
||||
// Per-arp lock-free RNG (Random mode) — no Math.random() on the audio thread.
|
||||
private var rng = 0x2545F491.toInt()
|
||||
private fun rnd(bound: Int): Int {
|
||||
var s = rng
|
||||
@@ -1069,16 +1135,37 @@ class AudioEngine(
|
||||
return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt()
|
||||
}
|
||||
|
||||
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
||||
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
|
||||
/** Active while it holds at least one note. */
|
||||
val active: Boolean get() = noteCount > 0
|
||||
|
||||
/** Start a fresh chord on [note], resetting the phase to step 0. */
|
||||
fun startChord(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
||||
noteCount = 0; addNote(note); velocity = vel; octaves = octs.coerceIn(1, 4)
|
||||
mode = modeOf(modeStr)
|
||||
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
|
||||
resetOnBar = resetBar; pendingReset = false
|
||||
}
|
||||
|
||||
/** Update params WITHOUT resetting the running phase — used when the pattern
|
||||
* loops over a still-arpeggiating note so the steady pulse continues
|
||||
* seamlessly across the loop boundary. */
|
||||
/** Replace the chord with [note] (the next same-line notes re-accumulate) while
|
||||
* KEEPING the running phase — used when the pattern loops back over a held
|
||||
* chord so the pulse stays steady instead of restarting. */
|
||||
fun resetChord(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
||||
noteCount = 0; addNote(note); velocity = vel
|
||||
refresh(octs, modeStr, stepSamp, resetBar)
|
||||
}
|
||||
|
||||
/** Add a note to the held chord (sorted ascending, de-duplicated). */
|
||||
fun addNote(note: Int) {
|
||||
val n = note.coerceIn(0, 127)
|
||||
var i = 0
|
||||
while (i < noteCount && notes[i] < n) i++
|
||||
if (i < noteCount && notes[i] == n) return // already held
|
||||
if (noteCount >= MAX_CHORD) return
|
||||
for (j in noteCount downTo i + 1) notes[j] = notes[j - 1]
|
||||
notes[i] = n; noteCount++
|
||||
}
|
||||
|
||||
/** Update params WITHOUT resetting the running phase. */
|
||||
fun refresh(octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
||||
octaves = octs.coerceIn(1, 4)
|
||||
mode = modeOf(modeStr)
|
||||
@@ -1086,31 +1173,38 @@ class AudioEngine(
|
||||
resetOnBar = resetBar
|
||||
}
|
||||
|
||||
/** Realign the pattern to step 0 at a bar downbeat: the next [advanceArps]
|
||||
* fire (scheduled immediately) plays step 0 without advancing first. */
|
||||
fun resetToBar() { step = 0; dir = 1; samplesLeft = 0.0; pendingReset = true }
|
||||
|
||||
/** True (once) if a bar reset is pending, so the caller skips [advance]. */
|
||||
fun consumePendingReset(): Boolean { val p = pendingReset; pendingReset = false; return p }
|
||||
|
||||
private fun modeOf(modeStr: String): Int =
|
||||
when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
|
||||
|
||||
fun stop() { active = false; base = -1 }
|
||||
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
|
||||
fun stop() { noteCount = 0; genStamp = -1 }
|
||||
|
||||
private fun total(): Int = (noteCount * octaves).coerceAtLeast(1)
|
||||
|
||||
fun currentNote(): Int {
|
||||
if (noteCount <= 0) return 0
|
||||
val idx = step.coerceIn(0, total() - 1)
|
||||
val oct = idx / noteCount
|
||||
return (notes[idx % noteCount] + 12 * oct).coerceIn(0, 127)
|
||||
}
|
||||
|
||||
fun advance() {
|
||||
val total = total()
|
||||
when (mode) {
|
||||
1 -> step = (step - 1 + octaves) % octaves
|
||||
2 -> if (octaves > 1) {
|
||||
1 -> step = (step - 1 + total) % total
|
||||
2 -> if (total > 1) {
|
||||
step += dir
|
||||
if (step >= octaves - 1) { step = octaves - 1; dir = -1 }
|
||||
if (step >= total - 1) { step = total - 1; dir = -1 }
|
||||
else if (step <= 0) { step = 0; dir = 1 }
|
||||
}
|
||||
3 -> step = if (octaves > 1) rnd(octaves) else 0
|
||||
else -> step = (step + 1) % octaves
|
||||
3 -> step = if (total > 1) rnd(total) else 0
|
||||
else -> step = (step + 1) % total
|
||||
}
|
||||
}
|
||||
|
||||
private companion object { const val MAX_CHORD = 8 }
|
||||
}
|
||||
|
||||
/** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
|
||||
@@ -1259,6 +1353,7 @@ class AudioEngine(
|
||||
private fun triggerArrangementLine(
|
||||
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
|
||||
) {
|
||||
lineTriggerGen++ // new line — notes below form one chord per arpeggiator
|
||||
// Two passes across all lanes: note-offs first, then note-ons, so a note-off
|
||||
// never cancels a note that begins on the same line/bus (a note-off releases
|
||||
// the whole bus). See [triggerPatternLine].
|
||||
@@ -1335,6 +1430,45 @@ class AudioEngine(
|
||||
seqVoices.forEach { it.noteOff() }
|
||||
sampleVoices.forEach { it.noteOff() }
|
||||
arps.forEach { it.stop() }
|
||||
allMidiNotesOff() // release any notes we sent to external gear
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- outgoing MIDI
|
||||
/** Enqueue one MIDI message for the send thread (audio-thread producer, no alloc). */
|
||||
private fun emitMidi(status: Int, d1: Int, d2: Int) {
|
||||
if (!midiOutEnabled) return
|
||||
val h = moutHead
|
||||
val next = (h + 1) and (moutRing.size - 1)
|
||||
if (next == moutTail) return // ring full — drop rather than block the audio thread
|
||||
moutRing[h] = (status shl 16) or ((d1 and 0x7F) shl 8) or (d2 and 0x7F)
|
||||
moutHead = next
|
||||
}
|
||||
|
||||
/** Send a note-on for [note] on app channel [ch] (1..16 → MIDI wire 0..15). */
|
||||
private fun emitNoteOn(ch: Int, note: Int, vel: Int) {
|
||||
if (ch < 1) return
|
||||
emitMidi(0x90 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), vel.coerceIn(1, 127))
|
||||
}
|
||||
|
||||
private fun emitNoteOff(ch: Int, note: Int) {
|
||||
if (ch < 1) return
|
||||
emitMidi(0x80 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), 0)
|
||||
}
|
||||
|
||||
/** Release every note we've sent out (on transport stop / pause / panic) so an
|
||||
* external instrument never hangs after Sizzle stops. */
|
||||
private fun allMidiNotesOff() {
|
||||
for (k in moutNote.indices) if (moutNote[k] >= 0) { emitNoteOff(moutCh[k], moutNote[k]); moutNote[k] = -1 }
|
||||
}
|
||||
|
||||
/** Drain one queued outgoing MIDI message, or -1 if the queue is empty. Called by
|
||||
* the MIDI clock thread (single consumer). */
|
||||
fun pollMidiOut(): Int {
|
||||
val t = moutTail
|
||||
if (t == moutHead) return -1
|
||||
val v = moutRing[t]
|
||||
moutTail = (t + 1) and (moutRing.size - 1)
|
||||
return v
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
|
||||
@@ -33,8 +33,12 @@ sealed interface InputAction {
|
||||
data object NoteOffCell : InputAction
|
||||
|
||||
// ----- Live note entry (also used to audition instruments) -----
|
||||
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction
|
||||
data class NoteOff(val pitch: Int) : InputAction
|
||||
// [channel] is the 1-based MIDI channel (1..16) for MIDI input, so notes can be
|
||||
// routed to the mixer track(s) listening on it; it is -1 when the source has no
|
||||
// channel of its own (physical / on-screen keyboard), which falls back to the
|
||||
// on-screen keyboard's channel.
|
||||
data class NoteOn(val pitch: Int, val velocity: Int, val channel: Int = -1) : InputAction
|
||||
data class NoteOff(val pitch: Int, val channel: Int = -1) : InputAction
|
||||
|
||||
// ----- Transport -----
|
||||
data object PlayPause : InputAction
|
||||
|
||||
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). */
|
||||
var ccBindings: MutableMap<Int, String> = mutableMapOf(),
|
||||
) {
|
||||
/** Sink for MIDI System Real-Time status bytes (0xF8..0xFF), notably clock (0xF8)
|
||||
* and Start/Continue/Stop (0xFA/0xFB/0xFC) — wired to [MidiClock] for clock sync. */
|
||||
var onRealtime: ((Int) -> Unit)? = null
|
||||
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val openPorts = mutableListOf<MidiOutputPort>()
|
||||
@@ -67,17 +70,23 @@ class MidiInput(
|
||||
while (i < end) {
|
||||
val status = data[i].toInt() and 0xFF
|
||||
if (status < 0x80) { i++; continue } // skip stray data bytes
|
||||
// System Real-Time (0xF8..0xFF) are single-byte and may be interleaved
|
||||
// anywhere; forward them (clock / start / stop) and move on one byte.
|
||||
if (status >= 0xF8) { onRealtime?.invoke(status); i++; continue }
|
||||
val type = status and 0xF0
|
||||
// Low nibble is the MIDI channel (0..15); expose it 1-based (1..16) so
|
||||
// it matches the mixer channels' `midiChannel` for routing.
|
||||
val channel = (status and 0x0F) + 1
|
||||
when (type) {
|
||||
0x90 -> { // Note On
|
||||
val note = data.getOrZero(i + 1)
|
||||
val vel = data.getOrZero(i + 2)
|
||||
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
|
||||
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
|
||||
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel, channel))
|
||||
else router.dispatch(InputAction.NoteOff(note, channel)) // vel 0 == note off
|
||||
i += 3
|
||||
}
|
||||
0x80 -> { // Note Off
|
||||
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
|
||||
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1), channel)); i += 3
|
||||
}
|
||||
0xB0 -> { // Control Change
|
||||
val cc = data.getOrZero(i + 1)
|
||||
|
||||
@@ -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,
|
||||
/** Show the live VU meters inside the mixer volume faders. */
|
||||
val vuMeters: Boolean = true,
|
||||
/** Send MIDI clock + start/stop to attached MIDI devices (drive external gear). */
|
||||
val sendMidiClock: Boolean = false,
|
||||
/** Follow an external MIDI clock's tempo + start/stop. */
|
||||
val receiveMidiClock: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -51,6 +55,8 @@ class SettingsStore(private val context: Context) {
|
||||
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
|
||||
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
|
||||
vuMeters = prefs[VU_METERS] ?: true,
|
||||
sendMidiClock = prefs[MIDI_CLOCK_SEND] ?: false,
|
||||
receiveMidiClock = prefs[MIDI_CLOCK_RECV] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +88,14 @@ class SettingsStore(private val context: Context) {
|
||||
context.settingsDataStore.edit { prefs -> prefs[VU_METERS] = enabled }
|
||||
}
|
||||
|
||||
/** Persist the MIDI-clock send / receive toggles. */
|
||||
suspend fun saveMidiClock(send: Boolean, receive: Boolean) {
|
||||
context.settingsDataStore.edit { prefs ->
|
||||
prefs[MIDI_CLOCK_SEND] = send
|
||||
prefs[MIDI_CLOCK_RECV] = receive
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PALETTE = stringPreferencesKey("palette")
|
||||
val RECORD_DIR = stringPreferencesKey("recordDirUri")
|
||||
@@ -89,5 +103,7 @@ class SettingsStore(private val context: Context) {
|
||||
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
|
||||
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
|
||||
val VU_METERS = booleanPreferencesKey("vuMeters")
|
||||
val MIDI_CLOCK_SEND = booleanPreferencesKey("midiClockSend")
|
||||
val MIDI_CLOCK_RECV = booleanPreferencesKey("midiClockRecv")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,20 @@ class SongLibrary(private val baseDir: File) {
|
||||
return f
|
||||
}
|
||||
|
||||
/** A shareable file path in the export sub-dir for a full project bundle ([BUNDLE_EXT]);
|
||||
* the caller writes the ZIP into it via [ProjectBundleIo]. */
|
||||
fun bundleExportFile(name: String): File {
|
||||
val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() }
|
||||
return File(dir, sanitize(name) + BUNDLE_EXT)
|
||||
}
|
||||
|
||||
/** Keep names safe as file names while staying human-readable. */
|
||||
private fun sanitize(name: String): String =
|
||||
name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" }
|
||||
|
||||
private companion object {
|
||||
const val EXT = ".szg" // full project (ProjectIo)
|
||||
const val BUNDLE_EXT = ".szgz" // full project + samples ZIP bundle (ProjectBundleIo)
|
||||
const val EXPORT_DIR = ".export"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,13 @@ import space.rcmd.android.sizzle.input.InputAction
|
||||
import space.rcmd.android.sizzle.input.InputRouter
|
||||
import space.rcmd.android.sizzle.model.Cell
|
||||
import space.rcmd.android.sizzle.model.CellColumn
|
||||
import space.rcmd.android.sizzle.model.ParamSpec
|
||||
import space.rcmd.android.sizzle.model.Pattern
|
||||
import space.rcmd.android.sizzle.model.Pitch
|
||||
import space.rcmd.android.sizzle.model.Project
|
||||
import space.rcmd.android.sizzle.model.TimeSignature
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -38,6 +41,14 @@ import kotlinx.coroutines.launch
|
||||
* performance, so after we mutate the grid we bump [revision]. Compose reads that
|
||||
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
|
||||
*/
|
||||
/** Tab index of the Tracker tab (see [App]'s tab order). */
|
||||
private const val TAB_TRACKER = 0
|
||||
|
||||
/** Reserved prefix for a toolbox slot's MIDI-CC parameter bindings, stored in the
|
||||
* slot's value map (`midicc@<paramKey>` -> CC number) so they persist with the
|
||||
* project / preset like any other parameter and never collide with a real key. */
|
||||
private const val CC_PREFIX = "midicc@"
|
||||
|
||||
class AppViewModel(
|
||||
val project: Project,
|
||||
private val engine: AudioEngine,
|
||||
@@ -50,6 +61,7 @@ class AppViewModel(
|
||||
private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary,
|
||||
private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore,
|
||||
private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary,
|
||||
private val midiClock: space.rcmd.android.sizzle.input.MidiClock,
|
||||
private val appContext: android.content.Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -108,6 +120,26 @@ class AppViewModel(
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Send MIDI clock + start/stop to attached devices; follow an external clock's
|
||||
* tempo + transport. Both off by default; toggled from Settings and persisted. */
|
||||
var sendMidiClock by mutableStateOf(false); private set
|
||||
var receiveMidiClock by mutableStateOf(false); private set
|
||||
|
||||
fun updateSendMidiClock(enabled: Boolean) {
|
||||
sendMidiClock = enabled; midiClock.sendEnabled = enabled
|
||||
engine.midiOutEnabled = enabled // also forward the tracks' notes out
|
||||
persistMidiClock(); touched()
|
||||
}
|
||||
|
||||
fun updateReceiveMidiClock(enabled: Boolean) {
|
||||
receiveMidiClock = enabled; midiClock.receiveEnabled = enabled
|
||||
persistMidiClock(); touched()
|
||||
}
|
||||
|
||||
private fun persistMidiClock() {
|
||||
viewModelScope.launch { settingsStore.saveMidiClock(sendMidiClock, receiveMidiClock) }
|
||||
}
|
||||
|
||||
fun updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) }
|
||||
fun togglePunchIn() { punchInMode = !punchInMode }
|
||||
fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); persistKeyboard(); touched() }
|
||||
@@ -136,6 +168,13 @@ class AppViewModel(
|
||||
var learnTarget by mutableStateOf<String?>(null); private set
|
||||
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null
|
||||
|
||||
// ----- Toolbox parameter MIDI-learn state (device editors) -----
|
||||
/** The toolbox slot + parameter key currently waiting to capture a MIDI control,
|
||||
* or (-1 / null) when nothing is arming. The device editor's MIDI-learn popup
|
||||
* reads these to highlight the armed row. */
|
||||
var paramLearnSlot by mutableIntStateOf(-1); private set
|
||||
var paramLearnKey by mutableStateOf<String?>(null); private set
|
||||
|
||||
init {
|
||||
// Collect neutral input actions from every non-touch device.
|
||||
viewModelScope.launch {
|
||||
@@ -186,15 +225,16 @@ class AppViewModel(
|
||||
cursorColumn = CellColumn.entries[flat % cols]
|
||||
}
|
||||
|
||||
/** Punch a note into the focused cell (velocity/channel from the keyboard) and
|
||||
* advance the cursor by [skipStep] rows, cycling past the pattern ends — the
|
||||
* touch-keyboard entry path. */
|
||||
fun punchNote(midi: Int) {
|
||||
/** Punch a note into the focused cell and advance the cursor by [skipStep] rows,
|
||||
* cycling past the pattern ends. Velocity/channel default to the on-screen
|
||||
* keyboard's (the touch-keyboard entry path); MIDI punch-in passes the incoming
|
||||
* note's own velocity and channel so the recorded cell plays back correctly. */
|
||||
fun punchNote(midi: Int, velocity: Int = kbdVelocity, channel: Int = kbdChannel) {
|
||||
val cell = focusedCell()
|
||||
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY)
|
||||
cell.channel = kbdChannel
|
||||
lastNote = cell.note; lastChannel = kbdChannel
|
||||
cell.velocity = velocity.coerceIn(0, Cell.MAX_VELOCITY)
|
||||
cell.channel = channel
|
||||
lastNote = cell.note; lastChannel = channel
|
||||
moveCursorVerticalWrap(skipStep)
|
||||
touched()
|
||||
}
|
||||
@@ -357,13 +397,42 @@ class AppViewModel(
|
||||
touched()
|
||||
}
|
||||
|
||||
// ---- Arrangement selection (hoisted from ArrangementRoll so Play/Stop can read it).
|
||||
// Rectangular selection anchor (…0) and live corner (…1); beat < 0 = no selection.
|
||||
// Play starts from [arrSelBeat0]'s beat if set; Stop clears the selection so the
|
||||
// highlight disappears and the next Play resumes from the top.
|
||||
var arrSelLane0 by mutableIntStateOf(-1); private set
|
||||
var arrSelBeat0 by mutableIntStateOf(-1); private set
|
||||
var arrSelLane1 by mutableIntStateOf(-1); private set
|
||||
var arrSelBeat1 by mutableIntStateOf(-1); private set
|
||||
|
||||
/** Set/update the arrangement selection rectangle. Pass all corners at once so the
|
||||
* ArrangementRoll's tap / drag gestures update it atomically. */
|
||||
fun setArrangementSelection(lane0: Int, beat0: Int, lane1: Int, beat1: Int) {
|
||||
arrSelLane0 = lane0; arrSelBeat0 = beat0; arrSelLane1 = lane1; arrSelBeat1 = beat1
|
||||
}
|
||||
|
||||
fun clearArrangementSelection() {
|
||||
arrSelLane0 = -1; arrSelBeat0 = -1; arrSelLane1 = -1; arrSelBeat1 = -1
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- transport control
|
||||
fun playPause() {
|
||||
val t = transport.value
|
||||
if (t.isPlaying) engine.pause() else engine.play()
|
||||
if (t.isPlaying) {
|
||||
engine.pause()
|
||||
} else {
|
||||
// Start from the earliest beat of the arrangement selection if there is
|
||||
// one; otherwise from wherever the engine last was (top after a Stop).
|
||||
val start = if (arrSelBeat0 >= 0) minOf(arrSelBeat0, arrSelBeat1) else -1
|
||||
engine.play(start)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() = engine.stop()
|
||||
fun stop() {
|
||||
clearArrangementSelection() // resets the highlight so the next Play starts from the top
|
||||
engine.stop()
|
||||
}
|
||||
|
||||
/** MIDI panic: silence all sounding/stuck notes immediately. */
|
||||
fun midiPanic() { heldNotes.clear(); engine.panic() }
|
||||
@@ -478,13 +547,22 @@ class AppViewModel(
|
||||
InputAction.NoteOffCell -> noteOffFocused()
|
||||
is InputAction.NoteOn -> {
|
||||
markNoteOn(action.pitch)
|
||||
engine.liveNoteOn(action.pitch, action.velocity)
|
||||
// Live entry also writes the note into the focused NOTE cell.
|
||||
if (cursorColumn == CellColumn.NOTE) {
|
||||
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
|
||||
// MIDI notes route to the mixer track(s) listening on their channel;
|
||||
// a channel-less source (physical keyboard) plays the default synth.
|
||||
if (action.channel >= 1) engine.busNoteOn(action.channel, action.pitch, action.velocity)
|
||||
else engine.liveNoteOn(action.pitch, action.velocity)
|
||||
// On the Tracker tab with punch-in armed, also enter the note into the
|
||||
// grid — recording its own velocity/channel so it plays back correctly.
|
||||
if (selectedTab == TAB_TRACKER && punchInMode) {
|
||||
val ch = if (action.channel >= 1) action.channel else kbdChannel
|
||||
punchNote(action.pitch, action.velocity, ch)
|
||||
}
|
||||
}
|
||||
is InputAction.NoteOff -> { markNoteOff(action.pitch); engine.liveNoteOff(action.pitch) }
|
||||
is InputAction.NoteOff -> {
|
||||
markNoteOff(action.pitch)
|
||||
if (action.channel >= 1) engine.busNoteOff(action.channel, action.pitch)
|
||||
else engine.liveNoteOff(action.pitch)
|
||||
}
|
||||
InputAction.PlayPause -> playPause()
|
||||
InputAction.Stop -> stop()
|
||||
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
|
||||
@@ -512,11 +590,95 @@ class AppViewModel(
|
||||
router.learnMode = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------- toolbox parameter MIDI-learn
|
||||
private fun ccKey(paramKey: String) = "$CC_PREFIX$paramKey"
|
||||
|
||||
/** The CC number currently bound to [slotIndex]'s [paramKey], or null. */
|
||||
fun paramCcFor(slotIndex: Int, paramKey: String): Int? =
|
||||
project.toolbox.getOrNull(slotIndex)?.string(ccKey(paramKey))?.toIntOrNull()
|
||||
|
||||
/** Arm MIDI-learn for a toolbox parameter: the next MIDI control moved binds to it. */
|
||||
fun beginParamLearn(slotIndex: Int, paramKey: String) {
|
||||
cancelLearn() // don't clash with a Settings nav/transport learn
|
||||
paramLearnSlot = slotIndex
|
||||
paramLearnKey = paramKey
|
||||
router.learnSource = space.rcmd.android.sizzle.input.InputSource.MIDI
|
||||
router.learnMode = true
|
||||
touched()
|
||||
}
|
||||
|
||||
fun cancelParamLearn() {
|
||||
paramLearnSlot = -1
|
||||
paramLearnKey = null
|
||||
router.learnSource = null
|
||||
router.learnMode = false
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Remove the MIDI-CC binding for [slotIndex]'s [paramKey]. */
|
||||
fun clearParamCc(slotIndex: Int, paramKey: String) {
|
||||
project.toolbox.getOrNull(slotIndex)?.values?.remove(ccKey(paramKey))
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Bind [cc] to [slotIndex]'s [paramKey]. A physical control drives one parameter,
|
||||
* so the CC is first removed from any parameter it was previously bound to. */
|
||||
private fun bindParamCc(slotIndex: Int, paramKey: String, cc: Int) {
|
||||
for (s in project.toolbox) {
|
||||
s.values.entries.removeAll { it.key.startsWith(CC_PREFIX) && it.value.toIntOrNull() == cc }
|
||||
}
|
||||
project.toolbox.getOrNull(slotIndex)?.set(ccKey(paramKey), cc.toString())
|
||||
touched()
|
||||
}
|
||||
|
||||
/** Drive every toolbox parameter bound to [cc] from a normalized ([0,1]) value. */
|
||||
private fun applyParamCc(cc: Int, norm: Float) {
|
||||
var changed = false
|
||||
for (slot in project.toolbox) {
|
||||
val type = slot.type ?: continue
|
||||
for (spec in type.params) {
|
||||
if (slot.values[ccKey(spec.key)]?.toIntOrNull() != cc) continue
|
||||
applyNormToParam(slot, spec, norm)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) touched()
|
||||
}
|
||||
|
||||
private fun applyNormToParam(slot: ToolboxSlot, spec: ParamSpec, norm: Float) {
|
||||
val n = norm.coerceIn(0f, 1f)
|
||||
if (spec.isEnum) {
|
||||
val idx = (n * (spec.choices.size - 1)).roundToInt().coerceIn(0, spec.choices.lastIndex)
|
||||
slot.set(spec.key, spec.choices[idx])
|
||||
} else {
|
||||
var v = spec.min + n * (spec.max - spec.min)
|
||||
if (spec.isInt) v = v.roundToInt().toFloat()
|
||||
slot.set(spec.key, v)
|
||||
}
|
||||
}
|
||||
|
||||
/** A control arrived during learn. If it matches the armed source, store it as
|
||||
* the binding for [learnTarget]. Each action keeps at most one binding, so any
|
||||
* previous one for this action (on the same device) is replaced. */
|
||||
private fun onRawControl(action: InputAction.RawControl) {
|
||||
val target = learnTarget ?: return
|
||||
// Toolbox parameter MIDI-learn takes priority: the first MIDI control captured
|
||||
// binds to the armed parameter.
|
||||
val plKey = paramLearnKey
|
||||
if (plKey != null) {
|
||||
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
|
||||
bindParamCc(paramLearnSlot, plKey, action.code)
|
||||
cancelParamLearn()
|
||||
}
|
||||
return
|
||||
}
|
||||
val target = learnTarget
|
||||
if (target == null) {
|
||||
// Not learning: a MIDI CC bound to a toolbox parameter drives it live.
|
||||
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
|
||||
applyParamCc(action.code, action.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action.source != learnSource) return
|
||||
when (action.source) {
|
||||
space.rcmd.android.sizzle.input.InputSource.MIDI ->
|
||||
@@ -810,6 +972,24 @@ class AppViewModel(
|
||||
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
|
||||
kbdOctave = s.kbdOctave.coerceIn(0, 8)
|
||||
vuMeters = s.vuMeters
|
||||
|
||||
// MIDI clock sync: feed the send thread live transport, follow an external
|
||||
// clock into the transport/tempo, and restore the persisted toggles.
|
||||
midiClock.playingProvider = { engine.transport.value.isPlaying }
|
||||
midiClock.bpmProvider = { project.tempoBpm }
|
||||
midiClock.noteSource = { engine.pollMidiOut() } // forward sequenced notes out
|
||||
midiClock.onReceiveStart = {
|
||||
viewModelScope.launch(Dispatchers.Main) { if (!engine.transport.value.isPlaying) engine.play() }
|
||||
}
|
||||
midiClock.onReceiveStop = {
|
||||
viewModelScope.launch(Dispatchers.Main) { engine.pause() }
|
||||
}
|
||||
midiClock.onReceiveTempo = { bpm ->
|
||||
viewModelScope.launch(Dispatchers.Main) { setTempo(bpm) }
|
||||
}
|
||||
sendMidiClock = s.sendMidiClock
|
||||
midiClock.sendEnabled = s.sendMidiClock; engine.midiOutEnabled = s.sendMidiClock
|
||||
receiveMidiClock = s.receiveMidiClock; midiClock.receiveEnabled = s.receiveMidiClock
|
||||
engine.recTempDir = appContext.cacheDir
|
||||
engine.setRecordTailBars(recordTailBars)
|
||||
engine.onRecordingStarted = {
|
||||
@@ -878,6 +1058,7 @@ class AppViewModel(
|
||||
fun loadProjectText(text: String) {
|
||||
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
|
||||
cursorLine = 0; cursorTrack = 0
|
||||
clearArrangementSelection() // the old arrangement's selection is no longer meaningful
|
||||
touched()
|
||||
}
|
||||
|
||||
@@ -949,6 +1130,35 @@ class AppViewModel(
|
||||
fun writeSngExport(): java.io.File =
|
||||
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())
|
||||
|
||||
/** Write the current project as a full, self-contained `.szgz` bundle (project +
|
||||
* all referenced sample WAVs) to a shareable file, for moving whole projects
|
||||
* between Sizzletracker instances. */
|
||||
fun writeProjectBundle(): java.io.File {
|
||||
val f = songLibrary.bundleExportFile(project.name.ifBlank { "project" })
|
||||
f.outputStream().use { space.rcmd.android.sizzle.io.ProjectBundleIo.write(project, it) }
|
||||
return f
|
||||
}
|
||||
|
||||
/** Import a full project bundle (in place), extracting its samples and restoring
|
||||
* the audio. Returns false (leaving the current project) if it isn't a valid
|
||||
* bundle. Replaces the current project, like [loadSong]. */
|
||||
fun importProjectBundle(bytes: ByteArray): Boolean {
|
||||
engine.stop()
|
||||
val samplesDir = java.io.File(appContext.filesDir, "projectsamples")
|
||||
val ok = runCatching {
|
||||
space.rcmd.android.sizzle.io.ProjectBundleIo.read(
|
||||
project, java.io.ByteArrayInputStream(bytes), samplesDir,
|
||||
)
|
||||
}.getOrDefault(false)
|
||||
if (ok) {
|
||||
presetLibrary.rehydrate(project) // decode the extracted WAVs into the SampleStore
|
||||
engine.rebuildFxChains() // mixer FX routing may have changed
|
||||
cursorLine = 0; cursorTrack = 0
|
||||
touched()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- full backup/restore
|
||||
/** Result of the last backup/restore, shown in a dialog; null when dismissed. */
|
||||
var backupMessage by mutableStateOf<String?>(null); private set
|
||||
|
||||
@@ -94,11 +94,11 @@ fun SettingsScreen(vm: AppViewModel) {
|
||||
) {
|
||||
item { PanicButton(vm) }
|
||||
item { ProjectCard(vm) }
|
||||
item { BackupCard(vm) }
|
||||
item { HelpCard() }
|
||||
item { AudioDevicesCard(vm) }
|
||||
item { InterfaceCard(vm) }
|
||||
item { ThemeCard(vm) }
|
||||
item { MidiSyncCard(vm) }
|
||||
item {
|
||||
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
|
||||
section = SettingsSection.GAMEPAD
|
||||
@@ -114,6 +114,7 @@ fun SettingsScreen(vm: AppViewModel) {
|
||||
section = SettingsSection.MIDI
|
||||
}
|
||||
}
|
||||
item { BackupCard(vm) }
|
||||
}
|
||||
|
||||
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
|
||||
// buttons (save / new / delete) so the selector is clear of them.
|
||||
Box {
|
||||
@@ -271,10 +286,29 @@ private fun ProjectCard(vm: AppViewModel) {
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
// Full project bundle (.szgz): project + all sample WAVs, for sharing whole
|
||||
// projects between Sizzletracker instances.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { shareFile(context, vm.writeProjectBundle(), "application/zip", "Share project") }) {
|
||||
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||
Text("Export project")
|
||||
}
|
||||
OutlinedButton(onClick = { bundleImporter.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) {
|
||||
Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
|
||||
Text("Import project")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"A bundle carries everything — patterns, mixer, FX, instruments and samples — " +
|
||||
"so it opens complete on another device. Import replaces the current project.",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
// Desktop interchange: .sng is only exported / imported, never the library.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { shareSongFile(context, vm.writeSngExport()) }) {
|
||||
OutlinedButton(onClick = { shareFile(context, vm.writeSngExport(), "text/plain", "Share song") }) {
|
||||
Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
|
||||
Text("Export .sng")
|
||||
}
|
||||
@@ -348,15 +382,15 @@ private fun ProjectCard(vm: AppViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch the system share sheet for an exported .sng file. */
|
||||
private fun shareSongFile(context: Context, file: java.io.File) {
|
||||
/** Launch the system share sheet for an exported file (song, .sng, or bundle). */
|
||||
private fun shareFile(context: Context, file: java.io.File, mime: String, title: String) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
type = mime
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, "Share song"))
|
||||
context.startActivity(Intent.createChooser(send, title))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -468,26 +502,53 @@ private fun openUrl(context: Context, url: String) {
|
||||
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. */
|
||||
@Composable
|
||||
private fun InterfaceCard(vm: AppViewModel) {
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
|
||||
SettingsCard("Interface", subtitle = "On-screen display options") {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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) })
|
||||
}
|
||||
ToggleRow(
|
||||
"Mixer VU meters",
|
||||
"Show live level meters inside the mixer volume faders.",
|
||||
vm.vuMeters,
|
||||
) { vm.updateVuMeters(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -299,6 +300,8 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER
|
||||
// Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree.
|
||||
var samplerPad by remember(slot.index) { mutableIntStateOf(0) }
|
||||
// Whether the MIDI-learn popup (map controls to this device's parameters) is open.
|
||||
var showMidiLearn by remember(slot.index) { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -312,8 +315,13 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
|
||||
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
|
||||
// Right-side toolbar: MIDI-learn (map controls to params) + close.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("⇄ MIDI", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { showMidiLearn = true } })
|
||||
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
|
||||
}
|
||||
}
|
||||
|
||||
// Preset browser: load / save / import / share for this device.
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,14 +100,13 @@ fun ArrangementRoll(vm: AppViewModel) {
|
||||
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||
|
||||
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none.
|
||||
// Unlike before, selecting does NOT change any cell; the toolbar toggle acts
|
||||
// on the selection. Read in composition (for the toolbar's FILL/CLEAR label)
|
||||
// and captured by the Canvas draw below.
|
||||
var selLane0 by remember { mutableIntStateOf(-1) }
|
||||
var selBeat0 by remember { mutableIntStateOf(-1) }
|
||||
var selLane1 by remember { mutableIntStateOf(-1) }
|
||||
var selBeat1 by remember { mutableIntStateOf(-1) }
|
||||
// Rectangular cell selection lives on the ViewModel so the transport (Play/Stop)
|
||||
// can read and clear it — Play starts from the selection's first beat, Stop clears
|
||||
// it. Selecting still does NOT change any cell; the toolbar toggle acts on it.
|
||||
val selLane0 = vm.arrSelLane0
|
||||
val selBeat0 = vm.arrSelBeat0
|
||||
val selLane1 = vm.arrSelLane1
|
||||
val selBeat1 = vm.arrSelBeat1
|
||||
|
||||
val hasSelection = selBeat0 >= 0
|
||||
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
|
||||
@@ -227,8 +226,7 @@ fun ArrangementRoll(vm: AppViewModel) {
|
||||
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
||||
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||
selLane0 = lane; selLane1 = lane
|
||||
selBeat0 = beat; selBeat1 = beat
|
||||
vm.setArrangementSelection(lane, beat, lane, beat)
|
||||
}
|
||||
}
|
||||
// Long-press then drag sweeps out a rectangular selection.
|
||||
@@ -240,14 +238,16 @@ fun ArrangementRoll(vm: AppViewModel) {
|
||||
}
|
||||
fun beatAt(off: Offset): Int =
|
||||
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||
var dragLane0 = 0; var dragBeat0 = 0
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { off ->
|
||||
selLane0 = laneAt(off); selLane1 = selLane0
|
||||
selBeat0 = beatAt(off); selBeat1 = selBeat0
|
||||
dragLane0 = laneAt(off); dragBeat0 = beatAt(off)
|
||||
vm.setArrangementSelection(dragLane0, dragBeat0, dragLane0, dragBeat0)
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
selLane1 = laneAt(change.position)
|
||||
selBeat1 = beatAt(change.position)
|
||||
vm.setArrangementSelection(
|
||||
dragLane0, dragBeat0, laneAt(change.position), beatAt(change.position),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package space.rcmd.android.sizzle.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,8 +20,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -147,14 +150,31 @@ private fun TransportButtons(vm: AppViewModel) {
|
||||
// Fixed width so the button doesn't resize as the label toggles.
|
||||
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||
active = isPlaying, width = 112.dp, onClick = vm::playPause)
|
||||
// Tempo nudger: tap -/+ around a fixed-width readout.
|
||||
// Tempo nudger: tap -/+ around a fixed-width readout. Tapping the readout itself
|
||||
// is TAP TEMPO — two or more taps in time set the tempo from the average interval.
|
||||
val tapTimes = remember { ArrayList<Long>() }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
|
||||
Text(
|
||||
"${project.tempoBpm.toInt()} BPM",
|
||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center, maxLines = 1,
|
||||
modifier = Modifier.width(72.dp).padding(vertical = 6.dp),
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
val now = System.currentTimeMillis()
|
||||
// A long pause starts a fresh count rather than averaging across it.
|
||||
if (tapTimes.isNotEmpty() && now - tapTimes.last() > TAP_RESET_MS) tapTimes.clear()
|
||||
tapTimes.add(now)
|
||||
while (tapTimes.size > TAP_MAX) tapTimes.removeAt(0)
|
||||
if (tapTimes.size >= 2) {
|
||||
val span = tapTimes.last() - tapTimes.first()
|
||||
if (span > 0) vm.setTempo(60_000f * (tapTimes.size - 1) / span)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(vertical = 6.dp),
|
||||
)
|
||||
RepeatButton("+", onStep = { vm.setTempo(project.tempoBpm + 1) })
|
||||
}
|
||||
@@ -224,3 +244,8 @@ private fun EditButtons(vm: AppViewModel) {
|
||||
// Swap the bottom half between the arranger and the punch-in keyboard.
|
||||
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
||||
}
|
||||
|
||||
// Tap-tempo tuning: a gap longer than this starts a fresh count; keep at most this
|
||||
// many recent taps in the averaging window.
|
||||
private const val TAP_RESET_MS = 2000L
|
||||
private const val TAP_MAX = 8
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The arpeggiator must cycle through ALL notes of a held chord (across the octave
|
||||
* range), not collapse to the last one entered — the fix for a held chord ringing
|
||||
* only its rightmost note.
|
||||
*/
|
||||
class ChannelArpTest {
|
||||
|
||||
private fun arp() = AudioEngine.ChannelArp()
|
||||
|
||||
/** The Up sequence over a chord × octaves is octave-major over the sorted notes. */
|
||||
@Test fun upCyclesWholeChordAcrossOctaves() {
|
||||
val a = arp()
|
||||
// Enter the chord out of order; it should sort ascending.
|
||||
a.startChord(67, 100, octs = 2, modeStr = "Up", stepSamp = 100.0, resetBar = false)
|
||||
a.addNote(60)
|
||||
a.addNote(64)
|
||||
assertEquals(3, a.noteCount)
|
||||
|
||||
// step 0 is the first note before any advance.
|
||||
val seq = ArrayList<Int>()
|
||||
seq.add(a.currentNote())
|
||||
repeat(5) { a.advance(); seq.add(a.currentNote()) }
|
||||
// 3 notes × 2 octaves = C4 E4 G4 C5 E5 G5, then wraps to C4.
|
||||
assertEquals(listOf(60, 64, 67, 72, 76, 79), seq)
|
||||
a.advance()
|
||||
assertEquals("wraps back to the start", 60, a.currentNote())
|
||||
}
|
||||
|
||||
@Test fun downStartsHandledAndStaysInRange() {
|
||||
val a = arp()
|
||||
a.startChord(60, 100, octs = 1, modeStr = "Down", stepSamp = 100.0, resetBar = false)
|
||||
a.addNote(64); a.addNote(67)
|
||||
val seen = HashSet<Int>()
|
||||
repeat(9) { seen.add(a.currentNote()); a.advance() }
|
||||
// Only the three chord notes (one octave) ever sound.
|
||||
assertEquals(setOf(60, 64, 67), seen)
|
||||
}
|
||||
|
||||
@Test fun addNoteDeDuplicates() {
|
||||
val a = arp()
|
||||
a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
|
||||
a.addNote(60); a.addNote(60)
|
||||
assertEquals(1, a.noteCount)
|
||||
}
|
||||
|
||||
@Test fun resetChordKeepsGoingWithNewChord() {
|
||||
val a = arp()
|
||||
a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
|
||||
a.addNote(64)
|
||||
a.advance() // move off step 0
|
||||
// A loop-back rebuilds the chord but keeps arpeggiating.
|
||||
a.resetChord(48, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
|
||||
a.addNote(55)
|
||||
assertTrue(a.active)
|
||||
val seen = HashSet<Int>()
|
||||
repeat(4) { seen.add(a.currentNote()); a.advance() }
|
||||
assertEquals(setOf(48, 55), seen)
|
||||
}
|
||||
|
||||
@Test fun stopClearsChord() {
|
||||
val a = arp()
|
||||
a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false)
|
||||
a.stop()
|
||||
assertEquals(0, a.noteCount)
|
||||
assertTrue(!a.active)
|
||||
}
|
||||
}
|
||||
@@ -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