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.
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 20
|
||||
versionName = "0.7.1"
|
||||
versionCode = 30
|
||||
versionName = "0.11.2"
|
||||
|
||||
// We provide our own instrumentation runner if/when tests are added.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@@ -153,6 +154,35 @@ class AudioEngine(
|
||||
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
||||
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:
|
||||
// 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
|
||||
@@ -508,7 +538,10 @@ class AudioEngine(
|
||||
}
|
||||
|
||||
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
|
||||
@@ -555,6 +588,7 @@ class AudioEngine(
|
||||
// note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
|
||||
blockGen++
|
||||
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 sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
|
||||
for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
|
||||
@@ -587,6 +621,9 @@ class AudioEngine(
|
||||
if (!s.isFinite()) s = 0f
|
||||
// Optional per-bus soft-clip limiter (toolbar toggle).
|
||||
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
|
||||
// sum. The master output/recording path stays stereo (2 channels).
|
||||
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 actASmpN) { val v = auditionSampleVoices[actASmp[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
|
||||
val oL = softClip(mixL * MASTER_GAIN)
|
||||
val oR = softClip(mixR * MASTER_GAIN)
|
||||
// Makeup gain (lifts instruments to media loudness) into a look-ahead
|
||||
// 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 + 1] = 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
|
||||
@@ -764,6 +858,11 @@ class AudioEngine(
|
||||
if (!cell.isPlayable) return
|
||||
// Remember the channel so a later note-off on this track releases it.
|
||||
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) {
|
||||
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
|
||||
val arp = arps[lane * Pattern.TRACK_COUNT + bus]
|
||||
@@ -794,19 +893,33 @@ class AudioEngine(
|
||||
}
|
||||
} else {
|
||||
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
|
||||
* 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,
|
||||
* stealing the oldest when full) is what makes the instrument polyphonic. Shared
|
||||
* 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
|
||||
// into this block's active list, so a note started mid-block sounds sample-
|
||||
// accurately within the same block.
|
||||
@@ -814,14 +927,16 @@ class AudioEngine(
|
||||
val vs = voiceSample(slot, note)
|
||||
when {
|
||||
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)
|
||||
// Empty bus (no instrument — e.g. the default channel 0) or a Sampler
|
||||
// with an empty pad → silent, matching the live audition path.
|
||||
slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
|
||||
// NES synth, or a SoundFont with nothing loaded → the tone generator.
|
||||
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))
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
liveVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
||||
auditionSampleVoices.firstOrNull { it.isActive && it.pitch == midi }?.noteOff()
|
||||
(liveVoices.firstOrNull { it.pitch == midi && it.isGated }
|
||||
?: 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)
|
||||
@@ -1225,11 +1345,26 @@ class AudioEngine(
|
||||
private const val LIVE_POLYPHONY = 8
|
||||
private const val AUDITION_POLYPHONY = 8
|
||||
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)
|
||||
// so playback sits closer to other media apps (it had noticeably more headroom
|
||||
// than typical music playback); the soft-knee limiter below still catches peaks.
|
||||
private const val MASTER_GAIN = 0.5f
|
||||
// Master make-up gain into the look-ahead brickwall limiter ([masterLimit]).
|
||||
// The old path was a flat 0.5× attenuation into a static soft-clip, which left
|
||||
// ~9 dB of unused headroom: even the loudest single preset with its mix bus
|
||||
// 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
|
||||
// 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
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,10 @@ class SampleVoice(private val engineSampleRate: Int) {
|
||||
private var endIndex = 0
|
||||
private var amp = 0f
|
||||
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,
|
||||
// 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
|
||||
|
||||
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.
|
||||
@@ -139,7 +147,7 @@ class SampleVoice(private val engineSampleRate: Int) {
|
||||
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
|
||||
* 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
|
||||
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 baseFreq = 440.0
|
||||
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()
|
||||
|
||||
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]. */
|
||||
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
|
||||
@@ -79,6 +88,7 @@ class SynthVoice(private val sampleRate: Int) {
|
||||
fun kill() {
|
||||
ampEnv.kill(); filtEnv.kill()
|
||||
pitch = -1
|
||||
ownerTrack = -1
|
||||
icB = 0f; icL = 0f
|
||||
}
|
||||
|
||||
@@ -254,6 +264,8 @@ class SynthVoice(private val sampleRate: Int) {
|
||||
private var level = 0f
|
||||
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
|
||||
/** 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) {
|
||||
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 androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
@@ -26,6 +27,8 @@ data class AppSettings(
|
||||
val kbdChannel: Int = 0,
|
||||
/** Base octave for the on-screen keyboards. */
|
||||
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,
|
||||
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
|
||||
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 {
|
||||
val PALETTE = stringPreferencesKey("palette")
|
||||
val RECORD_DIR = stringPreferencesKey("recordDirUri")
|
||||
val RECORD_TAIL = intPreferencesKey("recordTailBars")
|
||||
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
|
||||
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
|
||||
val VU_METERS = booleanPreferencesKey("vuMeters")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,15 @@ class AppViewModel(
|
||||
var kbdOctave by mutableIntStateOf(3); private set
|
||||
/** Velocity for keyboard notes (0..127). */
|
||||
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 togglePunchIn() { punchInMode = !punchInMode }
|
||||
@@ -212,6 +221,12 @@ class AppViewModel(
|
||||
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
||||
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.
|
||||
* 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.
|
||||
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
|
||||
kbdOctave = s.kbdOctave.coerceIn(0, 8)
|
||||
vuMeters = s.vuMeters
|
||||
engine.recTempDir = appContext.cacheDir
|
||||
engine.setRecordTailBars(recordTailBars)
|
||||
engine.onRecordingStarted = {
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
package space.rcmd.android.sizzle.ui
|
||||
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -17,17 +14,13 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.RepeatButton
|
||||
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) {
|
||||
val c = LocalRetro.current
|
||||
@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);
|
||||
// the toolbox audition keyboard stays fully chromatic (null = no filter).
|
||||
val inKey: Set<Int>? = if (punchIn) {
|
||||
val root = vm.project.rootNote
|
||||
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Only in-scale keys are playable on BOTH the tracker punch-in and the toolbox
|
||||
// audition keyboard (Chromatic yields all twelve = every key stays enabled).
|
||||
val root = vm.project.rootNote
|
||||
val inKey: Set<Int> = vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
|
||||
Column(
|
||||
modifier.fillMaxWidth().background(c.background).padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
@@ -89,37 +78,29 @@ private fun Selector(label: String, value: String, onDec: () -> Unit, onInc: ()
|
||||
RepeatButton("+", onStep = onInc)
|
||||
}
|
||||
|
||||
/** One octave; hold a key to audition (and, in punch-in mode, record) the note.
|
||||
* When [inKey] is non-null, out-of-scale keys are dimmed and inert. */
|
||||
/** One octave; hold or SLIDE across keys to audition (and, in punch-in mode, record)
|
||||
* 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
|
||||
private fun KeyboardOctave(
|
||||
vm: AppViewModel,
|
||||
punchIn: Boolean,
|
||||
inKey: Set<Int>?,
|
||||
inKey: Set<Int>,
|
||||
startMidi: Int,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
|
||||
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
val enabled = inKey == null || pc in inKey
|
||||
val liveMidi = rememberUpdatedState(midi)
|
||||
val livePunch = rememberUpdatedState(punchIn)
|
||||
PianoKey(
|
||||
label = Pitch.name(midi).replace("-", ""),
|
||||
isBlack = isBlack,
|
||||
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)
|
||||
waitForUpOrCancellation()
|
||||
vm.kbdNoteOff(n)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
PianoOctave(
|
||||
label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") },
|
||||
enabled = { pc -> pc in inKey },
|
||||
pressed = { pc -> vm.isNoteHeld(midiOf(pc)) },
|
||||
onNoteOn = { pc, slid ->
|
||||
val n = midiOf(pc)
|
||||
if (punchIn && !slid) vm.punchNote(n)
|
||||
vm.kbdNoteOn(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.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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
|
||||
* white keys it sits between (C# D# _ F# G# A#).
|
||||
*
|
||||
* Layout-only: the [key] slot renders each key and callers attach their own gesture
|
||||
* to [keyModifier] (tap-to-enter, hold-to-audition, …). Black keys are emitted last
|
||||
* so they draw — and receive touches — on top of the white keys they overlap, just
|
||||
* like a physical keyboard.
|
||||
* Input is handled at the octave level so a finger can **slide across keys** — a
|
||||
* glissando: the note under each finger retriggers as it crosses a key boundary
|
||||
* (black keys win in their upper region, exactly like a physical keyboard), and
|
||||
* 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
|
||||
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,
|
||||
blackWidthFraction: 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 blackW = whiteW * blackWidthFraction
|
||||
val blackH = maxHeight * blackHeightFraction
|
||||
// White keys: C D E F G A B, filling the width evenly.
|
||||
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) {
|
||||
key(
|
||||
pc, true,
|
||||
Modifier
|
||||
PianoKey(
|
||||
label(pc), isBlack = true, enabled = enabled(pc), selected = false,
|
||||
modifier = Modifier
|
||||
.offset(x = whiteW * (afterWhite + 1) - blackW / 2)
|
||||
.width(blackW)
|
||||
.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
|
||||
* [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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
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.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.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -36,6 +45,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.Mixer
|
||||
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.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.isLandscape
|
||||
@@ -55,6 +65,30 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
fun MixerScreen(vm: AppViewModel) {
|
||||
val c = LocalRetro.current
|
||||
@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)) {
|
||||
RecordToolbar(vm)
|
||||
Row(
|
||||
@@ -62,7 +96,8 @@ fun MixerScreen(vm: AppViewModel) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
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
|
||||
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
|
||||
@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(
|
||||
Modifier.weight(1f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) { ChannelLevels(vm, channel) }
|
||||
) { ChannelLevels(vm, channel, level, clipped, showVu) }
|
||||
}
|
||||
} else {
|
||||
// Portrait: one column, the fader filling the leftover height.
|
||||
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
|
||||
* leftover height of its column. */
|
||||
@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
|
||||
SectionLabel("Volume")
|
||||
VerticalFader(
|
||||
value = channel.volume,
|
||||
default = Mixer.DEFAULT_VOLUME,
|
||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||
level = level,
|
||||
clipped = clipped,
|
||||
showVu = showVu,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
// 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.
|
||||
* Snaps to the [default] level within a small band so returning to the standard
|
||||
* volume is easy to hit. */
|
||||
/** A vertical volume fader with a live VU meter drawn inside its filled area: fill
|
||||
* grows from the bottom to the set [value]; tap or drag to set. The VU ([level], a
|
||||
* 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
|
||||
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 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(
|
||||
modifier
|
||||
.border(1.dp, c.grid, RectangleShape)
|
||||
.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) {
|
||||
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))
|
||||
}
|
||||
},
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -92,7 +94,9 @@ fun SettingsScreen(vm: AppViewModel) {
|
||||
) {
|
||||
item { PanicButton(vm) }
|
||||
item { ProjectCard(vm) }
|
||||
item { HelpCard() }
|
||||
item { AudioDevicesCard(vm) }
|
||||
item { InterfaceCard(vm) }
|
||||
item { ThemeCard(vm) }
|
||||
item {
|
||||
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
|
||||
private fun ThemeCard(vm: AppViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
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.Column
|
||||
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.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.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -26,6 +34,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.components.RetroButton
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
* 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
|
||||
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
fun SamplerEditor(vm: AppViewModel, slot: ToolboxSlot, pad: Int, onSelectPad: (Int) -> Unit) {
|
||||
val c = LocalRetro.current
|
||||
val context = LocalContext.current
|
||||
@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) }
|
||||
val sampleId = slot.string(SamplerPads.key(pad))
|
||||
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 -----
|
||||
val sample = SampleStore.get(sampleId)
|
||||
val peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||
var dragging by remember { mutableIntStateOf(-1) }
|
||||
// ----- 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 peaks = remember(sampleId, sample?.data?.size) { sample?.let { computePeaks(it.data, 400) } }
|
||||
var dragging by remember { mutableIntStateOf(-1) }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(110.dp)
|
||||
.height(150.dp)
|
||||
.background(c.surface)
|
||||
.pointerInput(sampleId, pad) {
|
||||
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",
|
||||
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
|
||||
private fun PadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, rev: Int, onSelect: (Int) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
fun SamplerPadBank(vm: AppViewModel, slot: ToolboxSlot, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier) {
|
||||
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) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
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(
|
||||
modifier
|
||||
.height(46.dp)
|
||||
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
||||
.background(fill)
|
||||
.pointerInput(pad, slot.index) {
|
||||
@@ -286,7 +344,7 @@ private fun VerticalPadSlider(
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(96.dp)
|
||||
.height(76.dp)
|
||||
.background(c.surface)
|
||||
.border(if (isSelected) 2.dp else 1.dp, border, RectangleShape)
|
||||
.pointerInput(pad, slot.index) {
|
||||
|
||||
@@ -8,11 +8,8 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
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.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -27,7 +24,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.ToolboxSlot
|
||||
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.RepeatButton
|
||||
import space.rcmd.android.sizzle.ui.components.RetroButton
|
||||
@@ -218,7 +213,11 @@ private fun formatSliderValue(v: Float): String =
|
||||
@Composable
|
||||
fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
|
||||
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) }
|
||||
// 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(
|
||||
modifier.fillMaxWidth().background(c.background).padding(6.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)
|
||||
RepeatButton("+") { octave = (octave + 1).coerceAtMost(8) }
|
||||
}
|
||||
AuditionOctave(vm, slot.index, (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 + 2) * 12, Modifier.weight(1f)) // upper octave on top
|
||||
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
|
||||
* keyboard's height (PianoOctave needs a bounded height to lay its keys out). */
|
||||
@Composable
|
||||
private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, startMidi: Int, modifier: Modifier) {
|
||||
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
|
||||
val midi = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
val liveMidi = rememberUpdatedState(midi)
|
||||
PianoKey(
|
||||
label = Pitch.name(midi).replace("-", ""),
|
||||
isBlack = isBlack,
|
||||
enabled = true,
|
||||
selected = false,
|
||||
pressed = vm.isNoteHeld(midi),
|
||||
modifier = keyMod.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
val n = liveMidi.value
|
||||
vm.auditionOn(slotIndex, n)
|
||||
waitForUpOrCancellation()
|
||||
vm.auditionOff(n)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
private fun AuditionOctave(vm: AppViewModel, slotIndex: Int, inKey: Set<Int>, startMidi: Int, modifier: Modifier) {
|
||||
fun midiOf(pc: Int) = (startMidi + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
|
||||
PianoOctave(
|
||||
label = { pc -> Pitch.name(midiOf(pc)).replace("-", "") },
|
||||
enabled = { pc -> pc in inKey },
|
||||
pressed = { pc -> vm.isNoteHeld(midiOf(pc)) },
|
||||
// Sliding across keys auditions each in turn (a glissando); a lifted finger
|
||||
// stops its note.
|
||||
onNoteOn = { pc, _ -> vm.auditionOn(slotIndex, midiOf(pc)) },
|
||||
onNoteOff = { pc -> vm.auditionOff(midiOf(pc)) },
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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
|
||||
// above the keyboard rather than the whole editor scrolling as one.
|
||||
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))) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier
|
||||
.weight(if (pinnedKeyboard) 1.7f else 1f)
|
||||
.weight(if (pinnedBottom) 1.7f else 1f)
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
@@ -304,7 +310,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
when (type) {
|
||||
ToolboxType.NES_SYNTH -> NesSynthEditor(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.DELAY -> DelayEditor(vm, slot)
|
||||
ToolboxType.AMBIENCE -> AmbiencePager(vm, slot)
|
||||
@@ -316,9 +322,13 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
if (pinnedKeyboard) {
|
||||
if (pinnedBottom) {
|
||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
|
||||
if (type == ToolboxType.SAMPLER) {
|
||||
SamplerPadBank(vm, slot, samplerPad, onSelect = { samplerPad = it }, Modifier.weight(1f))
|
||||
} else {
|
||||
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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