Compare commits
25 Commits
c4f4cf34c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924b84a4d5 | ||
|
|
7296a3a72b | ||
|
|
dfb6ee9c9b | ||
|
|
9fdef7fee7 | ||
|
|
96bbc5a4cc | ||
|
|
682418d69e | ||
|
|
3c4c30bf1f | ||
|
|
b305f0a2b5 | ||
|
|
79a1844512 | ||
|
|
8c44ed5151 | ||
|
|
3488bac1ca | ||
|
|
5dfbda7c15 | ||
|
|
d43112f296 | ||
|
|
71bb50c775 | ||
|
|
ef13a46026 | ||
|
|
abdcf77ce5 | ||
|
|
8eee588780 | ||
|
|
40a946a894 | ||
|
|
3508777ba9 | ||
|
|
7a0f2e4426 | ||
|
|
b07d758609 | ||
|
|
a5b9837bbc | ||
|
|
5943040a2b | ||
|
|
b7ae8cfbff | ||
|
|
3c0b93855b |
@@ -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 = 20
|
versionCode = 48
|
||||||
versionName = "0.7.1"
|
versionName = "0.21.1"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
app.project, app.audioEngine, app.inputRouter,
|
app.project, app.audioEngine, app.inputRouter,
|
||||||
app.gamepadInput, app.midiInput, app.keyboardInput,
|
app.gamepadInput, app.midiInput, app.keyboardInput,
|
||||||
app.bindingStore, app.presetLibrary, app.songLibrary,
|
app.bindingStore, app.presetLibrary, app.songLibrary,
|
||||||
app.settingsStore, app.themeLibrary, app.applicationContext,
|
app.settingsStore, app.themeLibrary, app.midiClock, app.applicationContext,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -75,11 +75,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
SizzleAppUi(viewModel)
|
SizzleAppUi(viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
// Start MIDI listening (USB + BLE) and the MIDI clock here — bound to the
|
||||||
super.onStart()
|
// Activity's WHOLE lifetime (onCreate..onDestroy), not onStart/onStop — so a
|
||||||
|
// connected controller keeps driving the app while the screen is off. The
|
||||||
|
// audio engine already runs a continuous output stream via the started
|
||||||
|
// PlaybackService, which keeps the process alive to receive those events in
|
||||||
|
// the background; closing the ports on onStop was the only reason input died.
|
||||||
midi.start()
|
midi.start()
|
||||||
|
app.midiClock.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -92,12 +96,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
// Autosave the project whenever we leave the foreground, so a later
|
// Autosave the project whenever we leave the foreground, so a later
|
||||||
// system-kill (or crash) resumes from here on next launch.
|
// system-kill (or crash) resumes from here on next launch. MIDI is
|
||||||
|
// deliberately NOT stopped here (see onCreate / onDestroy) so controllers
|
||||||
|
// keep working with the screen off or the app backgrounded.
|
||||||
app.projectStore.save(app.project)
|
app.projectStore.save(app.project)
|
||||||
midi.stop()
|
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
// Release MIDI only when the Activity is actually torn down (task removed or
|
||||||
|
// finishing), not merely backgrounded / screen-off.
|
||||||
|
midi.stop()
|
||||||
|
app.midiClock.stop()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Hardware input forwarding ----
|
// ---- Hardware input forwarding ----
|
||||||
// Gamepad events are intercepted at DISPATCH time — before Compose's focus
|
// Gamepad events are intercepted at DISPATCH time — before Compose's focus
|
||||||
// system can consume D-pad / button keys — so USB *and* Bluetooth pads work
|
// system can consume D-pad / button keys — so USB *and* Bluetooth pads work
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
import kotlin.math.tanh
|
import kotlin.math.tanh
|
||||||
|
|
||||||
@@ -77,11 +78,29 @@ class AudioEngine(
|
|||||||
private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
|
private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
|
||||||
// Per-(lane,track) arpeggiator state, driven by advanceArps.
|
// Per-(lane,track) arpeggiator state, driven by advanceArps.
|
||||||
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
|
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
|
// 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
|
// 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.
|
// 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.
|
||||||
@@ -153,6 +172,35 @@ class AudioEngine(
|
|||||||
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
||||||
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
||||||
|
|
||||||
|
// ---- master brickwall limiter (feed-forward, look-ahead) ----
|
||||||
|
// A short delay line lets the peak detector "see" a loud sample coming and pull
|
||||||
|
// the gain down BEFORE it reaches the output, so the makeup gain below can be
|
||||||
|
// pushed hard (bringing quiet instruments up to media loudness) while the output
|
||||||
|
// is mathematically guaranteed to stay under LIM_CEILING — no clipping, and none
|
||||||
|
// of the tanh saturation a static soft-clipper would add to a hot single voice.
|
||||||
|
// Gain has instant attack (drops the moment a peak enters the look-ahead window),
|
||||||
|
// holds for the window length, then releases smoothly toward unity. Audio-thread
|
||||||
|
// state only.
|
||||||
|
private val limDelayL = FloatArray(LIM_LOOKAHEAD)
|
||||||
|
private val limDelayR = FloatArray(LIM_LOOKAHEAD)
|
||||||
|
private var limPos = 0
|
||||||
|
private var limGain = 1f
|
||||||
|
private var limHold = 0
|
||||||
|
// Limiter output for the current sample (avoids a per-sample Pair allocation).
|
||||||
|
private var limOutL = 0f
|
||||||
|
private var limOutR = 0f
|
||||||
|
|
||||||
|
// ---- per-channel VU metering (for the mixer strips' fader VU) ----
|
||||||
|
// The audio thread tracks each bus's post-fader/post-FX peak within a block, then
|
||||||
|
// once per block folds it into a smoothly-decaying meter value (instant attack,
|
||||||
|
// exponential release) and a clip-hold counter (latched when the bus crosses 0 dBFS
|
||||||
|
// so a brief overload stays visible). The UI polls [channelMeter]/[channelClipped]
|
||||||
|
// at frame rate. Plain arrays: single writer (audio thread), display-only readers —
|
||||||
|
// a stale value for one frame is harmless, so no locking/volatility is needed.
|
||||||
|
private val chBlockPeak = FloatArray(Pattern.TRACK_COUNT) // scratch, reset each block
|
||||||
|
private val chMeter = FloatArray(Pattern.TRACK_COUNT) // published decaying peak
|
||||||
|
private val chClipHold = IntArray(Pattern.TRACK_COUNT) // blocks left to show "clipping"
|
||||||
|
|
||||||
// Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
|
// Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
|
||||||
// the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
|
// the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
|
||||||
// never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
|
// never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
|
||||||
@@ -458,9 +506,34 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- transport
|
// --------------------------------------------------------------- transport
|
||||||
fun play() {
|
/**
|
||||||
|
* Start playback. When [startBeat] is >= 0, seek to that beat in the arrangement
|
||||||
|
* (or that line in pattern-loop mode) so Play resumes from a marker the user
|
||||||
|
* placed on the arrangement, instead of always from the top. Ignored if it doesn't
|
||||||
|
* land inside the current playlist / pattern.
|
||||||
|
*/
|
||||||
|
fun play(startBeat: Int = -1) {
|
||||||
rebuildArrangement()
|
rebuildArrangement()
|
||||||
rebuildFxChains()
|
rebuildFxChains()
|
||||||
|
if (startBeat >= 0) {
|
||||||
|
if (arrangementMode) {
|
||||||
|
val list = playlist
|
||||||
|
// Find the first occurrence of that beat in the (possibly A/B-expanded)
|
||||||
|
// playlist; if it isn't in there just start from the top.
|
||||||
|
var seek = -1
|
||||||
|
for (i in list.indices) if (list[i] == startBeat) { seek = i; break }
|
||||||
|
if (seek >= 0) {
|
||||||
|
playlistIndex = seek; lineInBeat = 0; arrPlayLine = 0; samplesIntoLine = 0.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val proj = project
|
||||||
|
if (proj != null) {
|
||||||
|
val pat = proj.activePattern()
|
||||||
|
val line = (startBeat * proj.timeSignature.linesPerBeat).coerceIn(0, pat.length - 1)
|
||||||
|
currentLine = line; samplesIntoLine = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pendingTrigger = true
|
pendingTrigger = true
|
||||||
playing = true
|
playing = true
|
||||||
maybeStartRecording()
|
maybeStartRecording()
|
||||||
@@ -485,6 +558,7 @@ class AudioEngine(
|
|||||||
busSampleVoices.forEach { it.kill() }
|
busSampleVoices.forEach { it.kill() }
|
||||||
arps.forEach { it.stop() }
|
arps.forEach { it.stop() }
|
||||||
trackLastChannel.fill(-1)
|
trackLastChannel.fill(-1)
|
||||||
|
allMidiNotesOff() // release any notes sent to external gear
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop playback. If already stopped, rewind to the very beginning. */
|
/** Stop playback. If already stopped, rewind to the very beginning. */
|
||||||
@@ -508,7 +582,10 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun liveNoteOff(pitch: Int) {
|
fun liveNoteOff(pitch: Int) {
|
||||||
liveVoices.firstOrNull { it.isActive && it.pitch == pitch }?.noteOff()
|
// Prefer a held (gated) voice over one already releasing, so a fast retrigger
|
||||||
|
// of the same pitch never leaves the held voice stuck (see [auditionSlotOff]).
|
||||||
|
(liveVoices.firstOrNull { it.pitch == pitch && it.isGated }
|
||||||
|
?: liveVoices.firstOrNull { it.isActive && it.pitch == pitch })?.noteOff()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- render loop
|
// ------------------------------------------------------------- render loop
|
||||||
@@ -555,6 +632,7 @@ class AudioEngine(
|
|||||||
// note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
|
// note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
|
||||||
blockGen++
|
blockGen++
|
||||||
actSeqN = 0; actSmpN = 0; actBusN = 0; actBSmpN = 0; actLiveN = 0; actASmpN = 0
|
actSeqN = 0; actSmpN = 0; actBusN = 0; actBSmpN = 0; actLiveN = 0; actASmpN = 0
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) chBlockPeak[ch] = 0f // reset per-channel VU accumulators
|
||||||
for (i in seqVoices.indices) if (seqVoices[i].isActive) { seqGen[i] = blockGen; actSeq[actSeqN++] = i }
|
for (i in seqVoices.indices) if (seqVoices[i].isActive) { seqGen[i] = blockGen; actSeq[actSeqN++] = i }
|
||||||
for (i in sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
|
for (i in sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
|
||||||
for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
|
for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
|
||||||
@@ -587,6 +665,9 @@ class AudioEngine(
|
|||||||
if (!s.isFinite()) s = 0f
|
if (!s.isFinite()) s = 0f
|
||||||
// Optional per-bus soft-clip limiter (toolbar toggle).
|
// Optional per-bus soft-clip limiter (toolbar toggle).
|
||||||
if (mc.limiter) s = softClip(s)
|
if (mc.limiter) s = softClip(s)
|
||||||
|
// Track this bus's peak for the mixer VU (post-fader/post-FX).
|
||||||
|
val a = if (s < 0f) -s else s
|
||||||
|
if (a > chBlockPeak[ch]) chBlockPeak[ch] = a
|
||||||
// Voices and inserts are mono; both master channels get the same
|
// Voices and inserts are mono; both master channels get the same
|
||||||
// sum. The master output/recording path stays stereo (2 channels).
|
// sum. The master output/recording path stays stereo (2 channels).
|
||||||
mixL += s; mixR += s
|
mixL += s; mixR += s
|
||||||
@@ -594,12 +675,87 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
for (k in 0 until actLiveN) { val v = liveVoices[actLive[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
|
for (k in 0 until actLiveN) { val v = liveVoices[actLive[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
|
||||||
for (k in 0 until actASmpN) { val v = auditionSampleVoices[actASmp[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
|
for (k in 0 until actASmpN) { val v = auditionSampleVoices[actASmp[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
|
||||||
val oL = softClip(mixL * MASTER_GAIN)
|
// Makeup gain (lifts instruments to media loudness) into a look-ahead
|
||||||
val oR = softClip(mixR * MASTER_GAIN)
|
// brickwall limiter that guarantees the output stays under the ceiling.
|
||||||
|
masterLimit(mixL * MASTER_MAKEUP, mixR * MASTER_MAKEUP)
|
||||||
|
val oL = limOutL
|
||||||
|
val oR = limOutR
|
||||||
out[2 * i] = oL
|
out[2 * i] = oL
|
||||||
out[2 * i + 1] = oR
|
out[2 * i + 1] = oR
|
||||||
if (recCapturing) recordSample(proj, oL, oR)
|
if (recCapturing) recordSample(proj, oL, oR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fold this block's per-channel peaks into the VU meter values: instant attack
|
||||||
|
// (jump up to a new peak), exponential release (decay back down), plus a clip
|
||||||
|
// latch that holds when the bus crossed 0 dBFS so a momentary overload is seen.
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val peak = chBlockPeak[ch]
|
||||||
|
val decayed = chMeter[ch] * METER_DECAY
|
||||||
|
chMeter[ch] = if (peak > decayed) peak else decayed
|
||||||
|
if (peak >= METER_CLIP_LEVEL) chClipHold[ch] = METER_CLIP_HOLD_BLOCKS
|
||||||
|
else if (chClipHold[ch] > 0) chClipHold[ch]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current VU level (peak amplitude, ~0..1+) for mixer bus [ch]; 0 if out of range. */
|
||||||
|
fun channelMeter(ch: Int): Float = if (ch in chMeter.indices) chMeter[ch] else 0f
|
||||||
|
|
||||||
|
/** True while bus [ch] is (or recently was) clipping past 0 dBFS — drives the red VU. */
|
||||||
|
fun channelClipped(ch: Int): Boolean = ch in chClipHold.indices && chClipHold[ch] > 0
|
||||||
|
|
||||||
|
/** The live visualization source for toolbox [slotIndex], if that slot is an effect
|
||||||
|
* in some channel's insert chain and produces one (first match wins). Read on the
|
||||||
|
* UI thread; the returned processor's viz fields are written by the audio thread. */
|
||||||
|
private fun vizFor(slotIndex: Int): VizEffect? {
|
||||||
|
val chains = channelChains
|
||||||
|
for (ch in chains) for (u in ch) {
|
||||||
|
if (u.slot.index == slotIndex) { val e = u.effect; if (e is VizEffect) return e }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy effect [slotIndex]'s scope samples into [out]; -1 if it isn't a live,
|
||||||
|
* routed viz effect (nothing to draw). */
|
||||||
|
fun effectScope(slotIndex: Int, out: FloatArray): Int = vizFor(slotIndex)?.copyScope(out) ?: -1
|
||||||
|
|
||||||
|
/** Effect [slotIndex]'s playhead (0..1), or -1 if no live viz source. */
|
||||||
|
fun effectPlayhead(slotIndex: Int): Float = vizFor(slotIndex)?.playhead() ?: -1f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
|
||||||
|
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.
|
||||||
|
*
|
||||||
|
* How it guarantees "no clipping": the incoming (post-makeup) sample is pushed
|
||||||
|
* into a [LIM_LOOKAHEAD]-sample delay line, and the gain is driven by the *newest*
|
||||||
|
* sample while it is applied to the *oldest* (delayed) one. When a sample louder
|
||||||
|
* than [LIM_CEILING] enters, the gain drops instantly to exactly the amount needed
|
||||||
|
* to tame it and is *held* for the whole look-ahead window (plus a small margin),
|
||||||
|
* so by the time that loud sample reaches the output the gain is already pulled
|
||||||
|
* down — the delayed peak can never exceed the ceiling. Between peaks the gain
|
||||||
|
* releases smoothly back toward unity, so quiet passages keep the full makeup.
|
||||||
|
* A final hard clamp is a cheap belt-and-braces guard (it should never engage).
|
||||||
|
*/
|
||||||
|
private fun masterLimit(inL: Float, inR: Float) {
|
||||||
|
val dL = limDelayL[limPos]
|
||||||
|
val dR = limDelayR[limPos]
|
||||||
|
limDelayL[limPos] = inL
|
||||||
|
limDelayR[limPos] = inR
|
||||||
|
limPos++; if (limPos >= LIM_LOOKAHEAD) limPos = 0
|
||||||
|
|
||||||
|
val peak = max(abs(inL), abs(inR))
|
||||||
|
val target = if (peak > LIM_CEILING) LIM_CEILING / peak else 1f
|
||||||
|
when {
|
||||||
|
target < limGain -> { limGain = target; limHold = LIM_LOOKAHEAD + LIM_HOLD_EXTRA }
|
||||||
|
limHold > 0 -> limHold--
|
||||||
|
else -> limGain += (1f - limGain) * LIM_RELEASE
|
||||||
|
}
|
||||||
|
|
||||||
|
var oL = dL * limGain
|
||||||
|
var oR = dR * limGain
|
||||||
|
if (oL > LIM_CEILING) oL = LIM_CEILING else if (oL < -LIM_CEILING) oL = -LIM_CEILING
|
||||||
|
if (oR > LIM_CEILING) oR = LIM_CEILING else if (oR < -LIM_CEILING) oR = -LIM_CEILING
|
||||||
|
limOutL = if (oL.isFinite()) oL else 0f
|
||||||
|
limOutR = if (oR.isFinite()) oR else 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Push slot parameters (+ tempo) into every live effect processor, but only when
|
/** Push slot parameters (+ tempo) into every live effect processor, but only when
|
||||||
@@ -721,6 +877,7 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) {
|
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.
|
// 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
|
// 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
|
// cancels a note that begins on the same line/bus (a note-off releases the
|
||||||
@@ -752,6 +909,8 @@ class AudioEngine(
|
|||||||
private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
|
private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
|
||||||
val trackKey = lane * Pattern.TRACK_COUNT + track
|
val trackKey = lane * Pattern.TRACK_COUNT + track
|
||||||
if (cell.isNoteOff) {
|
if (cell.isNoteOff) {
|
||||||
|
// Release this track's outgoing MIDI note first (external gear).
|
||||||
|
if (moutNote[trackKey] >= 0) { emitNoteOff(moutCh[trackKey], moutNote[trackKey]); moutNote[trackKey] = -1 }
|
||||||
val ch = trackLastChannel[trackKey]
|
val ch = trackLastChannel[trackKey]
|
||||||
if (ch < 0) return // this track hasn't played anything yet
|
if (ch < 0) return // this track hasn't played anything yet
|
||||||
for (bus in 0 until Pattern.TRACK_COUNT) {
|
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||||
@@ -764,6 +923,18 @@ 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,
|
||||||
|
// 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
|
||||||
|
// notes other tracks hold on the same bus (chords across tracks) keep sounding.
|
||||||
|
releaseTrackVoices(trackKey)
|
||||||
for (bus in 0 until Pattern.TRACK_COUNT) {
|
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||||
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
|
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
|
||||||
val arp = arps[lane * Pattern.TRACK_COUNT + bus]
|
val arp = arps[lane * Pattern.TRACK_COUNT + bus]
|
||||||
@@ -776,37 +947,58 @@ class AudioEngine(
|
|||||||
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||||
val mode = arpSlot.string("mode", "Up")
|
val mode = arpSlot.string("mode", "Up")
|
||||||
val resetBar = arpSlot.string("resetbar", "Off") == "On"
|
val resetBar = arpSlot.string("resetbar", "Off") == "On"
|
||||||
if (arp.active && arp.base == note) {
|
when {
|
||||||
// The same note is already arpeggiating — this trigger is the
|
arp.active && arp.genStamp == lineTriggerGen -> {
|
||||||
// pattern looping back over a still-held note. Keep the running
|
// Another note of the SAME line's chord — accumulate it so the
|
||||||
// phase (just refresh params) so the pulse stays steady across
|
// arp cycles the whole chord (not just the last note entered).
|
||||||
// the loop point instead of slipping when the loop length isn't
|
arp.addNote(note)
|
||||||
// an exact multiple of the arp step (dotted/triplet divisions).
|
arp.refresh(octaves, mode, stepSamples, resetBar)
|
||||||
arp.refresh(octaves, mode, stepSamples, resetBar)
|
}
|
||||||
} else {
|
arp.active -> {
|
||||||
arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
|
// A new line while still arpeggiating (the pattern looping back
|
||||||
// The arpeggiator is monophonic (it cycles octaves of ONE note),
|
// over a held chord): rebuild the chord from this line but keep
|
||||||
// so it must not stack notes. Releasing the bus first means a
|
// the running phase, so a looping chord doesn't hard-restart or
|
||||||
// chord entered on the same line collapses to a single
|
// slip when the loop length isn't a multiple of the arp step.
|
||||||
// arpeggiated note (the last one), not all of them at once.
|
arp.resetChord(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||||
releaseBus(bus)
|
arp.genStamp = lineTriggerGen
|
||||||
playNote(proj, bus, arp.currentNote(), velocity)
|
}
|
||||||
|
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 {
|
} else {
|
||||||
arp.stop()
|
arp.stop()
|
||||||
playNote(proj, bus, note, velocity)
|
playNote(proj, bus, note, velocity, ownerTrack = trackKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Release (fade out) every still-*held* sequencer voice started by tracker
|
||||||
|
* [trackKey] — used to make a track monophonic: its previous note is released when
|
||||||
|
* it plays a new one. Only gated voices are touched (release tails ring out), and
|
||||||
|
* matching by owner means sibling tracks' notes on a shared bus are left alone.
|
||||||
|
* Array loops allocate no iterator — safe on the audio thread. */
|
||||||
|
private fun releaseTrackVoices(trackKey: Int) {
|
||||||
|
for (v in seqVoices) if (v.ownerTrack == trackKey && v.isGated) v.noteOff()
|
||||||
|
for (v in sampleVoices) if (v.ownerTrack == trackKey && v.isGated) v.noteOff()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sound a note on a mixer [bus]: allocate a [SampleVoice] from the bus pool when
|
* Sound a note on a mixer [bus]: allocate a [SampleVoice] from the bus pool when
|
||||||
* the channel's instrument is a Sampler/SoundFont with a loaded sample, otherwise
|
* the channel's instrument is a Sampler/SoundFont with a loaded sample, otherwise
|
||||||
* a [SynthVoice] for the NES synth. Pooled allocation (up to [POLYPHONY] per bus,
|
* a [SynthVoice] for the NES synth. Pooled allocation (up to [POLYPHONY] per bus,
|
||||||
* stealing the oldest when full) is what makes the instrument polyphonic. Shared
|
* stealing the oldest when full) is what makes the instrument polyphonic. Shared
|
||||||
* by the sequencer and the arpeggiator.
|
* by the sequencer and the arpeggiator.
|
||||||
|
*
|
||||||
|
* [ownerTrack] tags the allocated voice with the tracker (lane,track) that started
|
||||||
|
* it (or -1 for arp/unowned notes), so [releaseTrackVoices] can later release just
|
||||||
|
* this track's note — see [triggerCell].
|
||||||
*/
|
*/
|
||||||
private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int) {
|
private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int, ownerTrack: Int = -1) {
|
||||||
// The allocators ([allocSynthVoice]/[allocSampleVoice]) queue the chosen voice
|
// The allocators ([allocSynthVoice]/[allocSampleVoice]) queue the chosen voice
|
||||||
// into this block's active list, so a note started mid-block sounds sample-
|
// into this block's active list, so a note started mid-block sounds sample-
|
||||||
// accurately within the same block.
|
// accurately within the same block.
|
||||||
@@ -814,14 +1006,16 @@ class AudioEngine(
|
|||||||
val vs = voiceSample(slot, note)
|
val vs = voiceSample(slot, note)
|
||||||
when {
|
when {
|
||||||
vs != null ->
|
vs != null ->
|
||||||
allocSampleVoice(bus).noteOn(vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
|
allocSampleVoice(bus).also { it.ownerTrack = ownerTrack }.noteOn(
|
||||||
|
vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
|
||||||
vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs)
|
vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs)
|
||||||
// Empty bus (no instrument — e.g. the default channel 0) or a Sampler
|
// Empty bus (no instrument — e.g. the default channel 0) or a Sampler
|
||||||
// with an empty pad → silent, matching the live audition path.
|
// with an empty pad → silent, matching the live audition path.
|
||||||
slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
|
slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
|
||||||
// NES synth, or a SoundFont with nothing loaded → the tone generator.
|
// NES synth, or a SoundFont with nothing loaded → the tone generator.
|
||||||
else -> {
|
else -> {
|
||||||
allocSynthVoice(bus).noteOn(note, velocity, synthPatch[bus] ?: patchForSlot(slot, proj.tempoBpm))
|
allocSynthVoice(bus).also { it.ownerTrack = ownerTrack }
|
||||||
|
.noteOn(note, velocity, synthPatch[bus] ?: patchForSlot(slot, proj.tempoBpm))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -917,17 +1111,22 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */
|
/** Per-(lane,track) arpeggiator: cycles through the held chord's notes across the
|
||||||
private class ChannelArp {
|
* octave range. Notes are kept sorted ascending; the linear step sequence is
|
||||||
var active = false; var base = -1; var velocity = 100
|
* 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
|
private var octaves = 1; private var mode = 0
|
||||||
var stepSamples = 0.0; var samplesLeft = 0.0
|
var stepSamples = 0.0; var samplesLeft = 0.0
|
||||||
private var step = 0; private var dir = 1
|
private var step = 0; private var dir = 1
|
||||||
/** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */
|
|
||||||
var resetOnBar = false
|
var resetOnBar = false
|
||||||
// Set by [resetToBar] so the next fire plays step 0 without first advancing.
|
var genStamp = -1
|
||||||
private var pendingReset = false
|
private var pendingReset = false
|
||||||
// Per-arp lock-free RNG (Random mode) — no Math.random() on the audio thread.
|
|
||||||
private var rng = 0x2545F491.toInt()
|
private var rng = 0x2545F491.toInt()
|
||||||
private fun rnd(bound: Int): Int {
|
private fun rnd(bound: Int): Int {
|
||||||
var s = rng
|
var s = rng
|
||||||
@@ -936,16 +1135,37 @@ class AudioEngine(
|
|||||||
return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt()
|
return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
/** Active while it holds at least one note. */
|
||||||
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4)
|
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)
|
mode = modeOf(modeStr)
|
||||||
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
|
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
|
||||||
resetOnBar = resetBar; pendingReset = false
|
resetOnBar = resetBar; pendingReset = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update params WITHOUT resetting the running phase — used when the pattern
|
/** Replace the chord with [note] (the next same-line notes re-accumulate) while
|
||||||
* loops over a still-arpeggiating note so the steady pulse continues
|
* KEEPING the running phase — used when the pattern loops back over a held
|
||||||
* seamlessly across the loop boundary. */
|
* 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) {
|
fun refresh(octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
|
||||||
octaves = octs.coerceIn(1, 4)
|
octaves = octs.coerceIn(1, 4)
|
||||||
mode = modeOf(modeStr)
|
mode = modeOf(modeStr)
|
||||||
@@ -953,31 +1173,38 @@ class AudioEngine(
|
|||||||
resetOnBar = resetBar
|
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 }
|
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 }
|
fun consumePendingReset(): Boolean { val p = pendingReset; pendingReset = false; return p }
|
||||||
|
|
||||||
private fun modeOf(modeStr: String): Int =
|
private fun modeOf(modeStr: String): Int =
|
||||||
when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
|
when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
|
||||||
|
|
||||||
fun stop() { active = false; base = -1 }
|
fun stop() { noteCount = 0; genStamp = -1 }
|
||||||
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
|
|
||||||
|
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() {
|
fun advance() {
|
||||||
|
val total = total()
|
||||||
when (mode) {
|
when (mode) {
|
||||||
1 -> step = (step - 1 + octaves) % octaves
|
1 -> step = (step - 1 + total) % total
|
||||||
2 -> if (octaves > 1) {
|
2 -> if (total > 1) {
|
||||||
step += dir
|
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 }
|
else if (step <= 0) { step = 0; dir = 1 }
|
||||||
}
|
}
|
||||||
3 -> step = if (octaves > 1) rnd(octaves) else 0
|
3 -> step = if (total > 1) rnd(total) else 0
|
||||||
else -> step = (step + 1) % octaves
|
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. */
|
/** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
|
||||||
@@ -1016,10 +1243,15 @@ class AudioEngine(
|
|||||||
v.noteOn(midi, 110, patchForSlot(slot, project?.tempoBpm ?: 120f))
|
v.noteOn(midi, 110, patchForSlot(slot, project?.tempoBpm ?: 120f))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release an auditioned note (matches both synth and sample audition pools). */
|
/** Release an auditioned note (matches both synth and sample audition pools).
|
||||||
|
* Releases a *held* (gated) voice of this pitch in preference to one that's already
|
||||||
|
* ringing out its release tail — otherwise rapid re-tapping / chord-tapping the same
|
||||||
|
* key lands the note-off on the releasing voice and leaves the held one stuck. */
|
||||||
fun auditionSlotOff(midi: Int) {
|
fun auditionSlotOff(midi: Int) {
|
||||||
liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
(liveVoices.firstOrNull { it.pitch == midi && it.isGated }
|
||||||
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
?: liveVoices.firstOrNull { it.isActive && it.pitch == midi })?.noteOff()
|
||||||
|
(auditionSampleVoices.firstOrNull { it.pitch == midi && it.isGated }
|
||||||
|
?: auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi })?.noteOff()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------- live mix-bus audition (MIDI piano)
|
// ---------------------------------------------- live mix-bus audition (MIDI piano)
|
||||||
@@ -1121,6 +1353,7 @@ class AudioEngine(
|
|||||||
private fun triggerArrangementLine(
|
private fun triggerArrangementLine(
|
||||||
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int,
|
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
|
// 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
|
// never cancels a note that begins on the same line/bus (a note-off releases
|
||||||
// the whole bus). See [triggerPatternLine].
|
// the whole bus). See [triggerPatternLine].
|
||||||
@@ -1197,6 +1430,45 @@ class AudioEngine(
|
|||||||
seqVoices.forEach { it.noteOff() }
|
seqVoices.forEach { it.noteOff() }
|
||||||
sampleVoices.forEach { it.noteOff() }
|
sampleVoices.forEach { it.noteOff() }
|
||||||
arps.forEach { it.stop() }
|
arps.forEach { it.stop() }
|
||||||
|
allMidiNotesOff() // release any notes we sent to external gear
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- outgoing MIDI
|
||||||
|
/** Enqueue one MIDI message for the send thread (audio-thread producer, no alloc). */
|
||||||
|
private fun emitMidi(status: Int, d1: Int, d2: Int) {
|
||||||
|
if (!midiOutEnabled) return
|
||||||
|
val h = moutHead
|
||||||
|
val next = (h + 1) and (moutRing.size - 1)
|
||||||
|
if (next == moutTail) return // ring full — drop rather than block the audio thread
|
||||||
|
moutRing[h] = (status shl 16) or ((d1 and 0x7F) shl 8) or (d2 and 0x7F)
|
||||||
|
moutHead = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a note-on for [note] on app channel [ch] (1..16 → MIDI wire 0..15). */
|
||||||
|
private fun emitNoteOn(ch: Int, note: Int, vel: Int) {
|
||||||
|
if (ch < 1) return
|
||||||
|
emitMidi(0x90 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), vel.coerceIn(1, 127))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitNoteOff(ch: Int, note: Int) {
|
||||||
|
if (ch < 1) return
|
||||||
|
emitMidi(0x80 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release every note we've sent out (on transport stop / pause / panic) so an
|
||||||
|
* external instrument never hangs after Sizzle stops. */
|
||||||
|
private fun allMidiNotesOff() {
|
||||||
|
for (k in moutNote.indices) if (moutNote[k] >= 0) { emitNoteOff(moutCh[k], moutNote[k]); moutNote[k] = -1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drain one queued outgoing MIDI message, or -1 if the queue is empty. Called by
|
||||||
|
* the MIDI clock thread (single consumer). */
|
||||||
|
fun pollMidiOut(): Int {
|
||||||
|
val t = moutTail
|
||||||
|
if (t == moutHead) return -1
|
||||||
|
val v = moutRing[t]
|
||||||
|
moutTail = (t + 1) and (moutRing.size - 1)
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
@@ -1225,11 +1497,26 @@ class AudioEngine(
|
|||||||
private const val LIVE_POLYPHONY = 8
|
private const val LIVE_POLYPHONY = 8
|
||||||
private const val AUDITION_POLYPHONY = 8
|
private const val AUDITION_POLYPHONY = 8
|
||||||
private const val BUS_POLYPHONY = 8 // per-mixer-channel live audition
|
private const val BUS_POLYPHONY = 8 // per-mixer-channel live audition
|
||||||
// Master make-up before the soft-clip limiter. Raised from 0.35 → 0.5 (~+3 dB)
|
// Master make-up gain into the look-ahead brickwall limiter ([masterLimit]).
|
||||||
// so playback sits closer to other media apps (it had noticeably more headroom
|
// The old path was a flat 0.5× attenuation into a static soft-clip, which left
|
||||||
// than typical music playback); the soft-knee limiter below still catches peaks.
|
// ~9 dB of unused headroom: even the loudest single preset with its mix bus
|
||||||
private const val MASTER_GAIN = 0.5f
|
// maxed peaked around -7 dBFS, well below other media playback. This pushes the
|
||||||
|
// signal UP (≈ +9.5 dB vs the old 0.5) so a single instrument reaches close to
|
||||||
|
// full scale, while the limiter guarantees the ceiling is never exceeded on
|
||||||
|
// busy passages — loudness without clipping. Tunable: raise for even louder /
|
||||||
|
// denser output, lower to trade loudness back for dynamics.
|
||||||
|
private const val MASTER_MAKEUP = 1.5f
|
||||||
|
// Brickwall limiter tuning (see [masterLimit]).
|
||||||
|
private const val LIM_CEILING = 0.97f // hard output ceiling (~-0.26 dBFS)
|
||||||
|
private const val LIM_LOOKAHEAD = 64 // delay/look-ahead in samples (~1.3 ms @ 48 kHz)
|
||||||
|
private const val LIM_HOLD_EXTRA = 8 // extra hold beyond the window so the peak fully clears
|
||||||
|
private const val LIM_RELEASE = 0.00025f // per-sample rise toward unity (~80 ms release @ 48 kHz)
|
||||||
private const val LIVE_GAIN = 0.5f
|
private const val LIVE_GAIN = 0.5f
|
||||||
|
// Mixer VU ballistics (per ~4 ms block). Decay ≈ falls to ~5% over ~300 ms;
|
||||||
|
// clip latches red for ~0.8 s so a brief overload is noticeable.
|
||||||
|
private const val METER_DECAY = 0.96f
|
||||||
|
private const val METER_CLIP_LEVEL = 0.999f // bus peak at/above 0 dBFS = clipping
|
||||||
|
private const val METER_CLIP_HOLD_BLOCKS = 200 // ~0.8 s at 192-frame blocks / 48 kHz
|
||||||
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
|
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package space.rcmd.android.sizzle.audio
|
package space.rcmd.android.sizzle.audio
|
||||||
|
|
||||||
import space.rcmd.android.sizzle.model.DelayDivisions
|
import space.rcmd.android.sizzle.model.DelayDivisions
|
||||||
|
import space.rcmd.android.sizzle.model.TimeDivision
|
||||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||||
import space.rcmd.android.sizzle.model.ToolboxType
|
import space.rcmd.android.sizzle.model.ToolboxType
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@@ -42,11 +43,27 @@ interface AudioEffect {
|
|||||||
ToolboxType.BITCRUSHER -> Bitcrusher()
|
ToolboxType.BITCRUSHER -> Bitcrusher()
|
||||||
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
ToolboxType.EQ10 -> GraphicEq(sampleRate)
|
||||||
ToolboxType.MIX_TIGHTENER -> MixTightener(sampleRate)
|
ToolboxType.MIX_TIGHTENER -> MixTightener(sampleRate)
|
||||||
|
ToolboxType.TAPE_ENGINE -> TapeEngine(sampleRate)
|
||||||
|
ToolboxType.GRANULAR -> GranularGlitch(sampleRate)
|
||||||
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
|
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An effect that publishes a small visualization snapshot for its editor to draw.
|
||||||
|
* The audio thread writes into plain fields/arrays; the UI reads them at frame rate
|
||||||
|
* (a stale value for one frame is harmless, so no locking is used). Read only through
|
||||||
|
* the engine, which hands back the live processor for the edited slot.
|
||||||
|
*/
|
||||||
|
interface VizEffect {
|
||||||
|
/** Copy the most recent scope samples (oldest → newest) into [out]; return the
|
||||||
|
* count written (0 if nothing yet). */
|
||||||
|
fun copyScope(out: FloatArray): Int
|
||||||
|
/** Current read/playhead position within the scope window, 0 (oldest) … 1 (newest). */
|
||||||
|
fun playhead(): Float
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Multi-tap tape delay: one shared delay line read by up to four independent
|
// Multi-tap tape delay: one shared delay line read by up to four independent
|
||||||
// "tape heads", each with its own tempo-synced division and on/off switch. A tape
|
// "tape heads", each with its own tempo-synced division and on/off switch. A tape
|
||||||
@@ -519,3 +536,195 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
|
|||||||
const val GAIN_SMOOTH = 0.05f // per-sample gain ramp toward the control-rate target
|
const val GAIN_SMOOTH = 0.05f // per-sample gain ramp toward the control-rate target
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tape Engine: a varispeed reel-to-reel. Audio is written into a ring buffer at
|
||||||
|
// rate 1 and read back at `speed` (< 1 = slower = lower pitch), interpolated — a
|
||||||
|
// classic tape brake / tape-stop. `speed` is driven by the editor's reel gesture
|
||||||
|
// (press to brake); the engine GLIDES toward it so pitch dives and recovers with
|
||||||
|
// tape-like inertia rather than jumping. wow (slow, tempo-synced) and flutter (fast)
|
||||||
|
// wobble the read rate; hiss adds constant tape noise. Fully wet — the whole signal
|
||||||
|
// runs through the tape — so at speed 1 / no wobble it's just a short constant delay.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private class TapeEngine(private val sampleRate: Int) : AudioEffect {
|
||||||
|
private val buf = FloatArray(sampleRate * 2) // 2 s ring (headroom for long brakes)
|
||||||
|
private var writePos = NOMINAL_DELAY
|
||||||
|
private var readPos = 0.0
|
||||||
|
private var targetSpeed = 1.0
|
||||||
|
private var curSpeed = 1.0
|
||||||
|
private var wowInc = 0.0; private var wowPhase = 0.0; private var wowDepth = 0f
|
||||||
|
private var flutInc = FLUTTER_HZ / sampleRate; private var flutPhase = 0.0; private var flutDepth = 0f
|
||||||
|
private var hiss = 0f
|
||||||
|
private var rng = 0x51ED2A19
|
||||||
|
|
||||||
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
|
targetSpeed = slot.float("speed", 1f).coerceIn(MIN_SPEED, 1f).toDouble()
|
||||||
|
wowDepth = slot.float("wow", 0.15f).coerceIn(0f, 1f) * MAX_WOW
|
||||||
|
flutDepth = slot.float("flutter", 0.15f).coerceIn(0f, 1f) * MAX_FLUT
|
||||||
|
hiss = slot.float("hiss", 0.10f).coerceIn(0f, 1f) * MAX_HISS
|
||||||
|
val secPerBeat = 60.0 / tempoBpm
|
||||||
|
val div = TimeDivision.fromIndex(slot.float("division", 2f).toInt())
|
||||||
|
val wowHz = 1.0 / (div.beatFraction * secPerBeat).coerceAtLeast(1e-4)
|
||||||
|
wowInc = wowHz / sampleRate
|
||||||
|
flutInc = FLUTTER_HZ / sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun process(x: Float): Float {
|
||||||
|
buf[writePos] = x
|
||||||
|
writePos++; if (writePos >= buf.size) writePos = 0
|
||||||
|
|
||||||
|
curSpeed += (targetSpeed - curSpeed) * SPEED_GLIDE // tape inertia
|
||||||
|
|
||||||
|
wowPhase += wowInc; if (wowPhase >= 1.0) wowPhase -= 1.0
|
||||||
|
flutPhase += flutInc; if (flutPhase >= 1.0) flutPhase -= 1.0
|
||||||
|
val wow = sin(2.0 * PI * wowPhase) * wowDepth
|
||||||
|
val flut = sin(2.0 * PI * flutPhase) * flutDepth
|
||||||
|
var rate = curSpeed * (1.0 + wow + flut)
|
||||||
|
if (rate < 0.01) rate = 0.01
|
||||||
|
|
||||||
|
val i0 = readPos.toInt() % buf.size
|
||||||
|
val i1 = (i0 + 1) % buf.size
|
||||||
|
val frac = (readPos - readPos.toInt()).toFloat()
|
||||||
|
var out = buf[i0] + (buf[i1] - buf[i0]) * frac
|
||||||
|
|
||||||
|
var rp = readPos + rate
|
||||||
|
if (rp >= buf.size) rp -= buf.size
|
||||||
|
// Keep the read head a valid distance behind the write head: never read the
|
||||||
|
// just-written/future region (min gap), and never lag past the buffer (max gap
|
||||||
|
// — a hard brake that would fall off snaps forward with a small glitch).
|
||||||
|
var gap = writePos - rp; if (gap < 0) gap += buf.size
|
||||||
|
val maxGap = buf.size - 2.0
|
||||||
|
if (gap < MIN_GAP) { rp = writePos - MIN_GAP; if (rp < 0) rp += buf.size }
|
||||||
|
else if (gap > maxGap) { rp = writePos - maxGap; if (rp < 0) rp += buf.size }
|
||||||
|
readPos = rp
|
||||||
|
|
||||||
|
if (hiss > 0f) out += nextRand() * hiss
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextRand(): Float {
|
||||||
|
var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
|
||||||
|
return s * 4.656613e-10f // -1 … 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NOMINAL_DELAY = 128 // baseline read latency (samples) for interp headroom
|
||||||
|
const val MIN_SPEED = 0.25f
|
||||||
|
const val MAX_WOW = 0.03f // ±3 % rate at full wow
|
||||||
|
const val MAX_FLUT = 0.008f // ±0.8 % rate at full flutter
|
||||||
|
const val MAX_HISS = 0.02f
|
||||||
|
const val FLUTTER_HZ = 11.0
|
||||||
|
const val SPEED_GLIDE = 0.00012f // per-sample glide (~0.15 s) toward target speed
|
||||||
|
const val MIN_GAP = 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Granular Glitch: records the incoming signal into a rolling buffer and, driven by
|
||||||
|
// Intensity, periodically "jumps" the read head back to a random earlier point and
|
||||||
|
// replays a short grain (stutter / repeat / octave glitch). Between grains it passes
|
||||||
|
// the signal through dry, so Intensity 0 is transparent and higher = more frequent,
|
||||||
|
// more mangled glitches. Publishes a decimated scope of the incoming sound plus the
|
||||||
|
// jumping read-head position ([VizEffect]) for the editor to draw.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private class GranularGlitch(private val sampleRate: Int) : AudioEffect, VizEffect {
|
||||||
|
private val buf = FloatArray(sampleRate) // 1 s history
|
||||||
|
private var writePos = 0
|
||||||
|
private var readPos = 0.0
|
||||||
|
private var grainLeft = 0
|
||||||
|
private var grainRate = 1.0
|
||||||
|
private var intensity = 0f
|
||||||
|
private var rng = 0x2F6E1DB7
|
||||||
|
|
||||||
|
// Visualization: a ring of decimated INPUT samples (the sound passing through) plus
|
||||||
|
// the normalized read-head position within that window.
|
||||||
|
private val scope = FloatArray(SCOPE_N)
|
||||||
|
private var scopeWrite = 0
|
||||||
|
private var decCount = 0
|
||||||
|
@Volatile private var playheadNorm = 1f
|
||||||
|
|
||||||
|
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
|
||||||
|
intensity = slot.float("intensity", 0.3f).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun process(x: Float): Float {
|
||||||
|
val wp = writePos
|
||||||
|
buf[wp] = x
|
||||||
|
writePos++; if (writePos >= buf.size) writePos = 0
|
||||||
|
|
||||||
|
val out: Float
|
||||||
|
if (grainLeft <= 0 && (intensity < 0.001f || !maybeStartGrain(wp))) {
|
||||||
|
// Dry passthrough — keep the read head trailing the write head so the next
|
||||||
|
// grain can start from "now" and jump back from there.
|
||||||
|
readPos = wp.toDouble()
|
||||||
|
out = x
|
||||||
|
} else {
|
||||||
|
val i0 = readPos.toInt() % buf.size
|
||||||
|
val i1 = (i0 + 1) % buf.size
|
||||||
|
val frac = (readPos - readPos.toInt()).toFloat()
|
||||||
|
out = buf[i0] + (buf[i1] - buf[i0]) * frac
|
||||||
|
var rp = readPos + grainRate
|
||||||
|
if (rp >= buf.size) rp -= buf.size
|
||||||
|
if (rp < 0) rp += buf.size
|
||||||
|
var gap = wp - rp; if (gap < 0) gap += buf.size
|
||||||
|
if (gap < 2.0) { rp = wp - 2.0; if (rp < 0) rp += buf.size } // don't read the future
|
||||||
|
readPos = rp
|
||||||
|
grainLeft--
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope: decimate the incoming signal into the ring.
|
||||||
|
if (++decCount >= SCOPE_DECIM) {
|
||||||
|
decCount = 0
|
||||||
|
scope[scopeWrite] = x
|
||||||
|
scopeWrite++; if (scopeWrite >= SCOPE_N) scopeWrite = 0
|
||||||
|
}
|
||||||
|
// Playhead: where the read head sits within the displayed window (0 oldest … 1 now).
|
||||||
|
var behind = wp - readPos; if (behind < 0) behind += buf.size
|
||||||
|
playheadNorm = (1f - behind.toFloat() / (SCOPE_N * SCOPE_DECIM)).coerceIn(0f, 1f)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maybe (probability scales with Intensity) start a new grain, jumping the read
|
||||||
|
* head back to a random earlier point. Returns true if a grain was started. */
|
||||||
|
private fun maybeStartGrain(wp: Int): Boolean {
|
||||||
|
if (nextUnit() > intensity * intensity * MAX_TRIGGER) return false
|
||||||
|
val minJ = MIN_JUMP_S * sampleRate
|
||||||
|
val maxJ = (MIN_JUMP_S + intensity * (MAX_JUMP_S - MIN_JUMP_S)) * sampleRate
|
||||||
|
val jump = minJ + nextUnit() * (maxJ - minJ)
|
||||||
|
var rp = wp.toDouble() - jump
|
||||||
|
while (rp < 0) rp += buf.size
|
||||||
|
readPos = rp
|
||||||
|
val lenSec = GRAIN_MAX_S - intensity * (GRAIN_MAX_S - GRAIN_MIN_S)
|
||||||
|
grainLeft = (lenSec * sampleRate * (0.5f + nextUnit())).toInt().coerceAtLeast(64)
|
||||||
|
grainRate = when { // occasional octave glitch, likelier when hot
|
||||||
|
nextUnit() < intensity * 0.3f -> 2.0
|
||||||
|
nextUnit() < intensity * 0.2f -> 0.5
|
||||||
|
else -> 1.0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun copyScope(out: FloatArray): Int {
|
||||||
|
val n = if (out.size < SCOPE_N) out.size else SCOPE_N
|
||||||
|
val start = scopeWrite // oldest sample in the ring
|
||||||
|
for (k in 0 until n) out[k] = scope[(start + k) % SCOPE_N]
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playhead(): Float = playheadNorm
|
||||||
|
|
||||||
|
private fun nextUnit(): Float {
|
||||||
|
var s = rng; s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5); rng = s
|
||||||
|
return (s.toLong() and 0xFFFFFFFFL).toFloat() * 2.3283064e-10f // 0 … 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SCOPE_N = 96 // scope points drawn
|
||||||
|
const val SCOPE_DECIM = 96 // input samples per point → ~192 ms window @ 48 kHz
|
||||||
|
const val MAX_TRIGGER = 0.0009f // per-sample grain-start probability at full intensity
|
||||||
|
const val MIN_JUMP_S = 0.03f // shortest jump-back
|
||||||
|
const val MAX_JUMP_S = 0.5f // longest jump-back at full intensity
|
||||||
|
const val GRAIN_MIN_S = 0.02f // shortest grain (hot = stutter)
|
||||||
|
const val GRAIN_MAX_S = 0.15f // longest grain (gentle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class SampleVoice(private val engineSampleRate: Int) {
|
|||||||
private var endIndex = 0
|
private var endIndex = 0
|
||||||
private var amp = 0f
|
private var amp = 0f
|
||||||
var pitch = -1; private set
|
var pitch = -1; private set
|
||||||
|
/** Which tracker (lane,track) started this voice, or -1 if unowned. See
|
||||||
|
* [SynthVoice.ownerTrack]: lets the sequencer make a track mono without cutting
|
||||||
|
* other tracks' notes on the same bus. */
|
||||||
|
var ownerTrack = -1
|
||||||
|
|
||||||
// ---- sustain loop (SoundFont): wrap [loopStartIdx, loopEndIdx) while sounding,
|
// ---- sustain loop (SoundFont): wrap [loopStartIdx, loopEndIdx) while sounding,
|
||||||
// until a note-off's release envelope ends the voice. Uses the file's loop points
|
// until a note-off's release envelope ends the voice. Uses the file's loop points
|
||||||
@@ -64,6 +68,10 @@ class SampleVoice(private val engineSampleRate: Int) {
|
|||||||
private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay
|
private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay
|
||||||
|
|
||||||
val isActive: Boolean get() = sample != null
|
val isActive: Boolean get() = sample != null
|
||||||
|
/** Note is still *held* (not in its release stage). A voice ringing out its
|
||||||
|
* release tail is still [isActive]; distinguishing the two lets a note-off
|
||||||
|
* target the held voice instead of one already releasing — see [SynthVoice.isGated]. */
|
||||||
|
val isGated: Boolean get() = sample != null && phase != Phase.RELEASE
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param root MIDI note at which the sample plays at its natural pitch.
|
* @param root MIDI note at which the sample plays at its natural pitch.
|
||||||
@@ -139,7 +147,7 @@ class SampleVoice(private val engineSampleRate: Int) {
|
|||||||
phase = Phase.RELEASE
|
phase = Phase.RELEASE
|
||||||
}
|
}
|
||||||
|
|
||||||
fun kill() { sample = null; pitch = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f; loopActive = false }
|
fun kill() { sample = null; pitch = -1; ownerTrack = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f; loopActive = false }
|
||||||
|
|
||||||
/** One sample of the decaying declick offset (used on the paths that stop the
|
/** One sample of the decaying declick offset (used on the paths that stop the
|
||||||
* voice, so any carried level rings out smoothly instead of snapping to 0). */
|
* voice, so any carried level rings out smoothly instead of snapping to 0). */
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class SynthVoice(private val sampleRate: Int) {
|
|||||||
|
|
||||||
private var patch: SynthPatch = SynthPatch.DEFAULT
|
private var patch: SynthPatch = SynthPatch.DEFAULT
|
||||||
var pitch = -1; private set
|
var pitch = -1; private set
|
||||||
|
/** Which tracker (lane,track) started this voice, or -1 if unowned (live/arp).
|
||||||
|
* Lets the sequencer release a track's previous note when it plays a new one
|
||||||
|
* (mono-per-track) without touching notes other tracks hold on the same bus. */
|
||||||
|
var ownerTrack = -1
|
||||||
private var velocity = 0f
|
private var velocity = 0f
|
||||||
private var baseFreq = 440.0
|
private var baseFreq = 440.0
|
||||||
private var keyNorm = 0f // note position relative to C-4 (60), -1..1
|
private var keyNorm = 0f // note position relative to C-4 (60), -1..1
|
||||||
@@ -55,6 +59,11 @@ class SynthVoice(private val sampleRate: Int) {
|
|||||||
private val filtEnv = Adsr()
|
private val filtEnv = Adsr()
|
||||||
|
|
||||||
val isActive: Boolean get() = ampEnv.active
|
val isActive: Boolean get() = ampEnv.active
|
||||||
|
/** Gate is still open (attack/decay/sustain) — the note is *held*. A voice in its
|
||||||
|
* release tail is still [isActive] but no longer gated; distinguishing the two
|
||||||
|
* lets a note-off target the held voice instead of one that's already ringing
|
||||||
|
* out (which otherwise leaves the held note stuck under rapid re-tapping). */
|
||||||
|
val isGated: Boolean get() = ampEnv.gateOpen
|
||||||
|
|
||||||
/** Start (or steal-and-restart) this voice with the given [patch]. */
|
/** Start (or steal-and-restart) this voice with the given [patch]. */
|
||||||
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
|
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
|
||||||
@@ -79,6 +88,7 @@ class SynthVoice(private val sampleRate: Int) {
|
|||||||
fun kill() {
|
fun kill() {
|
||||||
ampEnv.kill(); filtEnv.kill()
|
ampEnv.kill(); filtEnv.kill()
|
||||||
pitch = -1
|
pitch = -1
|
||||||
|
ownerTrack = -1
|
||||||
icB = 0f; icL = 0f
|
icB = 0f; icL = 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +264,8 @@ class SynthVoice(private val sampleRate: Int) {
|
|||||||
private var level = 0f
|
private var level = 0f
|
||||||
private var a = 0.01f; private var d = 0.1f; private var s = 0.7f; private var r = 0.2f
|
private var a = 0.01f; private var d = 0.1f; private var s = 0.7f; private var r = 0.2f
|
||||||
val active: Boolean get() = stage != 0
|
val active: Boolean get() = stage != 0
|
||||||
|
/** Attack/decay/sustain — i.e. gate on, not yet released (stage 4) or idle (0). */
|
||||||
|
val gateOpen: Boolean get() = stage in 1..3
|
||||||
|
|
||||||
fun gateOn(aa: Float, dd: Float, ss: Float, rr: Float) {
|
fun gateOn(aa: Float, dd: Float, ss: Float, rr: Float) {
|
||||||
a = aa.coerceAtLeast(0.0005f); d = dd; s = ss.coerceIn(0f, 1f); r = rr
|
a = aa.coerceAtLeast(0.0005f); d = dd; s = ss.coerceIn(0f, 1f); r = rr
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ sealed interface InputAction {
|
|||||||
data object NoteOffCell : InputAction
|
data object NoteOffCell : InputAction
|
||||||
|
|
||||||
// ----- Live note entry (also used to audition instruments) -----
|
// ----- Live note entry (also used to audition instruments) -----
|
||||||
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction
|
// [channel] is the 1-based MIDI channel (1..16) for MIDI input, so notes can be
|
||||||
data class NoteOff(val pitch: Int) : InputAction
|
// routed to the mixer track(s) listening on it; it is -1 when the source has no
|
||||||
|
// channel of its own (physical / on-screen keyboard), which falls back to the
|
||||||
|
// on-screen keyboard's channel.
|
||||||
|
data class NoteOn(val pitch: Int, val velocity: Int, val channel: Int = -1) : InputAction
|
||||||
|
data class NoteOff(val pitch: Int, val channel: Int = -1) : InputAction
|
||||||
|
|
||||||
// ----- Transport -----
|
// ----- Transport -----
|
||||||
data object PlayPause : InputAction
|
data object PlayPause : InputAction
|
||||||
|
|||||||
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,17 +70,23 @@ class MidiInput(
|
|||||||
while (i < end) {
|
while (i < end) {
|
||||||
val status = data[i].toInt() and 0xFF
|
val status = data[i].toInt() and 0xFF
|
||||||
if (status < 0x80) { i++; continue } // skip stray data bytes
|
if (status < 0x80) { i++; continue } // skip stray data bytes
|
||||||
|
// System Real-Time (0xF8..0xFF) are single-byte and may be interleaved
|
||||||
|
// anywhere; forward them (clock / start / stop) and move on one byte.
|
||||||
|
if (status >= 0xF8) { onRealtime?.invoke(status); i++; continue }
|
||||||
val type = status and 0xF0
|
val type = status and 0xF0
|
||||||
|
// Low nibble is the MIDI channel (0..15); expose it 1-based (1..16) so
|
||||||
|
// it matches the mixer channels' `midiChannel` for routing.
|
||||||
|
val channel = (status and 0x0F) + 1
|
||||||
when (type) {
|
when (type) {
|
||||||
0x90 -> { // Note On
|
0x90 -> { // Note On
|
||||||
val note = data.getOrZero(i + 1)
|
val note = data.getOrZero(i + 1)
|
||||||
val vel = data.getOrZero(i + 2)
|
val vel = data.getOrZero(i + 2)
|
||||||
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
|
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel, channel))
|
||||||
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
|
else router.dispatch(InputAction.NoteOff(note, channel)) // vel 0 == note off
|
||||||
i += 3
|
i += 3
|
||||||
}
|
}
|
||||||
0x80 -> { // Note Off
|
0x80 -> { // Note Off
|
||||||
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
|
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1), channel)); i += 3
|
||||||
}
|
}
|
||||||
0xB0 -> { // Control Change
|
0xB0 -> { // Control Change
|
||||||
val cc = data.getOrZero(i + 1)
|
val cc = data.getOrZero(i + 1)
|
||||||
|
|||||||
107
app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt
Normal file
107
app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package space.rcmd.android.sizzle.io
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole-app backup: a single ZIP of everything the app persists under its private
|
||||||
|
* files dir — saved songs, themes, instrument/effect presets (with their sample
|
||||||
|
* WAVs), the autosaved project, and the DataStore blobs holding settings + input
|
||||||
|
* bindings. Restore extracts into a staging folder first and only swaps it into
|
||||||
|
* place once the archive looks valid, so a corrupt or wrong file can never destroy
|
||||||
|
* the current data. Because the DataStore prefs are cached in memory while the app
|
||||||
|
* runs, a restore only takes full effect after the app is restarted.
|
||||||
|
*/
|
||||||
|
object BackupIo {
|
||||||
|
// Top-level content areas under filesDir. "datastore" holds the settings +
|
||||||
|
// bindings Preferences files (see SettingsStore / BindingStore).
|
||||||
|
private val CONTENT_DIRS = listOf("songs", "themes", "presets", "datastore")
|
||||||
|
private const val AUTOSAVE = "autosave.sng"
|
||||||
|
private const val STAGING = ".restore_tmp"
|
||||||
|
|
||||||
|
/** Write a full backup of [filesDir] to [out] as a ZIP. */
|
||||||
|
fun write(filesDir: File, out: OutputStream) {
|
||||||
|
ZipOutputStream(BufferedOutputStream(out)).use { zip ->
|
||||||
|
for (dir in CONTENT_DIRS) addTree(zip, File(filesDir, dir), dir)
|
||||||
|
File(filesDir, AUTOSAVE).takeIf { it.isFile }?.let { addFile(zip, it, AUTOSAVE) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a backup ZIP from [input] into [filesDir], replacing the current data.
|
||||||
|
* Returns false (leaving current data untouched) if the archive is empty or holds
|
||||||
|
* none of the expected areas. Settings/bindings apply after an app restart.
|
||||||
|
*/
|
||||||
|
fun read(filesDir: File, input: InputStream): Boolean {
|
||||||
|
val staging = File(filesDir, STAGING)
|
||||||
|
staging.deleteRecursively(); staging.mkdirs()
|
||||||
|
val stagingRoot = staging.canonicalPath + File.separator
|
||||||
|
|
||||||
|
var any = false
|
||||||
|
try {
|
||||||
|
ZipInputStream(BufferedInputStream(input)).use { zin ->
|
||||||
|
var entry: ZipEntry? = zin.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val name = entry.name
|
||||||
|
if (!entry.isDirectory && !name.contains("..")) {
|
||||||
|
val target = File(staging, name)
|
||||||
|
// Zip-slip guard: the resolved path must stay inside staging.
|
||||||
|
if (target.canonicalPath.startsWith(stagingRoot)) {
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
target.outputStream().use { zin.copyTo(it) }
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zin.closeEntry()
|
||||||
|
entry = zin.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// A truncated / non-ZIP file: bail out without touching the live data.
|
||||||
|
staging.deleteRecursively(); return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val looksValid = any &&
|
||||||
|
(CONTENT_DIRS.any { File(staging, it).exists() } || File(staging, AUTOSAVE).isFile)
|
||||||
|
if (!looksValid) { staging.deleteRecursively(); return false }
|
||||||
|
|
||||||
|
// Swap each restored area into place (delete the live one first so a rename
|
||||||
|
// over it succeeds; fall back to a copy if rename can't cross the boundary).
|
||||||
|
for (dir in CONTENT_DIRS) {
|
||||||
|
val src = File(staging, dir)
|
||||||
|
if (!src.exists()) continue
|
||||||
|
val dst = File(filesDir, dir)
|
||||||
|
dst.deleteRecursively()
|
||||||
|
if (!src.renameTo(dst)) src.copyRecursively(dst, overwrite = true)
|
||||||
|
}
|
||||||
|
File(staging, AUTOSAVE).takeIf { it.isFile }?.let { src ->
|
||||||
|
val dst = File(filesDir, AUTOSAVE)
|
||||||
|
dst.delete()
|
||||||
|
if (!src.renameTo(dst)) src.copyTo(dst, overwrite = true)
|
||||||
|
}
|
||||||
|
staging.deleteRecursively()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addTree(zip: ZipOutputStream, dir: File, prefix: String) {
|
||||||
|
if (!dir.isDirectory) return
|
||||||
|
dir.walkTopDown().filter { it.isFile }.forEach { f ->
|
||||||
|
addFile(zip, f, prefix + "/" + f.relativeTo(dir).path.replace(File.separatorChar, '/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addFile(zip: ZipOutputStream, f: File, entryName: String) {
|
||||||
|
zip.putNextEntry(ZipEntry(entryName))
|
||||||
|
f.inputStream().use { it.copyTo(zip) }
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package space.rcmd.android.sizzle.io
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
@@ -26,6 +27,12 @@ data class AppSettings(
|
|||||||
val kbdChannel: Int = 0,
|
val kbdChannel: Int = 0,
|
||||||
/** Base octave for the on-screen keyboards. */
|
/** Base octave for the on-screen keyboards. */
|
||||||
val kbdOctave: Int = 3,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +54,9 @@ class SettingsStore(private val context: Context) {
|
|||||||
recordTailBars = prefs[RECORD_TAIL] ?: 2,
|
recordTailBars = prefs[RECORD_TAIL] ?: 2,
|
||||||
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,
|
||||||
|
sendMidiClock = prefs[MIDI_CLOCK_SEND] ?: false,
|
||||||
|
receiveMidiClock = prefs[MIDI_CLOCK_RECV] ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +83,27 @@ class SettingsStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist whether the mixer's fader VU meters are shown. */
|
||||||
|
suspend fun saveVuMeters(enabled: Boolean) {
|
||||||
|
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")
|
||||||
val RECORD_TAIL = intPreferencesKey("recordTailBars")
|
val RECORD_TAIL = intPreferencesKey("recordTailBars")
|
||||||
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 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,30 @@ enum class ToolboxType(
|
|||||||
// (see MixTightenerPresets); only Strength is user-facing. Rendered by
|
// (see MixTightenerPresets); only Strength is user-facing. Rendered by
|
||||||
// MixTightener in Effects.kt, edited by ui/toolbox/MixTightenerEditor.
|
// MixTightener in Effects.kt, edited by ui/toolbox/MixTightenerEditor.
|
||||||
listOf(ParamSpec("strength", "Strength", 1f, 0f, 1f)),
|
listOf(ParamSpec("strength", "Strength", 1f, 0f, 1f)),
|
||||||
|
),
|
||||||
|
TAPE_ENGINE(
|
||||||
|
ToolboxKind.EFFECT, "Tape Engine",
|
||||||
|
// A reel-to-reel varispeed tape: press a reel in the editor to "brake" it and
|
||||||
|
// the pitch dives like a slowing tape (the 'speed' param is driven by that reel
|
||||||
|
// gesture, not a slider). wow/flutter add slow + fast pitch wobble, hiss adds
|
||||||
|
// tape noise, and the wow wobble rate is tempo-synced via 'division'. Rendered
|
||||||
|
// by TapeEngine in Effects.kt, edited by ui/toolbox/TapeEngineEditor.
|
||||||
|
listOf(
|
||||||
|
ParamSpec("speed", "Speed", 1f, 0.25f, 1f),
|
||||||
|
ParamSpec("wow", "Wow", 0.15f, 0f, 1f),
|
||||||
|
ParamSpec("flutter", "Flutter", 0.15f, 0f, 1f),
|
||||||
|
ParamSpec("hiss", "Hiss", 0.10f, 0f, 1f),
|
||||||
|
ParamSpec("division", "Wow Sync", 2f, 0f, 6f, isInt = true), // index into TimeDivision
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GRANULAR(
|
||||||
|
ToolboxKind.EFFECT, "Granular Glitch",
|
||||||
|
// Stutters/repeats short grains read from a rolling buffer of the incoming
|
||||||
|
// sound; the read head jumps around, and the editor draws it as a moving
|
||||||
|
// playhead over a dotted scope of the signal. One control: Intensity (how often
|
||||||
|
// it glitches + how mangled). Rendered by GranularGlitch in Effects.kt, edited
|
||||||
|
// by ui/toolbox/GranularEditor.
|
||||||
|
listOf(ParamSpec("intensity", "Intensity", 0.3f, 0f, 1f)),
|
||||||
);
|
);
|
||||||
|
|
||||||
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
|
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ import space.rcmd.android.sizzle.input.InputAction
|
|||||||
import space.rcmd.android.sizzle.input.InputRouter
|
import space.rcmd.android.sizzle.input.InputRouter
|
||||||
import space.rcmd.android.sizzle.model.Cell
|
import space.rcmd.android.sizzle.model.Cell
|
||||||
import space.rcmd.android.sizzle.model.CellColumn
|
import space.rcmd.android.sizzle.model.CellColumn
|
||||||
|
import space.rcmd.android.sizzle.model.ParamSpec
|
||||||
import space.rcmd.android.sizzle.model.Pattern
|
import space.rcmd.android.sizzle.model.Pattern
|
||||||
import space.rcmd.android.sizzle.model.Pitch
|
import space.rcmd.android.sizzle.model.Pitch
|
||||||
import space.rcmd.android.sizzle.model.Project
|
import space.rcmd.android.sizzle.model.Project
|
||||||
import space.rcmd.android.sizzle.model.TimeSignature
|
import space.rcmd.android.sizzle.model.TimeSignature
|
||||||
|
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -37,6 +41,14 @@ import kotlinx.coroutines.launch
|
|||||||
* performance, so after we mutate the grid we bump [revision]. Compose reads that
|
* performance, so after we mutate the grid we bump [revision]. Compose reads that
|
||||||
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
|
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
|
||||||
*/
|
*/
|
||||||
|
/** Tab index of the Tracker tab (see [App]'s tab order). */
|
||||||
|
private const val TAB_TRACKER = 0
|
||||||
|
|
||||||
|
/** Reserved prefix for a toolbox slot's MIDI-CC parameter bindings, stored in the
|
||||||
|
* slot's value map (`midicc@<paramKey>` -> CC number) so they persist with the
|
||||||
|
* project / preset like any other parameter and never collide with a real key. */
|
||||||
|
private const val CC_PREFIX = "midicc@"
|
||||||
|
|
||||||
class AppViewModel(
|
class AppViewModel(
|
||||||
val project: Project,
|
val project: Project,
|
||||||
private val engine: AudioEngine,
|
private val engine: AudioEngine,
|
||||||
@@ -49,6 +61,7 @@ class AppViewModel(
|
|||||||
private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary,
|
private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary,
|
||||||
private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore,
|
private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore,
|
||||||
private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary,
|
private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary,
|
||||||
|
private val midiClock: space.rcmd.android.sizzle.input.MidiClock,
|
||||||
private val appContext: android.content.Context,
|
private val appContext: android.content.Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -97,6 +110,35 @@ class AppViewModel(
|
|||||||
var kbdOctave by mutableIntStateOf(3); private set
|
var kbdOctave by mutableIntStateOf(3); private set
|
||||||
/** Velocity for keyboard notes (0..127). */
|
/** Velocity for keyboard notes (0..127). */
|
||||||
var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set
|
var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set
|
||||||
|
/** Whether the mixer's fader VU meters are shown (Settings toggle). */
|
||||||
|
var vuMeters by mutableStateOf(true); private set
|
||||||
|
|
||||||
|
/** Enable/disable the mixer fader VU meters and persist the choice. */
|
||||||
|
fun updateVuMeters(enabled: Boolean) {
|
||||||
|
vuMeters = enabled
|
||||||
|
viewModelScope.launch { settingsStore.saveVuMeters(enabled) }
|
||||||
|
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 }
|
||||||
@@ -126,6 +168,13 @@ class AppViewModel(
|
|||||||
var learnTarget by mutableStateOf<String?>(null); private set
|
var learnTarget by mutableStateOf<String?>(null); private set
|
||||||
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null
|
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null
|
||||||
|
|
||||||
|
// ----- Toolbox parameter MIDI-learn state (device editors) -----
|
||||||
|
/** The toolbox slot + parameter key currently waiting to capture a MIDI control,
|
||||||
|
* or (-1 / null) when nothing is arming. The device editor's MIDI-learn popup
|
||||||
|
* reads these to highlight the armed row. */
|
||||||
|
var paramLearnSlot by mutableIntStateOf(-1); private set
|
||||||
|
var paramLearnKey by mutableStateOf<String?>(null); private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Collect neutral input actions from every non-touch device.
|
// Collect neutral input actions from every non-touch device.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -176,15 +225,16 @@ class AppViewModel(
|
|||||||
cursorColumn = CellColumn.entries[flat % cols]
|
cursorColumn = CellColumn.entries[flat % cols]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Punch a note into the focused cell (velocity/channel from the keyboard) and
|
/** Punch a note into the focused cell and advance the cursor by [skipStep] rows,
|
||||||
* advance the cursor by [skipStep] rows, cycling past the pattern ends — the
|
* cycling past the pattern ends. Velocity/channel default to the on-screen
|
||||||
* touch-keyboard entry path. */
|
* keyboard's (the touch-keyboard entry path); MIDI punch-in passes the incoming
|
||||||
fun punchNote(midi: Int) {
|
* note's own velocity and channel so the recorded cell plays back correctly. */
|
||||||
|
fun punchNote(midi: Int, velocity: Int = kbdVelocity, channel: Int = kbdChannel) {
|
||||||
val cell = focusedCell()
|
val cell = focusedCell()
|
||||||
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY)
|
cell.velocity = velocity.coerceIn(0, Cell.MAX_VELOCITY)
|
||||||
cell.channel = kbdChannel
|
cell.channel = channel
|
||||||
lastNote = cell.note; lastChannel = kbdChannel
|
lastNote = cell.note; lastChannel = channel
|
||||||
moveCursorVerticalWrap(skipStep)
|
moveCursorVerticalWrap(skipStep)
|
||||||
touched()
|
touched()
|
||||||
}
|
}
|
||||||
@@ -212,6 +262,31 @@ class AppViewModel(
|
|||||||
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
||||||
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
|
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
|
||||||
|
|
||||||
|
/** True if the on-screen keyboard's MIDI channel routes to any bus whose instrument
|
||||||
|
* is a Sampler. A Sampler maps a pad to each MIDI note, so scale filtering would
|
||||||
|
* hide pads — the stacked keyboards go fully chromatic in that case. */
|
||||||
|
fun kbdRoutesToSampler(): Boolean {
|
||||||
|
for (ch in project.mixer.channels) {
|
||||||
|
if (ch.midiChannel != kbdChannel) continue
|
||||||
|
val slot = project.toolbox.getOrNull(ch.instrumentSlot) ?: continue
|
||||||
|
if (slot.type == space.rcmd.android.sizzle.model.ToolboxType.SAMPLER) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live VU level (peak, ~0..1+) for mixer bus [ch] — polled at frame rate by the
|
||||||
|
* mixer's fader VU meters. */
|
||||||
|
fun channelMeter(ch: Int): Float = engine.channelMeter(ch)
|
||||||
|
/** True while mixer bus [ch] is (or just was) clipping past 0 dBFS. */
|
||||||
|
fun channelClipped(ch: Int): Boolean = engine.channelClipped(ch)
|
||||||
|
|
||||||
|
/** Copy the live scope samples for the effect in toolbox [slotIndex] into [out];
|
||||||
|
* returns the count, or -1 if it isn't a routed, running visual effect. Polled at
|
||||||
|
* frame rate by the Granular editor's oscilloscope. */
|
||||||
|
fun effectScope(slotIndex: Int, out: FloatArray): Int = engine.effectScope(slotIndex, out)
|
||||||
|
/** The effect [slotIndex]'s jumping playhead position (0..1), or -1 if none live. */
|
||||||
|
fun effectPlayhead(slotIndex: Int): Float = engine.effectPlayhead(slotIndex)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The core value-editing operation, shared by drag gestures and +/- input.
|
* The core value-editing operation, shared by drag gestures and +/- input.
|
||||||
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
||||||
@@ -322,13 +397,42 @@ class AppViewModel(
|
|||||||
touched()
|
touched()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Arrangement selection (hoisted from ArrangementRoll so Play/Stop can read it).
|
||||||
|
// Rectangular selection anchor (…0) and live corner (…1); beat < 0 = no selection.
|
||||||
|
// Play starts from [arrSelBeat0]'s beat if set; Stop clears the selection so the
|
||||||
|
// highlight disappears and the next Play resumes from the top.
|
||||||
|
var arrSelLane0 by mutableIntStateOf(-1); private set
|
||||||
|
var arrSelBeat0 by mutableIntStateOf(-1); private set
|
||||||
|
var arrSelLane1 by mutableIntStateOf(-1); private set
|
||||||
|
var arrSelBeat1 by mutableIntStateOf(-1); private set
|
||||||
|
|
||||||
|
/** Set/update the arrangement selection rectangle. Pass all corners at once so the
|
||||||
|
* ArrangementRoll's tap / drag gestures update it atomically. */
|
||||||
|
fun setArrangementSelection(lane0: Int, beat0: Int, lane1: Int, beat1: Int) {
|
||||||
|
arrSelLane0 = lane0; arrSelBeat0 = beat0; arrSelLane1 = lane1; arrSelBeat1 = beat1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearArrangementSelection() {
|
||||||
|
arrSelLane0 = -1; arrSelBeat0 = -1; arrSelLane1 = -1; arrSelBeat1 = -1
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------- transport control
|
// ------------------------------------------------------- transport control
|
||||||
fun playPause() {
|
fun playPause() {
|
||||||
val t = transport.value
|
val t = transport.value
|
||||||
if (t.isPlaying) engine.pause() else engine.play()
|
if (t.isPlaying) {
|
||||||
|
engine.pause()
|
||||||
|
} else {
|
||||||
|
// Start from the earliest beat of the arrangement selection if there is
|
||||||
|
// one; otherwise from wherever the engine last was (top after a Stop).
|
||||||
|
val start = if (arrSelBeat0 >= 0) minOf(arrSelBeat0, arrSelBeat1) else -1
|
||||||
|
engine.play(start)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() = engine.stop()
|
fun stop() {
|
||||||
|
clearArrangementSelection() // resets the highlight so the next Play starts from the top
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
/** MIDI panic: silence all sounding/stuck notes immediately. */
|
/** MIDI panic: silence all sounding/stuck notes immediately. */
|
||||||
fun midiPanic() { heldNotes.clear(); engine.panic() }
|
fun midiPanic() { heldNotes.clear(); engine.panic() }
|
||||||
@@ -443,13 +547,22 @@ class AppViewModel(
|
|||||||
InputAction.NoteOffCell -> noteOffFocused()
|
InputAction.NoteOffCell -> noteOffFocused()
|
||||||
is InputAction.NoteOn -> {
|
is InputAction.NoteOn -> {
|
||||||
markNoteOn(action.pitch)
|
markNoteOn(action.pitch)
|
||||||
engine.liveNoteOn(action.pitch, action.velocity)
|
// MIDI notes route to the mixer track(s) listening on their channel;
|
||||||
// Live entry also writes the note into the focused NOTE cell.
|
// a channel-less source (physical keyboard) plays the default synth.
|
||||||
if (cursorColumn == CellColumn.NOTE) {
|
if (action.channel >= 1) engine.busNoteOn(action.channel, action.pitch, action.velocity)
|
||||||
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
|
else engine.liveNoteOn(action.pitch, action.velocity)
|
||||||
|
// On the Tracker tab with punch-in armed, also enter the note into the
|
||||||
|
// grid — recording its own velocity/channel so it plays back correctly.
|
||||||
|
if (selectedTab == TAB_TRACKER && punchInMode) {
|
||||||
|
val ch = if (action.channel >= 1) action.channel else kbdChannel
|
||||||
|
punchNote(action.pitch, action.velocity, ch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is InputAction.NoteOff -> { markNoteOff(action.pitch); engine.liveNoteOff(action.pitch) }
|
is InputAction.NoteOff -> {
|
||||||
|
markNoteOff(action.pitch)
|
||||||
|
if (action.channel >= 1) engine.busNoteOff(action.channel, action.pitch)
|
||||||
|
else engine.liveNoteOff(action.pitch)
|
||||||
|
}
|
||||||
InputAction.PlayPause -> playPause()
|
InputAction.PlayPause -> playPause()
|
||||||
InputAction.Stop -> stop()
|
InputAction.Stop -> stop()
|
||||||
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
|
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
|
||||||
@@ -477,11 +590,95 @@ class AppViewModel(
|
|||||||
router.learnMode = false
|
router.learnMode = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------- toolbox parameter MIDI-learn
|
||||||
|
private fun ccKey(paramKey: String) = "$CC_PREFIX$paramKey"
|
||||||
|
|
||||||
|
/** The CC number currently bound to [slotIndex]'s [paramKey], or null. */
|
||||||
|
fun paramCcFor(slotIndex: Int, paramKey: String): Int? =
|
||||||
|
project.toolbox.getOrNull(slotIndex)?.string(ccKey(paramKey))?.toIntOrNull()
|
||||||
|
|
||||||
|
/** Arm MIDI-learn for a toolbox parameter: the next MIDI control moved binds to it. */
|
||||||
|
fun beginParamLearn(slotIndex: Int, paramKey: String) {
|
||||||
|
cancelLearn() // don't clash with a Settings nav/transport learn
|
||||||
|
paramLearnSlot = slotIndex
|
||||||
|
paramLearnKey = paramKey
|
||||||
|
router.learnSource = space.rcmd.android.sizzle.input.InputSource.MIDI
|
||||||
|
router.learnMode = true
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelParamLearn() {
|
||||||
|
paramLearnSlot = -1
|
||||||
|
paramLearnKey = null
|
||||||
|
router.learnSource = null
|
||||||
|
router.learnMode = false
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the MIDI-CC binding for [slotIndex]'s [paramKey]. */
|
||||||
|
fun clearParamCc(slotIndex: Int, paramKey: String) {
|
||||||
|
project.toolbox.getOrNull(slotIndex)?.values?.remove(ccKey(paramKey))
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bind [cc] to [slotIndex]'s [paramKey]. A physical control drives one parameter,
|
||||||
|
* so the CC is first removed from any parameter it was previously bound to. */
|
||||||
|
private fun bindParamCc(slotIndex: Int, paramKey: String, cc: Int) {
|
||||||
|
for (s in project.toolbox) {
|
||||||
|
s.values.entries.removeAll { it.key.startsWith(CC_PREFIX) && it.value.toIntOrNull() == cc }
|
||||||
|
}
|
||||||
|
project.toolbox.getOrNull(slotIndex)?.set(ccKey(paramKey), cc.toString())
|
||||||
|
touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive every toolbox parameter bound to [cc] from a normalized ([0,1]) value. */
|
||||||
|
private fun applyParamCc(cc: Int, norm: Float) {
|
||||||
|
var changed = false
|
||||||
|
for (slot in project.toolbox) {
|
||||||
|
val type = slot.type ?: continue
|
||||||
|
for (spec in type.params) {
|
||||||
|
if (slot.values[ccKey(spec.key)]?.toIntOrNull() != cc) continue
|
||||||
|
applyNormToParam(slot, spec, norm)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) touched()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyNormToParam(slot: ToolboxSlot, spec: ParamSpec, norm: Float) {
|
||||||
|
val n = norm.coerceIn(0f, 1f)
|
||||||
|
if (spec.isEnum) {
|
||||||
|
val idx = (n * (spec.choices.size - 1)).roundToInt().coerceIn(0, spec.choices.lastIndex)
|
||||||
|
slot.set(spec.key, spec.choices[idx])
|
||||||
|
} else {
|
||||||
|
var v = spec.min + n * (spec.max - spec.min)
|
||||||
|
if (spec.isInt) v = v.roundToInt().toFloat()
|
||||||
|
slot.set(spec.key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A control arrived during learn. If it matches the armed source, store it as
|
/** A control arrived during learn. If it matches the armed source, store it as
|
||||||
* the binding for [learnTarget]. Each action keeps at most one binding, so any
|
* the binding for [learnTarget]. Each action keeps at most one binding, so any
|
||||||
* previous one for this action (on the same device) is replaced. */
|
* previous one for this action (on the same device) is replaced. */
|
||||||
private fun onRawControl(action: InputAction.RawControl) {
|
private fun onRawControl(action: InputAction.RawControl) {
|
||||||
val target = learnTarget ?: return
|
// Toolbox parameter MIDI-learn takes priority: the first MIDI control captured
|
||||||
|
// binds to the armed parameter.
|
||||||
|
val plKey = paramLearnKey
|
||||||
|
if (plKey != null) {
|
||||||
|
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
|
||||||
|
bindParamCc(paramLearnSlot, plKey, action.code)
|
||||||
|
cancelParamLearn()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val target = learnTarget
|
||||||
|
if (target == null) {
|
||||||
|
// Not learning: a MIDI CC bound to a toolbox parameter drives it live.
|
||||||
|
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
|
||||||
|
applyParamCc(action.code, action.value)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.source != learnSource) return
|
if (action.source != learnSource) return
|
||||||
when (action.source) {
|
when (action.source) {
|
||||||
space.rcmd.android.sizzle.input.InputSource.MIDI ->
|
space.rcmd.android.sizzle.input.InputSource.MIDI ->
|
||||||
@@ -774,6 +971,25 @@ class AppViewModel(
|
|||||||
// Restore the on-screen keyboard's channel + octave.
|
// Restore the on-screen keyboard's channel + octave.
|
||||||
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
|
||||||
|
|
||||||
|
// 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 = {
|
||||||
@@ -842,6 +1058,7 @@ class AppViewModel(
|
|||||||
fun loadProjectText(text: String) {
|
fun loadProjectText(text: String) {
|
||||||
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
|
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
|
||||||
cursorLine = 0; cursorTrack = 0
|
cursorLine = 0; cursorTrack = 0
|
||||||
|
clearArrangementSelection() // the old arrangement's selection is no longer meaningful
|
||||||
touched()
|
touched()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,6 +1130,70 @@ 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
|
||||||
|
/** Result of the last backup/restore, shown in a dialog; null when dismissed. */
|
||||||
|
var backupMessage by mutableStateOf<String?>(null); private set
|
||||||
|
fun dismissBackupMessage() { backupMessage = null }
|
||||||
|
|
||||||
|
/** Write a full backup (songs, presets, themes, autosave, settings + bindings) to
|
||||||
|
* the SAF [uri] the user picked. Runs off the main thread. */
|
||||||
|
fun backupTo(uri: android.net.Uri) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ok = runCatching {
|
||||||
|
appContext.contentResolver.openOutputStream(uri)?.use {
|
||||||
|
space.rcmd.android.sizzle.io.BackupIo.write(appContext.filesDir, it)
|
||||||
|
} != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
backupMessage = if (ok) "Backup saved." else "Couldn't write the backup."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore a full backup from the SAF [uri], replacing all current data. Settings
|
||||||
|
* and bindings apply after the app is restarted. Runs off the main thread. */
|
||||||
|
fun restoreFrom(uri: android.net.Uri) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ok = runCatching {
|
||||||
|
appContext.contentResolver.openInputStream(uri)?.use {
|
||||||
|
space.rcmd.android.sizzle.io.BackupIo.read(appContext.filesDir, it)
|
||||||
|
} ?: false
|
||||||
|
}.getOrDefault(false)
|
||||||
|
backupMessage = if (ok) {
|
||||||
|
"Backup restored. Close and reopen the app to finish applying it."
|
||||||
|
} else {
|
||||||
|
"Restore failed — that doesn't look like a Sizzletracker backup."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------ instrument/effect presets
|
// ------------------------------------------------------ instrument/effect presets
|
||||||
/** Preset names available for a device type (for the editor's browser dropdown). */
|
/** Preset names available for a device type (for the editor's browser dropdown). */
|
||||||
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =
|
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
package space.rcmd.android.sizzle.ui
|
package space.rcmd.android.sizzle.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -17,17 +14,13 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import space.rcmd.android.sizzle.model.Pitch
|
import space.rcmd.android.sizzle.model.Pitch
|
||||||
import space.rcmd.android.sizzle.ui.components.PianoKey
|
|
||||||
import space.rcmd.android.sizzle.ui.components.PianoOctave
|
import space.rcmd.android.sizzle.ui.components.PianoOctave
|
||||||
import space.rcmd.android.sizzle.ui.components.RepeatButton
|
import space.rcmd.android.sizzle.ui.components.RepeatButton
|
||||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||||
@@ -35,6 +28,9 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
|||||||
|
|
||||||
private const val VEL_STEP = 8
|
private const val VEL_STEP = 8
|
||||||
|
|
||||||
|
/** All twelve pitch classes — used when scale filtering is bypassed (Sampler routing). */
|
||||||
|
private val CHROMATIC: Set<Int> = (0..11).toSet()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stacked two-octave keyboard shared by the Toolbox (audition) and Tracker
|
* A stacked two-octave keyboard shared by the Toolbox (audition) and Tracker
|
||||||
* (punch-in) tabs. Its channel, octave range and velocity live on the
|
* (punch-in) tabs. Its channel, octave range and velocity live on the
|
||||||
@@ -47,14 +43,15 @@ private const val VEL_STEP = 8
|
|||||||
fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) {
|
fun StackedKeyboard(vm: AppViewModel, punchIn: Boolean, modifier: Modifier) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
|
||||||
// In punch-in mode only in-scale keys are playable (Chromatic yields all twelve);
|
// Only in-scale keys are playable on BOTH the tracker punch-in and the toolbox
|
||||||
// the toolbox audition keyboard stays fully chromatic (null = no filter).
|
// audition keyboard (Chromatic yields all twelve = every key stays enabled). But a
|
||||||
val inKey: Set<Int>? = if (punchIn) {
|
// Sampler maps a pad to each MIDI note, so scale filtering would hide pads — when
|
||||||
val root = vm.project.rootNote
|
// the keyboard's channel routes to a Sampler, go fully chromatic so every pad is
|
||||||
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
// reachable.
|
||||||
} else {
|
val root = vm.project.rootNote
|
||||||
null
|
val inKey: Set<Int> =
|
||||||
}
|
if (vm.kbdRoutesToSampler()) CHROMATIC
|
||||||
|
else vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
||||||
Column(
|
Column(
|
||||||
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
@@ -89,37 +86,29 @@ private fun Selector(label: String, value: String, onDec: () -> Unit, onInc: ()
|
|||||||
RepeatButton("+", onStep = onInc)
|
RepeatButton("+", onStep = onInc)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One octave; hold a key to audition (and, in punch-in mode, record) the note.
|
/** One octave; hold or SLIDE across keys to audition (and, in punch-in mode, record)
|
||||||
* When [inKey] is non-null, out-of-scale keys are dimmed and inert. */
|
* notes. Out-of-scale keys (not in [inKey]) are dimmed and skipped. Sliding auditions
|
||||||
|
* every key it crosses; in punch-in mode only the initial press records, so a slide
|
||||||
|
* plays a glide without spraying a run of notes into the pattern. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun KeyboardOctave(
|
private fun KeyboardOctave(
|
||||||
vm: AppViewModel,
|
vm: AppViewModel,
|
||||||
punchIn: Boolean,
|
punchIn: Boolean,
|
||||||
inKey: Set<Int>?,
|
inKey: Set<Int>,
|
||||||
startMidi: Int,
|
startMidi: Int,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
|
fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
PianoOctave(
|
||||||
val enabled = inKey == null || pc in inKey
|
label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") },
|
||||||
val liveMidi = rememberUpdatedState(midi)
|
enabled = { pc -> pc in inKey },
|
||||||
val livePunch = rememberUpdatedState(punchIn)
|
pressed = { pc -> vm.isNoteHeld(midiOf(pc)) },
|
||||||
PianoKey(
|
onNoteOn = { pc, slid ->
|
||||||
label = Pitch.name(midi).replace("-", ""),
|
val n = midiOf(pc)
|
||||||
isBlack = isBlack,
|
if (punchIn && !slid) vm.punchNote(n)
|
||||||
enabled = enabled,
|
vm.kbdNoteOn(n)
|
||||||
selected = false,
|
},
|
||||||
pressed = vm.isNoteHeld(midi),
|
onNoteOff = { pc -> vm.kbdNoteOff(midiOf(pc)) },
|
||||||
modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) {
|
modifier = modifier.fillMaxWidth(),
|
||||||
awaitEachGesture {
|
)
|
||||||
awaitFirstDown()
|
|
||||||
val n = liveMidi.value
|
|
||||||
if (livePunch.value) vm.punchNote(n)
|
|
||||||
vm.kbdNoteOn(n)
|
|
||||||
waitForUpOrCancellation()
|
|
||||||
vm.kbdNoteOff(n)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||||
@@ -51,6 +52,7 @@ fun Knob(
|
|||||||
valueText: String,
|
valueText: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
default: Float = min,
|
default: Float = min,
|
||||||
|
knobSize: Dp = 44.dp,
|
||||||
onChange: (Float) -> Unit,
|
onChange: (Float) -> Unit,
|
||||||
) {
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@@ -71,7 +73,7 @@ fun Knob(
|
|||||||
)
|
)
|
||||||
Canvas(
|
Canvas(
|
||||||
Modifier
|
Modifier
|
||||||
.size(44.dp)
|
.size(knobSize)
|
||||||
.pointerInput(min, max) {
|
.pointerInput(min, max) {
|
||||||
// Relative: slide up to raise, down to lower. A full min..max sweep
|
// Relative: slide up to raise, down to lower. A full min..max sweep
|
||||||
// takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height),
|
// takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package space.rcmd.android.sizzle.ui.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -16,53 +17,129 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.input.pointer.PointerId
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lays out one octave as a real piano keyboard: seven white keys across the bottom
|
* One octave laid out as a real piano keyboard: seven white keys across the bottom
|
||||||
* with the five black keys overlaid on top, each centred on the gap between the two
|
* with the five black keys overlaid on top, each centred on the gap between the two
|
||||||
* white keys it sits between (C# D# _ F# G# A#).
|
* white keys it sits between (C# D# _ F# G# A#).
|
||||||
*
|
*
|
||||||
* Layout-only: the [key] slot renders each key and callers attach their own gesture
|
* Input is handled at the octave level so a finger can **slide across keys** — a
|
||||||
* to [keyModifier] (tap-to-enter, hold-to-audition, …). Black keys are emitted last
|
* glissando: the note under each finger retriggers as it crosses a key boundary
|
||||||
* so they draw — and receive touches — on top of the white keys they overlap, just
|
* (black keys win in their upper region, exactly like a physical keyboard), and
|
||||||
* like a physical keyboard.
|
* multiple fingers play independently (chords still work). For each pitch class the
|
||||||
|
* caller supplies its [label], whether it's [enabled] (out-of-scale keys are dimmed
|
||||||
|
* and skipped), whether it's currently [pressed] (visual), and [onNoteOn] /
|
||||||
|
* [onNoteOff] to sound it — [onNoteOn]'s `slid` flag is false on the initial press
|
||||||
|
* and true when a finger slides onto the key, so callers can treat the two
|
||||||
|
* differently (e.g. only punch-record the initial press).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PianoOctave(
|
fun PianoOctave(
|
||||||
|
label: (pitchClass: Int) -> String,
|
||||||
|
enabled: (pitchClass: Int) -> Boolean,
|
||||||
|
pressed: (pitchClass: Int) -> Boolean,
|
||||||
|
onNoteOn: (pitchClass: Int, slid: Boolean) -> Unit,
|
||||||
|
onNoteOff: (pitchClass: Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
blackWidthFraction: Float = 0.62f,
|
blackWidthFraction: Float = 0.62f,
|
||||||
blackHeightFraction: Float = 0.62f,
|
blackHeightFraction: Float = 0.62f,
|
||||||
key: @Composable (pitchClass: Int, isBlack: Boolean, keyModifier: Modifier) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier) {
|
// Latest callbacks, so the long-lived gesture always calls the current ones
|
||||||
|
// (octave / scale changes swap the lambdas without restarting the gesture).
|
||||||
|
val onOn by rememberUpdatedState(onNoteOn)
|
||||||
|
val onOff by rememberUpdatedState(onNoteOff)
|
||||||
|
val isEnabled by rememberUpdatedState(enabled)
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
// Per-pointer current pitch class (-1 = on a disabled key or off the
|
||||||
|
// keyboard). Independent pointers → chords; a moving pointer → glissando.
|
||||||
|
val active = HashMap<PointerId, Int>()
|
||||||
|
do {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
for (ch in event.changes) {
|
||||||
|
if (ch.pressed) {
|
||||||
|
val raw = pcAt(ch.position, size, blackWidthFraction, blackHeightFraction)
|
||||||
|
val pc = if (raw in 0..11 && isEnabled(raw)) raw else -1
|
||||||
|
val prev = active[ch.id]
|
||||||
|
when {
|
||||||
|
prev == null -> { active[ch.id] = pc; if (pc >= 0) onOn(pc, false) }
|
||||||
|
pc != prev -> {
|
||||||
|
if (prev >= 0) onOff(prev)
|
||||||
|
if (pc >= 0) onOn(pc, true)
|
||||||
|
active[ch.id] = pc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch.consume()
|
||||||
|
} else {
|
||||||
|
val prev = active.remove(ch.id)
|
||||||
|
if (prev != null && prev >= 0) onOff(prev)
|
||||||
|
ch.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (active.isNotEmpty())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
val whiteW = maxWidth / 7
|
val whiteW = maxWidth / 7
|
||||||
val blackW = whiteW * blackWidthFraction
|
val blackW = whiteW * blackWidthFraction
|
||||||
val blackH = maxHeight * blackHeightFraction
|
val blackH = maxHeight * blackHeightFraction
|
||||||
// White keys: C D E F G A B, filling the width evenly.
|
// White keys: C D E F G A B, filling the width evenly.
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
for (pc in WHITE_PCS) key(pc, false, Modifier.weight(1f).fillMaxHeight())
|
for (pc in WHITE_PCS) {
|
||||||
|
PianoKey(label(pc), isBlack = false, enabled = enabled(pc), selected = false,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight(), pressed = pressed(pc))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Black keys, each centred on the boundary after a given white-key index.
|
// Black keys, each centred on the boundary after a given white-key index,
|
||||||
|
// drawn last so they sit on top of the white keys they overlap.
|
||||||
for ((pc, afterWhite) in BLACK_PCS) {
|
for ((pc, afterWhite) in BLACK_PCS) {
|
||||||
key(
|
PianoKey(
|
||||||
pc, true,
|
label(pc), isBlack = true, enabled = enabled(pc), selected = false,
|
||||||
Modifier
|
modifier = Modifier
|
||||||
.offset(x = whiteW * (afterWhite + 1) - blackW / 2)
|
.offset(x = whiteW * (afterWhite + 1) - blackW / 2)
|
||||||
.width(blackW)
|
.width(blackW)
|
||||||
.height(blackH),
|
.height(blackH),
|
||||||
|
pressed = pressed(pc),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hit-test a touch position to a pitch class (0..11), or -1 if off the keyboard.
|
||||||
|
* Black keys occupy their narrower upper region; below that (and between them) the
|
||||||
|
* white key at that x wins — matching how the keys are drawn. */
|
||||||
|
private fun pcAt(pos: Offset, size: IntSize, blackWidthFraction: Float, blackHeightFraction: Float): Int {
|
||||||
|
val w = size.width.toFloat()
|
||||||
|
val h = size.height.toFloat()
|
||||||
|
if (w <= 0f || h <= 0f || pos.x < 0f || pos.x >= w || pos.y < 0f || pos.y >= h) return -1
|
||||||
|
val whiteW = w / 7f
|
||||||
|
if (pos.y <= h * blackHeightFraction) {
|
||||||
|
val blackW = whiteW * blackWidthFraction
|
||||||
|
for ((pc, afterWhite) in BLACK_PCS) {
|
||||||
|
val bx = whiteW * (afterWhite + 1) - blackW / 2f
|
||||||
|
if (pos.x >= bx && pos.x <= bx + blackW) return pc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val wi = (pos.x / whiteW).toInt().coerceIn(0, 6)
|
||||||
|
return WHITE_PCS[wi]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single piano key face: light for white keys, dark for black, dimmed when
|
* A single piano key face: light for white keys, dark for black, dimmed when
|
||||||
* [enabled] is false and accent-filled when [selected]. [pressed] lights the key
|
* [enabled] is false and accent-filled when [selected]. [pressed] lights the key
|
||||||
|
|||||||
@@ -18,16 +18,25 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableFloatState
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -36,6 +45,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import space.rcmd.android.sizzle.model.Mixer
|
import space.rcmd.android.sizzle.model.Mixer
|
||||||
import space.rcmd.android.sizzle.model.MixerChannel
|
import space.rcmd.android.sizzle.model.MixerChannel
|
||||||
|
import space.rcmd.android.sizzle.model.Pattern
|
||||||
import space.rcmd.android.sizzle.model.ToolboxKind
|
import space.rcmd.android.sizzle.model.ToolboxKind
|
||||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||||
import space.rcmd.android.sizzle.ui.isLandscape
|
import space.rcmd.android.sizzle.ui.isLandscape
|
||||||
@@ -55,6 +65,30 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
|||||||
fun MixerScreen(vm: AppViewModel) {
|
fun MixerScreen(vm: AppViewModel) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||||
|
|
||||||
|
// Live per-channel VU state, polled from the audio engine once per frame. Held in
|
||||||
|
// stable state objects so only the fader's draw phase (which reads them) repaints —
|
||||||
|
// the strips themselves never recompose from meter movement. Writing an unchanged
|
||||||
|
// value is a no-op, so an idle (silent) mixer costs nothing but the frame tick.
|
||||||
|
// The whole poll loop is skipped when VU meters are turned off in Settings.
|
||||||
|
val vuOn = vm.vuMeters
|
||||||
|
val levels = remember { List(Pattern.TRACK_COUNT) { mutableFloatStateOf(0f) } }
|
||||||
|
val clips = remember { List(Pattern.TRACK_COUNT) { mutableStateOf(false) } }
|
||||||
|
LaunchedEffect(vuOn) {
|
||||||
|
if (!vuOn) {
|
||||||
|
// Clear any lingering values so nothing shows if re-enabled while silent.
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) { levels[ch].floatValue = 0f; clips[ch].value = false }
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
withFrameNanos { }
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
levels[ch].floatValue = vm.channelMeter(ch)
|
||||||
|
clips[ch].value = vm.channelClipped(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
RecordToolbar(vm)
|
RecordToolbar(vm)
|
||||||
Row(
|
Row(
|
||||||
@@ -62,7 +96,8 @@ fun MixerScreen(vm: AppViewModel) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
vm.project.mixer.channels.forEach { channel ->
|
vm.project.mixer.channels.forEach { channel ->
|
||||||
ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
|
ChannelStrip(vm, channel, levels[channel.index], clips[channel.index], vuOn,
|
||||||
|
Modifier.weight(1f).fillMaxHeight())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +159,14 @@ private fun RecordToolbar(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
|
private fun ChannelStrip(
|
||||||
|
vm: AppViewModel,
|
||||||
|
channel: MixerChannel,
|
||||||
|
level: MutableFloatState,
|
||||||
|
clipped: State<Boolean>,
|
||||||
|
showVu: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh
|
||||||
|
|
||||||
@@ -148,12 +190,12 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
|
|||||||
Column(
|
Column(
|
||||||
Modifier.weight(1f).fillMaxHeight(),
|
Modifier.weight(1f).fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) { ChannelLevels(vm, channel) }
|
) { ChannelLevels(vm, channel, level, clipped, showVu) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Portrait: one column, the fader filling the leftover height.
|
// Portrait: one column, the fader filling the leftover height.
|
||||||
ChannelRouting(vm, channel)
|
ChannelRouting(vm, channel)
|
||||||
ChannelLevels(vm, channel)
|
ChannelLevels(vm, channel, level, clipped, showVu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,13 +247,22 @@ private fun ColumnScope.ChannelRouting(vm: AppViewModel, channel: MixerChannel)
|
|||||||
/** Volume fader + limiter + mute/solo for a mixer [channel]. The fader fills the
|
/** Volume fader + limiter + mute/solo for a mixer [channel]. The fader fills the
|
||||||
* leftover height of its column. */
|
* leftover height of its column. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
|
private fun ColumnScope.ChannelLevels(
|
||||||
|
vm: AppViewModel,
|
||||||
|
channel: MixerChannel,
|
||||||
|
level: MutableFloatState,
|
||||||
|
clipped: State<Boolean>,
|
||||||
|
showVu: Boolean,
|
||||||
|
) {
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
|
||||||
SectionLabel("Volume")
|
SectionLabel("Volume")
|
||||||
VerticalFader(
|
VerticalFader(
|
||||||
value = channel.volume,
|
value = channel.volume,
|
||||||
default = Mixer.DEFAULT_VOLUME,
|
default = Mixer.DEFAULT_VOLUME,
|
||||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||||
|
level = level,
|
||||||
|
clipped = clipped,
|
||||||
|
showVu = showVu,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
// Per-bus soft-clip limiter, sitting above mute/solo.
|
// Per-bus soft-clip limiter, sitting above mute/solo.
|
||||||
@@ -228,16 +279,69 @@ private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A vertical volume fader: fill grows from the bottom; tap or drag to set.
|
/** A vertical volume fader with a live VU meter drawn inside its filled area: fill
|
||||||
* Snaps to the [default] level within a small band so returning to the standard
|
* grows from the bottom to the set [value]; tap or drag to set. The VU ([level], a
|
||||||
* volume is easy to hit. */
|
* peak amplitude polled each frame) rises from the bottom within the fill, green →
|
||||||
|
* amber as it gets hotter, and the whole meter turns red while the bus is [clipped]
|
||||||
|
* (past 0 dBFS). Snaps to the [default] level within a small band so returning to the
|
||||||
|
* standard volume is easy to hit.
|
||||||
|
*
|
||||||
|
* Everything is painted in a single [drawBehind]; [level]/[clipped] are read in the
|
||||||
|
* draw phase, so meter movement repaints only this fader — no recomposition. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerticalFader(value: Float, default: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
|
private fun VerticalFader(
|
||||||
|
value: Float,
|
||||||
|
default: Float,
|
||||||
|
onValueChange: (Float) -> Unit,
|
||||||
|
level: MutableFloatState,
|
||||||
|
clipped: State<Boolean>,
|
||||||
|
showVu: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
|
val volFill = c.accent.copy(alpha = 0.30f)
|
||||||
|
val vuLo = c.playhead // green — nominal level
|
||||||
|
val vuHi = c.accent // amber — getting hot
|
||||||
|
val vuClip = c.danger // red — clipping
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.border(1.dp, c.grid, RectangleShape)
|
.border(1.dp, c.grid, RectangleShape)
|
||||||
.background(c.background)
|
.background(c.background)
|
||||||
|
.drawBehind {
|
||||||
|
val h = size.height
|
||||||
|
val w = size.width
|
||||||
|
val vol = value.coerceIn(0f, 1f)
|
||||||
|
val volH = vol * h
|
||||||
|
val volTop = h - volH
|
||||||
|
// Volume fill (the set level), translucent.
|
||||||
|
if (volH > 0f) drawRect(volFill, topLeft = Offset(0f, volTop), size = Size(w, volH))
|
||||||
|
|
||||||
|
// VU meter, contained within the fill. Colour maps to ABSOLUTE level
|
||||||
|
// over the full track height (bottom green → top red), so a taller bar
|
||||||
|
// is visibly hotter; the gradient's red tip is what "hard clipping turns
|
||||||
|
// to a red hue" looks like as the signal climbs. Skipped when the VU
|
||||||
|
// meters are turned off in Settings.
|
||||||
|
val lvl = if (showVu) level.floatValue.coerceIn(0f, 1f) else 0f
|
||||||
|
val meterFrac = if (lvl < vol) lvl else vol // stay inside the filled area
|
||||||
|
if (showVu && meterFrac > 0.001f) {
|
||||||
|
val mH = meterFrac * h
|
||||||
|
val top = h - mH
|
||||||
|
if (clipped.value) {
|
||||||
|
drawRect(vuClip, topLeft = Offset(0f, top), size = Size(w, mH))
|
||||||
|
} else {
|
||||||
|
val brush = Brush.verticalGradient(
|
||||||
|
0f to vuLo, 0.65f to vuLo, 0.85f to vuHi, 1f to vuClip,
|
||||||
|
startY = h, endY = 0f, // stop 0 = bottom, stop 1 = top of the track
|
||||||
|
)
|
||||||
|
drawRect(brush, topLeft = Offset(0f, top), size = Size(w, mH))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumb line at the set level.
|
||||||
|
val thumb = 3.dp.toPx()
|
||||||
|
drawRect(c.accent, topLeft = Offset(0f, (volTop - thumb / 2f).coerceIn(0f, h - thumb)),
|
||||||
|
size = Size(w, thumb))
|
||||||
|
}
|
||||||
.pointerInput(default) {
|
.pointerInput(default) {
|
||||||
detectTapGestures { off -> onValueChange(snapDefault(1f - off.y / size.height, default)) }
|
detectTapGestures { off -> onValueChange(snapDefault(1f - off.y / size.height, default)) }
|
||||||
}
|
}
|
||||||
@@ -247,18 +351,7 @@ private fun VerticalFader(value: Float, default: Float, onValueChange: (Float) -
|
|||||||
onValueChange(snapDefault(1f - change.position.y / size.height, default))
|
onValueChange(snapDefault(1f - change.position.y / size.height, default))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.BottomCenter,
|
)
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight(value.coerceIn(0.001f, 1f))
|
|
||||||
.background(c.accent.copy(alpha = 0.30f)),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
|
||||||
) {
|
|
||||||
Box(Modifier.fillMaxWidth().height(3.dp).background(c.accent)) // thumb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snap the fader to the [default] level when the raw value lands within a small
|
/** Snap the fader to the [default] level when the raw value lands within a small
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.AudioDeviceInfo
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
import android.net.Uri
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -47,6 +48,7 @@ import androidx.compose.material3.ListItemDefaults
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -92,8 +94,11 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
) {
|
) {
|
||||||
item { PanicButton(vm) }
|
item { PanicButton(vm) }
|
||||||
item { ProjectCard(vm) }
|
item { ProjectCard(vm) }
|
||||||
|
item { HelpCard() }
|
||||||
item { AudioDevicesCard(vm) }
|
item { AudioDevicesCard(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
|
||||||
@@ -109,6 +114,7 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
section = SettingsSection.MIDI
|
section = SettingsSection.MIDI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item { BackupCard(vm) }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsSection.GAMEPAD -> LazyColumn(
|
SettingsSection.GAMEPAD -> LazyColumn(
|
||||||
@@ -231,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 {
|
||||||
@@ -266,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")
|
||||||
}
|
}
|
||||||
@@ -343,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
|
||||||
@@ -368,6 +407,151 @@ private fun AudioDevicesCard(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whole-app backup to a single ZIP (songs, presets, themes, settings + bindings)
|
||||||
|
* and restore from one. Restore is destructive and needs an app restart. */
|
||||||
|
@Composable
|
||||||
|
private fun BackupCard(vm: AppViewModel) {
|
||||||
|
var confirmRestore by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
val backupPicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.CreateDocument("application/zip"),
|
||||||
|
) { uri -> uri?.let { vm.backupTo(it) } }
|
||||||
|
val restorePicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> if (uri != null) confirmRestore = uri }
|
||||||
|
|
||||||
|
SettingsCard("Backup & Restore", subtitle = "Save or restore all songs, presets, themes & settings") {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { backupPicker.launch("sizzletracker-backup.zip") }) { Text("Back up all") }
|
||||||
|
OutlinedButton(onClick = { restorePicker.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) {
|
||||||
|
Text("Restore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Backup writes one .zip. Restore replaces ALL current data and takes effect " +
|
||||||
|
"after you reopen the app.",
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRestore?.let { uri ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmRestore = null },
|
||||||
|
title = { Text("Restore backup?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"This replaces all current songs, presets, themes and settings with the " +
|
||||||
|
"backup's contents. This can't be undone.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { vm.restoreFrom(uri); confirmRestore = null }) { Text("Restore") } },
|
||||||
|
dismissButton = { TextButton(onClick = { confirmRestore = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.backupMessage?.let { msg ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { vm.dismissBackupMessage() },
|
||||||
|
title = { Text("Backup & Restore") },
|
||||||
|
text = { Text(msg) },
|
||||||
|
confirmButton = { TextButton(onClick = { vm.dismissBackupMessage() }) { Text("OK") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** About + external links: app version, source, user guide, and support. */
|
||||||
|
@Composable
|
||||||
|
private fun HelpCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val version = remember {
|
||||||
|
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull()
|
||||||
|
}
|
||||||
|
val appName = remember { context.applicationInfo.loadLabel(context.packageManager).toString() }
|
||||||
|
SettingsCard("Help", subtitle = "About, source, user guide & support") {
|
||||||
|
Text(
|
||||||
|
appName + (version?.let { " v$it" } ?: ""),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
LinkRow("Source code", "https://git.rcmd.space/tiredsysadmin/sizzletracker-android")
|
||||||
|
LinkRow(
|
||||||
|
"User guide",
|
||||||
|
"https://git.rcmd.space/tiredsysadmin/sizzletracker-android/src/branch/main/docs/USER_GUIDE.md#sizzletracker--user-guide",
|
||||||
|
)
|
||||||
|
LinkRow("Support us", "https://rcmd.space/pages/support/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tappable label + destination URL that opens in the system browser. A tall,
|
||||||
|
* full-width row (~64 dp) so it's a comfortable thumb target on a phone. */
|
||||||
|
@Composable
|
||||||
|
private fun LinkRow(label: String, url: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().clickable { openUrl(context, url) }.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open [url] in the user's browser; a missing browser is ignored rather than crashing. */
|
||||||
|
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") {
|
||||||
|
ToggleRow(
|
||||||
|
"Mixer VU meters",
|
||||||
|
"Show live level meters inside the mixer volume faders.",
|
||||||
|
vm.vuMeters,
|
||||||
|
) { vm.updateVuMeters(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThemeCard(vm: AppViewModel) {
|
private fun ThemeCard(vm: AppViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package space.rcmd.android.sizzle.ui.toolbox
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||||
|
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||||
|
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.GRANULAR] effect: a
|
||||||
|
* dotted oscilloscope of the sound passing through with the granular read head drawn
|
||||||
|
* as a jumping playhead, and one horizontal Intensity slider below. The scope only
|
||||||
|
* shows data while the effect is placed on a mixer channel that is receiving signal.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GranularEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
val mono = FontFamily.Monospace
|
||||||
|
|
||||||
|
// Polled once per frame from the audio engine: the scope samples, the playhead
|
||||||
|
// position, and how many samples are live (-1 = not routed / no signal).
|
||||||
|
val scope = remember { FloatArray(SCOPE_CAP) }
|
||||||
|
var count by remember { mutableIntStateOf(-1) }
|
||||||
|
var playhead by remember { mutableFloatStateOf(-1f) }
|
||||||
|
var tick by remember { mutableIntStateOf(0) }
|
||||||
|
LaunchedEffect(slot.index) {
|
||||||
|
while (true) {
|
||||||
|
withFrameNanos { }
|
||||||
|
count = vm.effectScope(slot.index, scope)
|
||||||
|
playhead = vm.effectPlayhead(slot.index)
|
||||||
|
tick++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().height(160.dp).background(c.background).border(1.dp, c.grid, RectangleShape),
|
||||||
|
) {
|
||||||
|
Canvas(Modifier.fillMaxWidth().height(160.dp)) {
|
||||||
|
@Suppress("UNUSED_EXPRESSION") tick // subscribe the draw to the per-frame updates
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val cy = h * 0.5f
|
||||||
|
val amp = h * 0.42f
|
||||||
|
drawLine(c.grid, Offset(0f, cy), Offset(w, cy), strokeWidth = 1f) // baseline
|
||||||
|
val n = count
|
||||||
|
if (n > 1) {
|
||||||
|
// Dotted waveform of the signal passing through.
|
||||||
|
val step = w / (n - 1)
|
||||||
|
for (k in 0 until n) {
|
||||||
|
val y = cy - scope[k].coerceIn(-1f, 1f) * amp
|
||||||
|
drawCircle(c.textDim, radius = 2f, center = Offset(k * step, y))
|
||||||
|
}
|
||||||
|
// Jumping playhead over the scope.
|
||||||
|
val ph = playhead
|
||||||
|
if (ph in 0f..1f) {
|
||||||
|
val px = ph * w
|
||||||
|
drawLine(c.accent, Offset(px, 0f), Offset(px, h), strokeWidth = 2f)
|
||||||
|
drawCircle(c.accent, radius = 4f, center = Offset(px, cy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count < 0) {
|
||||||
|
Text(
|
||||||
|
"Add to a mixer channel's FX and play to see the scope.",
|
||||||
|
color = c.textDim, fontFamily = mono, fontSize = 11.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val intensity = slot.float("intensity", 0.3f).coerceIn(0f, 1f)
|
||||||
|
Text("Intensity ${(intensity * 100).roundToInt()}%",
|
||||||
|
color = c.accent, fontFamily = mono, fontSize = 12.sp)
|
||||||
|
HorizontalSlider(
|
||||||
|
value = intensity,
|
||||||
|
onValueChange = { slot.set("intensity", it.coerceIn(0f, 1f)); vm.bumpForToolbar() },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(40.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Low = occasional stutters · High = constant glitch & octave jumps.",
|
||||||
|
color = c.textDim, fontFamily = mono, fontSize = 10.sp,
|
||||||
|
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A horizontal fill-from-left slider matching the app's retro faders. */
|
||||||
|
@Composable
|
||||||
|
private fun HorizontalSlider(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.border(1.dp, c.grid, RectangleShape)
|
||||||
|
.background(c.background)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { off -> onValueChange(off.x / size.width) }
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGestures { change, _ -> change.consume(); onValueChange(change.position.x / size.width) }
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(value.coerceIn(0.001f, 1f))
|
||||||
|
.background(c.accent.copy(alpha = 0.30f)),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxHeight().width(3.dp).background(c.accent)) // thumb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SCOPE_CAP = 128 // UI buffer; the engine fills up to its own scope size
|
||||||
@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
@@ -15,9 +16,16 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -26,6 +34,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -44,6 +53,7 @@ import space.rcmd.android.sizzle.model.ToolboxSlot
|
|||||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,13 +68,18 @@ import kotlin.math.abs
|
|||||||
* volume lives in the fader bank. When the sequencer plays a pad's note the sample
|
* volume lives in the fader bank. When the sequencer plays a pad's note the sample
|
||||||
* fires at its natural pitch.
|
* fires at its natural pitch.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The Sampler's detail area (selected-pad source buttons + a swipeable waveform /
|
||||||
|
* per-pad-volume view). The playable 16-pad grid itself is rendered separately by
|
||||||
|
* [SamplerPadBank], pinned at the bottom of the editor at the same height as the
|
||||||
|
* other instruments' audition keyboards; [pad] selection is hoisted so both agree.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot, pad: Int, onSelectPad: (Int) -> Unit) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: pads/waveform refresh
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe: pads/waveform refresh
|
||||||
|
|
||||||
var pad by remember(slot.index) { mutableIntStateOf(0) }
|
|
||||||
var importError by remember(slot.index) { mutableStateOf<String?>(null) }
|
var importError by remember(slot.index) { mutableStateOf<String?>(null) }
|
||||||
val sampleId = slot.string(SamplerPads.key(pad))
|
val sampleId = slot.string(SamplerPads.key(pad))
|
||||||
val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f)
|
val sliceStart = slot.float(SamplerPads.sliceStartKey(pad), 0f)
|
||||||
@@ -112,14 +127,73 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Waveform + draggable slice markers for the selected pad -----
|
// ----- Swipe: page 1 = waveform + slice markers, page 2 = per-pad volumes -----
|
||||||
val sample = SampleStore.get(sampleId)
|
val pagerState = rememberPagerState(pageCount = { 2 })
|
||||||
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
val scope = rememberCoroutineScope()
|
||||||
var dragging by remember { mutableIntStateOf(-1) }
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||||
|
pageSpacing = 12.dp,
|
||||||
|
) { page ->
|
||||||
|
if (page == 0) {
|
||||||
|
WaveformPage(vm, slot, pad, sampleId, sliceStart, sliceEnd)
|
||||||
|
} else {
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
"Pad volumes (drag a slider; tap selects that pad):",
|
||||||
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
PadVolumeBank(vm, slot, pad, rev, onSelect = onSelectPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dot pagination: tap a dot (or swipe) to switch waveform <-> volumes.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
val active = i == pagerState.currentPage
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.size(if (active) 10.dp else 7.dp)
|
||||||
|
.background(if (active) c.accent else c.grid, CircleShape)
|
||||||
|
.clickable { scope.launch { pagerState.animateScrollToPage(i) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importError?.let { msg ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { importError = null },
|
||||||
|
title = { Text("Import failed") },
|
||||||
|
text = { Text(msg) },
|
||||||
|
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waveform + draggable slice markers for the selected pad (swipe page 1). */
|
||||||
|
@Composable
|
||||||
|
private fun WaveformPage(
|
||||||
|
vm: AppViewModel,
|
||||||
|
slot: ToolboxSlot,
|
||||||
|
pad: Int,
|
||||||
|
sampleId: String,
|
||||||
|
sliceStart: Float,
|
||||||
|
sliceEnd: Float,
|
||||||
|
) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
val sample = SampleStore.get(sampleId)
|
||||||
|
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||||
|
var dragging by remember { mutableIntStateOf(-1) }
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Canvas(
|
Canvas(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(110.dp)
|
.height(150.dp)
|
||||||
.background(c.surface)
|
.background(c.surface)
|
||||||
.pointerInput(sampleId, pad) {
|
.pointerInput(sampleId, pad) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
@@ -159,42 +233,27 @@ fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
|||||||
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers, then CLIP to trim",
|
else "${sample.data.size} frames @ ${sample.sampleRate} Hz · drag the markers, then CLIP to trim",
|
||||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ----- Per-pad output volume: 16 vertical sliders laid out 8 x 2 -----
|
|
||||||
Text(
|
|
||||||
"Pad volumes (drag a slider; tap selects that pad):",
|
|
||||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
|
||||||
)
|
|
||||||
PadVolumeBank(vm, slot, pad, rev, onSelect = { pad = it })
|
|
||||||
|
|
||||||
// ----- 16-pad bank (bottom section) -----
|
|
||||||
Text(
|
|
||||||
"Pads (from C2 — press to play & select; CLEAR empties the selected pad):",
|
|
||||||
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
|
||||||
)
|
|
||||||
// Pass rev so assign/clear (which only bump revision) redraw the pads: a
|
|
||||||
// changed Int param defeats Compose strong-skipping on these leaves.
|
|
||||||
PadBank(vm, slot, pad, rev, onSelect = { pad = it })
|
|
||||||
}
|
|
||||||
|
|
||||||
importError?.let { msg ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { importError = null },
|
|
||||||
title = { Text("Import failed") },
|
|
||||||
text = { Text(msg) },
|
|
||||||
confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 4x4 pad grid. Pads read ascending left-to-right, top-to-bottom (pad 0 = C2). */
|
/** The playable 4x4 pad grid, pinned at the editor bottom and filling [modifier]'s
|
||||||
|
* height so the pads match the audition keyboards on the other instrument editors.
|
||||||
|
* Pads read ascending left-to-right, top-to-bottom (pad 0 = C2). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) {
|
fun SamplerPadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
val c = LocalRetro.current
|
||||||
|
val rev = vm.revision // redraw pads on assign/clear (which only bump revision)
|
||||||
|
Column(
|
||||||
|
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
for (row in 0 until 4) {
|
for (row in 0 until 4) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(
|
||||||
|
Modifier.fillMaxWidth().weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
for (col in 0 until 4) {
|
for (col in 0 until 4) {
|
||||||
PadCell(vm, slot, row * 4 + col, selected, rev, Modifier.weight(1f), onSelect)
|
PadCell(vm, slot, row * 4 + col, selected, rev, Modifier.weight(1f).fillMaxHeight(), onSelect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,7 +283,6 @@ private fun PadCell(
|
|||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.height(46.dp)
|
|
||||||
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
||||||
.background(fill)
|
.background(fill)
|
||||||
.pointerInput(pad, slot.index) {
|
.pointerInput(pad, slot.index) {
|
||||||
@@ -286,7 +344,7 @@ private fun VerticalPadSlider(
|
|||||||
Canvas(
|
Canvas(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(96.dp)
|
.height(76.dp)
|
||||||
.background(c.surface)
|
.background(c.surface)
|
||||||
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
||||||
.pointerInput(pad, slot.index) {
|
.pointerInput(pad, slot.index) {
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -27,7 +24,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -43,7 +39,6 @@ import space.rcmd.android.sizzle.audio.SampleStore
|
|||||||
import space.rcmd.android.sizzle.model.Pitch
|
import space.rcmd.android.sizzle.model.Pitch
|
||||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||||
import space.rcmd.android.sizzle.ui.components.PianoKey
|
|
||||||
import space.rcmd.android.sizzle.ui.components.PianoOctave
|
import space.rcmd.android.sizzle.ui.components.PianoOctave
|
||||||
import space.rcmd.android.sizzle.ui.components.RepeatButton
|
import space.rcmd.android.sizzle.ui.components.RepeatButton
|
||||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||||
@@ -218,7 +213,11 @@ private fun formatSliderValue(v: Float): String =
|
|||||||
@Composable
|
@Composable
|
||||||
fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
|
fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh key filtering on scale/root change
|
||||||
var octave by remember(slot.index) { mutableIntStateOf(3) }
|
var octave by remember(slot.index) { mutableIntStateOf(3) }
|
||||||
|
// In-scale keys only (Chromatic yields all twelve), matching the punch-in keyboard.
|
||||||
|
val root = vm.project.rootNote
|
||||||
|
val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
||||||
Column(
|
Column(
|
||||||
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
@@ -232,8 +231,8 @@ fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier
|
|||||||
Text("$octave–${octave + 1}", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
Text("$octave–${octave + 1}", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp)
|
||||||
RepeatButton("+") { octave = (octave + 1).coerceAtMost(8) }
|
RepeatButton("+") { octave = (octave + 1).coerceAtMost(8) }
|
||||||
}
|
}
|
||||||
AuditionOctave(vm, slot.index, (octave + 2) * 12, Modifier.weight(1f)) // upper octave on top
|
AuditionOctave(vm, slot.index, inKey, (octave + 2) * 12, Modifier.weight(1f)) // upper octave on top
|
||||||
AuditionOctave(vm, slot.index, (octave + 1) * 12, Modifier.weight(1f)) // lower octave below
|
AuditionOctave(vm, slot.index, inKey, (octave + 1) * 12, Modifier.weight(1f)) // lower octave below
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,25 +241,16 @@ fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier
|
|||||||
* instrument. [modifier] carries the weight so the two octaves share the pinned
|
* instrument. [modifier] carries the weight so the two octaves share the pinned
|
||||||
* keyboard's height (PianoOctave needs a bounded height to lay its keys out). */
|
* keyboard's height (PianoOctave needs a bounded height to lay its keys out). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, startMidi: Int, modifier: Modifier) {
|
private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, inKey: Set<Int>, startMidi: Int, modifier: Modifier) {
|
||||||
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
|
fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||||
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
PianoOctave(
|
||||||
val liveMidi = rememberUpdatedState(midi)
|
label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") },
|
||||||
PianoKey(
|
enabled = { pc -> pc in inKey },
|
||||||
label = Pitch.name(midi).replace("-", ""),
|
pressed = { pc -> vm.isNoteHeld(midiOf(pc)) },
|
||||||
isBlack = isBlack,
|
// Sliding across keys auditions each in turn (a glissando); a lifted finger
|
||||||
enabled = true,
|
// stops its note.
|
||||||
selected = false,
|
onNoteOn = { pc, _ -> vm.auditionOn(slotIndex, midiOf(pc)) },
|
||||||
pressed = vm.isNoteHeld(midi),
|
onNoteOff = { pc -> vm.auditionOff(midiOf(pc)) },
|
||||||
modifier = keyMod.pointerInput(Unit) {
|
modifier = modifier.fillMaxWidth(),
|
||||||
awaitEachGesture {
|
)
|
||||||
awaitFirstDown()
|
|
||||||
val n = liveMidi.value
|
|
||||||
vm.auditionOn(slotIndex, n)
|
|
||||||
waitForUpOrCancellation()
|
|
||||||
vm.auditionOff(n)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package space.rcmd.android.sizzle.ui.toolbox
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import space.rcmd.android.sizzle.model.TimeDivision
|
||||||
|
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||||
|
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||||
|
import space.rcmd.android.sizzle.ui.components.Knob
|
||||||
|
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.TAPE_ENGINE] effect,
|
||||||
|
* drawn as a reel-to-reel tape transport: two NAB reels (metal flange with three
|
||||||
|
* rounded-triangular bobbin holes) feeding tape down to a playhead block between them
|
||||||
|
* under tension. **Press and hold a reel to brake it** — the tape (and its pitch)
|
||||||
|
* dives, then springs back when released. Below is a row of vintage round knobs: wow,
|
||||||
|
* flutter, hiss, and the tempo-sync division for the wow wobble.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TapeEngineEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||||
|
val c = LocalRetro.current
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
val mono = FontFamily.Monospace
|
||||||
|
|
||||||
|
// Rotation + a display speed eased toward the slot's target, so the reels dive and
|
||||||
|
// recover in step with the audio's own glide. Advanced once per frame.
|
||||||
|
var angle by remember { mutableFloatStateOf(0f) }
|
||||||
|
var displaySpeed by remember { mutableFloatStateOf(1f) }
|
||||||
|
LaunchedEffect(slot.index) {
|
||||||
|
while (true) {
|
||||||
|
withFrameNanos { }
|
||||||
|
val target = slot.float("speed", 1f).coerceIn(0.25f, 1f)
|
||||||
|
displaySpeed += (target - displaySpeed) * 0.10f
|
||||||
|
angle = (angle + displaySpeed * DEG_PER_FRAME) % 360f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeed(v: Float) { slot.set("speed", v.coerceIn(0.25f, 1f)); vm.bumpForToolbar() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
|
.background(c.surface)
|
||||||
|
// Press a reel to brake the tape; release to let it spin back up.
|
||||||
|
.pointerInput(slot.index) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = { off ->
|
||||||
|
if (overReel(off, size.width.toFloat(), size.height.toFloat())) {
|
||||||
|
setSpeed(BRAKE_SPEED)
|
||||||
|
tryAwaitRelease()
|
||||||
|
setSpeed(1f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Canvas(Modifier.fillMaxWidth().height(180.dp)) {
|
||||||
|
val cx0 = size.width * REEL_L_X
|
||||||
|
val cx1 = size.width * REEL_R_X
|
||||||
|
val cy = size.height * REEL_CY
|
||||||
|
val r = min(size.height * 0.30f, size.width * 0.20f)
|
||||||
|
|
||||||
|
// Playhead block between + below the reels.
|
||||||
|
val hw = (cx1 - cx0) * 0.46f
|
||||||
|
val hh = size.height * 0.13f
|
||||||
|
val hx = size.width * 0.5f
|
||||||
|
val hy = cy + r + size.height * 0.14f
|
||||||
|
val tl = Offset(hx - hw / 2f, hy - hh / 2f)
|
||||||
|
val tr = Offset(hx + hw / 2f, hy - hh / 2f)
|
||||||
|
|
||||||
|
// Tensioned tape: from each reel's bottom straight down to the playhead
|
||||||
|
// and across its top. No line over the top of the reels.
|
||||||
|
val p0 = Offset(cx0, cy + r)
|
||||||
|
val p1 = Offset(cx1, cy + r)
|
||||||
|
val tape = c.textDim
|
||||||
|
drawLine(tape, p0, tl, strokeWidth = 2.5f)
|
||||||
|
drawLine(tape, tl, tr, strokeWidth = 2.5f)
|
||||||
|
drawLine(tape, tr, p1, strokeWidth = 2.5f)
|
||||||
|
|
||||||
|
// Playhead (rounded rectangle) drawn over the tape it tensions.
|
||||||
|
drawRoundRect(
|
||||||
|
color = c.grid, topLeft = Offset(hx - hw / 2f, hy - hh / 2f),
|
||||||
|
size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f),
|
||||||
|
)
|
||||||
|
drawRoundRect(
|
||||||
|
color = c.accent, topLeft = Offset(hx - hw / 2f, hy - hh / 2f),
|
||||||
|
size = Size(hw, hh), cornerRadius = CornerRadius(hh * 0.45f, hh * 0.45f),
|
||||||
|
style = Stroke(width = 2f),
|
||||||
|
)
|
||||||
|
// Head gap slit in the middle of the playhead.
|
||||||
|
drawLine(c.accent, Offset(hx, hy - hh / 2f + 3f), Offset(hx, hy + hh / 2f - 3f), strokeWidth = 2f)
|
||||||
|
|
||||||
|
drawReel(cx0, cy, r, angle, c.accent, c.grid, c.surface, c.textDim)
|
||||||
|
drawReel(cx1, cy, r, angle, c.accent, c.grid, c.surface, c.textDim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Hold a reel to brake the tape — pitch dives, then springs back.",
|
||||||
|
color = c.textDim, fontFamily = mono, fontSize = 11.sp,
|
||||||
|
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vintage round knobs, all in a row (each weighted so all four fit).
|
||||||
|
val divIdx = slot.float("division", 2f).toInt().coerceIn(0, TimeDivision.entries.lastIndex)
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
PercentKnob(vm, slot, "wow", "WOW", 0.15f, Modifier.weight(1f))
|
||||||
|
PercentKnob(vm, slot, "flutter", "FLUTTER", 0.15f, Modifier.weight(1f))
|
||||||
|
PercentKnob(vm, slot, "hiss", "HISS", 0.10f, Modifier.weight(1f))
|
||||||
|
Knob(
|
||||||
|
label = "WOW SYNC",
|
||||||
|
value = divIdx.toFloat(), min = 0f, max = TimeDivision.entries.lastIndex.toFloat(),
|
||||||
|
valueText = TimeDivision.fromIndex(divIdx).label, default = 2f,
|
||||||
|
modifier = Modifier.weight(1f), knobSize = KNOB_SIZE,
|
||||||
|
onChange = { v ->
|
||||||
|
slot.set("division", v.roundToInt().coerceIn(0, TimeDivision.entries.lastIndex).toFloat())
|
||||||
|
vm.bumpForToolbar()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A 0..1 knob showing its value as a percentage, writing [key] on the slot. */
|
||||||
|
@Composable
|
||||||
|
private fun PercentKnob(vm: AppViewModel, slot: ToolboxSlot, key: String, label: String, default: Float, modifier: Modifier) {
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
|
||||||
|
val value = slot.float(key, default).coerceIn(0f, 1f)
|
||||||
|
Knob(
|
||||||
|
label = label, value = value, min = 0f, max = 1f,
|
||||||
|
valueText = "${(value * 100).roundToInt()}%", default = default,
|
||||||
|
modifier = modifier, knobSize = KNOB_SIZE,
|
||||||
|
onChange = { slot.set(key, it.coerceIn(0f, 1f)); vm.bumpForToolbar() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if [off] is within either reel's circle (used to gate the brake gesture). */
|
||||||
|
private fun overReel(off: Offset, w: Float, h: Float): Boolean {
|
||||||
|
val cy = h * REEL_CY
|
||||||
|
val r = min(h * 0.30f, w * 0.20f)
|
||||||
|
return hypot(off.x - w * REEL_L_X, off.y - cy) <= r ||
|
||||||
|
hypot(off.x - w * REEL_R_X, off.y - cy) <= r
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw one NAB reel: metal flange, three rotating rounded-triangular bobbin holes,
|
||||||
|
* and the hub in the centre. */
|
||||||
|
private fun DrawScope.drawReel(
|
||||||
|
cx: Float, cy: Float, r: Float, angle: Float,
|
||||||
|
accent: Color, flange: Color, hole: Color, rim: Color,
|
||||||
|
) {
|
||||||
|
val center = Offset(cx, cy)
|
||||||
|
drawCircle(flange, radius = r, center = center) // metal flange
|
||||||
|
for (k in 0 until 3) drawPath(bobbinHole(cx, cy, r, angle + k * 120f), hole) // trapezoidal cutouts
|
||||||
|
drawCircle(hole, radius = r * 0.26f, center = center) // bobbin centre (open)
|
||||||
|
drawCircle(accent, radius = r * 0.26f, center = center, style = Stroke(width = 3f))
|
||||||
|
drawCircle(accent, radius = r * 0.14f, center = center, style = Stroke(width = 2f))
|
||||||
|
drawCircle(rim, radius = r, center = center, style = Stroke(width = 2.5f)) // flange edge
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One rounded-trapezoidal bobbin hole at [thetaDeg] around the reel: wide toward the
|
||||||
|
* hub, narrow toward the rim (the narrow side faces outward). */
|
||||||
|
private fun bobbinHole(cx: Float, cy: Float, r: Float, thetaDeg: Float): Path {
|
||||||
|
val rIn = r * 0.40f; val rOut = r * 0.86f
|
||||||
|
val inner = 44f // angular half-spread at the hub (wide side)
|
||||||
|
val outer = 13f // angular half-spread at the rim (narrow side)
|
||||||
|
val innerL = polar(cx, cy, rIn, thetaDeg - inner)
|
||||||
|
val outerL = polar(cx, cy, rOut, thetaDeg - outer)
|
||||||
|
val outerR = polar(cx, cy, rOut, thetaDeg + outer)
|
||||||
|
val innerR = polar(cx, cy, rIn, thetaDeg + inner)
|
||||||
|
return roundedPoly(arrayOf(innerL, outerL, outerR, innerR), r * 0.09f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A closed polygon through [v] with every corner rounded to radius [corner]. */
|
||||||
|
private fun roundedPoly(v: Array<Offset>, corner: Float): Path {
|
||||||
|
val n = v.size
|
||||||
|
val path = Path()
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val curr = v[i]
|
||||||
|
val prev = v[(i + n - 1) % n]
|
||||||
|
val next = v[(i + 1) % n]
|
||||||
|
val a = curr + toward(curr, prev, corner) // approach point on the incoming edge
|
||||||
|
val b = curr + toward(curr, next, corner) // leave point on the outgoing edge
|
||||||
|
if (i == 0) path.moveTo(a.x, a.y) else path.lineTo(a.x, a.y)
|
||||||
|
path.quadraticBezierTo(curr.x, curr.y, b.x, b.y)
|
||||||
|
}
|
||||||
|
path.close()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vector of length [len] from [from] toward [to]. */
|
||||||
|
private fun toward(from: Offset, to: Offset, len: Float): Offset {
|
||||||
|
val dx = to.x - from.x; val dy = to.y - from.y
|
||||||
|
val d = hypot(dx, dy).coerceAtLeast(1e-3f)
|
||||||
|
return Offset(dx / d * len, dy / d * len)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun polar(cx: Float, cy: Float, rad: Float, deg: Float): Offset {
|
||||||
|
val a = Math.toRadians(deg.toDouble())
|
||||||
|
return Offset(cx + rad * cos(a).toFloat(), cy + rad * sin(a).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val KNOB_SIZE = 58.dp // a touch bigger than the default 44 dp knob
|
||||||
|
private const val DEG_PER_FRAME = 6f // ~1 rev/sec at 60 fps and full speed
|
||||||
|
private const val BRAKE_SPEED = 0.5f // target while a reel is held (≈ one octave down)
|
||||||
|
private const val REEL_L_X = 0.30f
|
||||||
|
private const val REEL_R_X = 0.70f
|
||||||
|
private const val REEL_CY = 0.38f // reel centre as a fraction of the canvas height
|
||||||
@@ -39,7 +39,9 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -241,26 +243,42 @@ private fun SlotTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A simple overlay list of all available instruments and effects to load. */
|
/** A simple overlay list of all available devices, grouped into instruments, MIDI
|
||||||
|
* effects and audio effects (separated by a gap + header). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
private fun DevicePicker(onDismiss: () -> Unit, onPick: (ToolboxType) -> Unit) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
|
// MIDI effects alter note generation (handled by the sequencer); every other
|
||||||
|
// effect processes audio. Split the two so the list reads in three clear groups.
|
||||||
|
val midiEffects = setOf(ToolboxType.ARPEGGIATOR, ToolboxType.TRANSPOSER, ToolboxType.LFO)
|
||||||
|
val groups = listOf(
|
||||||
|
"INSTRUMENTS" to ToolboxType.entries.filter { it.isInstrument },
|
||||||
|
"MIDI EFFECTS" to ToolboxType.entries.filter { !it.isInstrument && it in midiEffects },
|
||||||
|
"AUDIO EFFECTS" to ToolboxType.entries.filter { !it.isInstrument && it !in midiEffects },
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
|
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
|
||||||
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.fillMaxHeight().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||||
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
|
Text("SELECT DEVICE", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 16.sp)
|
||||||
ToolboxType.entries.forEach { type ->
|
groups.forEach { (header, types) ->
|
||||||
|
Spacer(Modifier.height(16.dp)) // visual gap between groups
|
||||||
Text(
|
Text(
|
||||||
"[${type.kind.name.take(3)}] ${type.displayName}",
|
header, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
|
||||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
modifier = Modifier.padding(bottom = 2.dp),
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.pointerInput(type) { detectTapGestures { onPick(type) } }
|
|
||||||
.padding(vertical = 6.dp),
|
|
||||||
)
|
)
|
||||||
|
types.forEach { type ->
|
||||||
|
Text(
|
||||||
|
type.displayName,
|
||||||
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 18.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pointerInput(type) { detectTapGestures { onPick(type) } }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,12 +295,19 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
|||||||
// the toolbox / tracker keyboards), so their controls scroll in a weighted region
|
// the toolbox / tracker keyboards), so their controls scroll in a weighted region
|
||||||
// above the keyboard rather than the whole editor scrolling as one.
|
// above the keyboard rather than the whole editor scrolling as one.
|
||||||
val pinnedKeyboard = type == ToolboxType.SOUNDFONT || type == ToolboxType.NES_SYNTH
|
val pinnedKeyboard = type == ToolboxType.SOUNDFONT || type == ToolboxType.NES_SYNTH
|
||||||
|
// The Sampler pins its 16-pad grid at the bottom in that same region, so the
|
||||||
|
// playable pads are the same height as the keyboards across every instrument editor.
|
||||||
|
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))) {
|
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(if (pinnedKeyboard) 1.7f else 1f)
|
.weight(if (pinnedBottom) 1.7f else 1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(12.dp)
|
.padding(12.dp)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
@@ -290,8 +315,13 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
|||||||
) {
|
) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
|
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
|
||||||
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
// Right-side toolbar: MIDI-learn (map controls to params) + close.
|
||||||
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
|
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
Text("⇄ MIDI", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.pointerInput(Unit) { detectTapGestures { showMidiLearn = true } })
|
||||||
|
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preset browser: load / save / import / share for this device.
|
// Preset browser: load / save / import / share for this device.
|
||||||
@@ -304,7 +334,9 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
|||||||
when (type) {
|
when (type) {
|
||||||
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
|
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
|
||||||
ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(vm, slot)
|
ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(vm, slot)
|
||||||
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
|
ToolboxType.TAPE_ENGINE -> TapeEngineEditor(vm, slot)
|
||||||
|
ToolboxType.GRANULAR -> GranularEditor(vm, slot)
|
||||||
|
ToolboxType.SAMPLER -> SamplerEditor(vm, slot, samplerPad, onSelectPad = { samplerPad = it })
|
||||||
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
|
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
|
||||||
ToolboxType.DELAY -> DelayEditor(vm, slot)
|
ToolboxType.DELAY -> DelayEditor(vm, slot)
|
||||||
ToolboxType.AMBIENCE -> AmbiencePager(vm, slot)
|
ToolboxType.AMBIENCE -> AmbiencePager(vm, slot)
|
||||||
@@ -316,9 +348,92 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pinnedKeyboard) {
|
if (pinnedBottom) {
|
||||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||||
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
|
if (type == ToolboxType.SAMPLER) {
|
||||||
|
SamplerPadBank(vm, slot, samplerPad, onSelect = { samplerPad = it }, Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +443,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
|||||||
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last
|
* single stacked column. Each [ParamControl] fills its half-width cell (an odd last
|
||||||
* param gets an empty cell beside it). */
|
* param gets an empty cell beside it). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
|
internal fun ParamGrid(vm: AppViewModel, slot: ToolboxSlot, params: List<ParamSpec>) {
|
||||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < params.size) {
|
while (i < params.size) {
|
||||||
|
|||||||
@@ -94,20 +94,19 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
val glyphs = remember(measurer) { GlyphCache(measurer) }
|
||||||
|
|
||||||
val beatWidth = 20.dp
|
val beatWidth = 20.dp
|
||||||
val muteColWidth = 34.dp // wide enough to hit comfortably from the screen edge
|
val muteColWidth = 51.dp // ~1.5× — wider, easy to hit from the screen edge
|
||||||
val laneColWidth = 68.dp // mute toggle + block number
|
val laneColWidth = 102.dp // mute toggle (51) + block number header (51), both ~1.5×
|
||||||
val rulerHeight = 18.dp
|
val rulerHeight = 18.dp
|
||||||
val beatWidthPx = with(density) { beatWidth.toPx() }
|
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||||
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||||
|
|
||||||
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none.
|
// Rectangular cell selection lives on the ViewModel so the transport (Play/Stop)
|
||||||
// Unlike before, selecting does NOT change any cell; the toolbar toggle acts
|
// can read and clear it — Play starts from the selection's first beat, Stop clears
|
||||||
// on the selection. Read in composition (for the toolbar's FILL/CLEAR label)
|
// it. Selecting still does NOT change any cell; the toolbar toggle acts on it.
|
||||||
// and captured by the Canvas draw below.
|
val selLane0 = vm.arrSelLane0
|
||||||
var selLane0 by remember { mutableIntStateOf(-1) }
|
val selBeat0 = vm.arrSelBeat0
|
||||||
var selBeat0 by remember { mutableIntStateOf(-1) }
|
val selLane1 = vm.arrSelLane1
|
||||||
var selLane1 by remember { mutableIntStateOf(-1) }
|
val selBeat1 = vm.arrSelBeat1
|
||||||
var selBeat1 by remember { mutableIntStateOf(-1) }
|
|
||||||
|
|
||||||
val hasSelection = selBeat0 >= 0
|
val hasSelection = selBeat0 >= 0
|
||||||
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
|
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
|
||||||
@@ -227,8 +226,7 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
||||||
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||||
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||||
selLane0 = lane; selLane1 = lane
|
vm.setArrangementSelection(lane, beat, lane, beat)
|
||||||
selBeat0 = beat; selBeat1 = beat
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Long-press then drag sweeps out a rectangular selection.
|
// Long-press then drag sweeps out a rectangular selection.
|
||||||
@@ -240,14 +238,16 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
fun beatAt(off: Offset): Int =
|
fun beatAt(off: Offset): Int =
|
||||||
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||||
|
var dragLane0 = 0; var dragBeat0 = 0
|
||||||
detectDragGesturesAfterLongPress(
|
detectDragGesturesAfterLongPress(
|
||||||
onDragStart = { off ->
|
onDragStart = { off ->
|
||||||
selLane0 = laneAt(off); selLane1 = selLane0
|
dragLane0 = laneAt(off); dragBeat0 = beatAt(off)
|
||||||
selBeat0 = beatAt(off); selBeat1 = selBeat0
|
vm.setArrangementSelection(dragLane0, dragBeat0, dragLane0, dragBeat0)
|
||||||
},
|
},
|
||||||
onDrag = { change, _ ->
|
onDrag = { change, _ ->
|
||||||
selLane1 = laneAt(change.position)
|
vm.setArrangementSelection(
|
||||||
selBeat1 = beatAt(change.position)
|
dragLane0, dragBeat0, laneAt(change.position), beatAt(change.position),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package space.rcmd.android.sizzle.ui.tracker
|
package space.rcmd.android.sizzle.ui.tracker
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -19,8 +20,10 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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.
|
// Fixed width so the button doesn't resize as the label toggles.
|
||||||
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||||
active = isPlaying, width = 112.dp, onClick = vm::playPause)
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
|
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
|
||||||
Text(
|
Text(
|
||||||
"${project.tempoBpm.toInt()} BPM",
|
"${project.tempoBpm.toInt()} BPM",
|
||||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
textAlign = TextAlign.Center, maxLines = 1,
|
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) })
|
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.
|
// Swap the bottom half between the arranger and the punch-in keyboard.
|
||||||
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
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,73 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package space.rcmd.android.sizzle.audio
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the gate-vs-release distinction the audition note-off relies on. A voice in
|
||||||
|
* its release tail must report [SynthVoice.isActive] == true (still sounding) but
|
||||||
|
* [SynthVoice.isGated] == false, so a note-off preferring a gated voice never lands
|
||||||
|
* on a releasing one — the fix for stuck notes under rapid / chord tapping.
|
||||||
|
*/
|
||||||
|
class SynthVoiceGateTest {
|
||||||
|
|
||||||
|
private val sr = 48_000
|
||||||
|
|
||||||
|
@Test fun heldNoteIsGated() {
|
||||||
|
val v = SynthVoice(sr)
|
||||||
|
v.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||||
|
assertTrue("a freshly triggered note is gated", v.isGated)
|
||||||
|
assertTrue(v.isActive)
|
||||||
|
// Advance well past the attack/decay into sustain — still held.
|
||||||
|
repeat(sr / 2) { v.render() }
|
||||||
|
assertTrue("a sustaining note is still gated", v.isGated)
|
||||||
|
assertTrue(v.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun releasingNoteIsActiveButNotGated() {
|
||||||
|
val v = SynthVoice(sr)
|
||||||
|
v.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||||
|
repeat(sr / 4) { v.render() } // reach sustain
|
||||||
|
v.noteOff()
|
||||||
|
// One sample into the release: still audible, but no longer gated.
|
||||||
|
v.render()
|
||||||
|
assertTrue("a releasing voice is still active (audible tail)", v.isActive)
|
||||||
|
assertFalse("a releasing voice is no longer gated", v.isGated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun releaseReachesSilence() {
|
||||||
|
val v = SynthVoice(sr)
|
||||||
|
v.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||||
|
repeat(sr / 4) { v.render() }
|
||||||
|
v.noteOff()
|
||||||
|
// DEFAULT release is 0.2 s; render a full second to be safe.
|
||||||
|
repeat(sr) { v.render() }
|
||||||
|
assertFalse("the voice frees itself after the release completes", v.isActive)
|
||||||
|
assertFalse(v.isGated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stuck-note scenario at the voice level: a releasing voice and a held voice
|
||||||
|
* of the same pitch coexist; selecting the gated one for note-off leaves no held
|
||||||
|
* voice behind (whereas picking the first *active* match would strand it). */
|
||||||
|
@Test fun gatedPreferenceReleasesTheHeldVoice() {
|
||||||
|
val releasing = SynthVoice(sr)
|
||||||
|
val held = SynthVoice(sr)
|
||||||
|
releasing.noteOn(60, 100, SynthPatch.DEFAULT)
|
||||||
|
repeat(sr / 4) { releasing.render() }
|
||||||
|
releasing.noteOff()
|
||||||
|
releasing.render() // now active-but-not-gated
|
||||||
|
held.noteOn(60, 100, SynthPatch.DEFAULT) // same pitch, gated
|
||||||
|
|
||||||
|
val pool = listOf(releasing, held)
|
||||||
|
// Mirror auditionSlotOff's selection: prefer a gated voice of this pitch.
|
||||||
|
val target = pool.firstOrNull { it.pitch == 60 && it.isGated }
|
||||||
|
?: pool.firstOrNull { it.isActive && it.pitch == 60 }
|
||||||
|
target?.noteOff()
|
||||||
|
|
||||||
|
assertFalse("the held voice received the note-off and is releasing", held.isGated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package space.rcmd.android.sizzle.io
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
|
/** Round-trip + safety coverage for the whole-app [BackupIo]. */
|
||||||
|
class BackupIoTest {
|
||||||
|
|
||||||
|
private fun tempDir(): File = Files.createTempDirectory("backup-test").toFile()
|
||||||
|
|
||||||
|
private fun seed(dir: File) {
|
||||||
|
File(dir, "songs").mkdirs()
|
||||||
|
File(dir, "songs/tune.szg").writeText("SONG-DATA")
|
||||||
|
File(dir, "presets/DELAY").mkdirs()
|
||||||
|
File(dir, "presets/DELAY/slap.szp").writeText("PRESET-DATA")
|
||||||
|
File(dir, "datastore").mkdirs()
|
||||||
|
File(dir, "datastore/app_settings.preferences_pb").writeBytes(byteArrayOf(1, 2, 3, 4))
|
||||||
|
File(dir, "autosave.sng").writeText("AUTO-DATA")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun backupRestoreRoundTripReplacesContent() {
|
||||||
|
val source = tempDir().also { seed(it) }
|
||||||
|
val zip = ByteArrayOutputStream().also { BackupIo.write(source, it) }.toByteArray()
|
||||||
|
|
||||||
|
// Restore into a dir that already holds different + stale data.
|
||||||
|
val target = tempDir()
|
||||||
|
File(target, "songs").mkdirs()
|
||||||
|
File(target, "songs/stale.szg").writeText("STALE") // not in the backup → must go
|
||||||
|
|
||||||
|
assertTrue(BackupIo.read(target, ByteArrayInputStream(zip)))
|
||||||
|
|
||||||
|
assertEquals("SONG-DATA", File(target, "songs/tune.szg").readText())
|
||||||
|
assertFalse("a song absent from the backup is removed", File(target, "songs/stale.szg").exists())
|
||||||
|
assertEquals("PRESET-DATA", File(target, "presets/DELAY/slap.szp").readText())
|
||||||
|
assertEquals("AUTO-DATA", File(target, "autosave.sng").readText())
|
||||||
|
assertArrayEquals(byteArrayOf(1, 2, 3, 4), File(target, "datastore/app_settings.preferences_pb").readBytes())
|
||||||
|
assertFalse("staging is cleaned up", File(target, ".restore_tmp").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun invalidArchiveLeavesDataIntact() {
|
||||||
|
val target = tempDir()
|
||||||
|
File(target, "songs").mkdirs()
|
||||||
|
File(target, "songs/keep.szg").writeText("KEEP")
|
||||||
|
|
||||||
|
assertFalse(BackupIo.read(target, ByteArrayInputStream("this is not a zip".toByteArray())))
|
||||||
|
assertEquals("existing data survives a bad restore file", "KEEP", File(target, "songs/keep.szg").readText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun emptyZipIsRejected() {
|
||||||
|
val emptyZip = ByteArrayOutputStream().also { java.util.zip.ZipOutputStream(it).close() }.toByteArray()
|
||||||
|
val target = tempDir()
|
||||||
|
File(target, "songs").mkdirs()
|
||||||
|
File(target, "songs/keep.szg").writeText("KEEP")
|
||||||
|
|
||||||
|
assertFalse(BackupIo.read(target, ByteArrayInputStream(emptyZip)))
|
||||||
|
assertEquals("KEEP", File(target, "songs/keep.szg").readText())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertArrayEquals(a: ByteArray, b: ByteArray) =
|
||||||
|
assertTrue("byte arrays differ", a.contentEquals(b))
|
||||||
|
}
|
||||||
@@ -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