Compare commits
9 Commits
c4f4cf34c0
...
8eee588780
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 = 30
|
||||||
versionName = "0.7.1"
|
versionName = "0.11.2"
|
||||||
|
|
||||||
// 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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -153,6 +154,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
|
||||||
@@ -508,7 +538,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 +588,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 +621,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 +631,69 @@ 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
@@ -764,6 +858,11 @@ 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
|
||||||
|
// 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]
|
||||||
@@ -794,19 +893,33 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
} 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 +927,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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1016,10 +1131,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)
|
||||||
@@ -1225,11 +1345,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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +50,7 @@ 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +77,17 @@ 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 }
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ 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()
|
||||||
|
}
|
||||||
|
|
||||||
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 }
|
||||||
@@ -212,6 +221,12 @@ 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) }
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -774,6 +789,7 @@ 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
|
||||||
engine.recTempDir = appContext.cacheDir
|
engine.recTempDir = appContext.cacheDir
|
||||||
engine.setRecordTailBars(recordTailBars)
|
engine.setRecordTailBars(recordTailBars)
|
||||||
engine.onRecordingStarted = {
|
engine.onRecordingStarted = {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -47,14 +40,10 @@ 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).
|
||||||
val inKey: Set<Int>? = if (punchIn) {
|
|
||||||
val root = vm.project.rootNote
|
val root = vm.project.rootNote
|
||||||
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
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 +78,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,
|
|
||||||
selected = false,
|
|
||||||
pressed = vm.isNoteHeld(midi),
|
|
||||||
modifier = if (!enabled) keyMod else keyMod.pointerInput(Unit) {
|
|
||||||
awaitEachGesture {
|
|
||||||
awaitFirstDown()
|
|
||||||
val n = liveMidi.value
|
|
||||||
if (livePunch.value) vm.punchNote(n)
|
|
||||||
vm.kbdNoteOn(n)
|
vm.kbdNoteOn(n)
|
||||||
waitForUpOrCancellation()
|
|
||||||
vm.kbdNoteOff(n)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
onNoteOff = { pc -> vm.kbdNoteOff(midiOf(pc)) },
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +94,9 @@ 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 {
|
item {
|
||||||
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
|
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
|
||||||
@@ -368,6 +372,73 @@ private fun AudioDevicesCard(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interface options: currently the mixer VU-meter toggle. */
|
||||||
|
@Composable
|
||||||
|
private fun InterfaceCard(vm: AppViewModel) {
|
||||||
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
|
||||||
|
SettingsCard("Interface", subtitle = "On-screen display options") {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Mixer VU meters", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
"Show live level meters inside the mixer volume faders.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = vm.vuMeters, onCheckedChange = { vm.updateVuMeters(it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThemeCard(vm: AppViewModel) {
|
private fun ThemeCard(vm: AppViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -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 pagerState = rememberPagerState(pageCount = { 2 })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
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 sample = SampleStore.get(sampleId)
|
||||||
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||||
var dragging by remember { mutableIntStateOf(-1) }
|
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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.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
|
||||||
@@ -277,12 +278,17 @@ 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) }
|
||||||
|
|
||||||
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()),
|
||||||
@@ -304,7 +310,7 @@ 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.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,12 +322,16 @@ 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
|
||||||
|
if (type == ToolboxType.SAMPLER) {
|
||||||
|
SamplerPadBank(vm, slot, samplerPad, onSelect = { samplerPad = it }, Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
|
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lay a list of [params] out two-per-row so the editor fits more controls than a
|
/** Lay a list of [params] out two-per-row so the editor fits more controls than a
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user