Compare commits

...

9 Commits

Author SHA1 Message Date
Reactorcoremeltdown
8eee588780 Point Help user-guide link at the guide's heading anchor (v0.11.2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:37:41 +02:00
Reactorcoremeltdown
40a946a894 Add Help section to Settings (about, source, guide, support) (v0.11.1)
A new Help card under Project shows the app name + live version (read from the
package, so it never drifts from the build) and links to the source repo, the
user guide, and the support page. Each link is a full-width ~64 dp row with a
large label, the destination URL beneath, and a chevron affordance — a
comfortable thumb target that opens in the system browser (missing-browser safe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:25:37 +02:00
Reactorcoremeltdown
3508777ba9 Mono-per-track: a new note releases the track's previous one (v0.10.0)
A sustaining note held with no explicit note-off used to pile up until
voice-stealing killed it. Now, when a note appears under the playhead on a
track, that track's still-held note is released automatically, so a line moves
note-to-note like a real monophonic voice.

Each sequencer voice carries an ownerTrack tag (the lane+track that started it),
and triggerCell calls releaseTrackVoices(trackKey) before playing a new note. It
releases only voices this track owns and only gated (still-held) ones, so chords
built across several tracks on the same bus keep sounding and release tails ring
out. Uses note-off (not kill) for a click-free legato hand-off. Arp voices are
unowned and keep self-managing as mono-per-bus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:55:51 +02:00
Reactorcoremeltdown
7a0f2e4426 Make mixer VU meters optional via a Settings toggle (v0.9.1)
Add a persisted "Mixer VU meters" switch (Settings -> Interface, on by default),
backed by a DataStore boolean. When off, the mixer skips the per-frame poll loop
entirely (the LaunchedEffect is keyed on the flag) and doesn't draw the meter, so
disabling it removes the metering cost, not just the visuals; the volume fill and
thumb render exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:54:07 +02:00
Reactorcoremeltdown
b07d758609 Add per-channel VU meters inside the mixer faders (v0.9.0)
The render loop now tracks each bus's post-fader/post-FX peak per block and folds
it into a decaying meter value (instant attack, ~300 ms release) plus a clip
latch (~0.8 s when the bus crosses 0 dBFS). Exposed via channelMeter /
channelClipped and surfaced on the ViewModel.

Each mixer fader paints, in one drawBehind, a VU meter inside its volume fill:
rising from the bottom, coloured green -> amber by absolute level with a red tip,
and solid red while the bus clips. A per-frame ticker writes into stable state
objects read only in the fader's draw phase, so meter movement repaints just the
four faders (no recomposition); an unchanged write is a no-op, so a silent mixer
costs nothing but the frame tick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:53:35 +02:00
Reactorcoremeltdown
a5b9837bbc Fix stuck audition notes under rapid / chord tapping (v0.8.1)
The synth/SF2 audition note-off matched a voice purely by pitch, and isActive
stays true through a voice's release tail. So tapping a key, then immediately
holding it again, landed the note-off on the still-releasing first voice (a
no-op) and left the held one stuck, needing a MIDI panic. Two fingers on one
key hit the same trap.

Give voices an isGated flag (held = ADSR not in release/idle) and make
auditionSlotOff / liveNoteOff release a gated voice of that pitch in preference
to one already ringing out. Add SynthVoiceGateTest covering the gate/release
distinction and the exact stuck-note scenario.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:51:41 +02:00
Reactorcoremeltdown
5943040a2b Louder master via makeup gain + look-ahead limiter (v0.8.0)
The master stage was a flat 0.5x attenuation into a static tanh soft-clip,
leaving ~9 dB of unused headroom: even the loudest single preset with its mix
bus maxed peaked around -7 dBFS, well below other media playback.

Replace it with a generous makeup gain (1.5x, ~+9.5 dB) feeding a feed-forward,
look-ahead brickwall limiter. A short delay line lets the peak detector pull the
gain down before a loud sample reaches the output and hold it across the
look-ahead window, so the output is mathematically guaranteed to stay under the
ceiling (0.97) - loudness without clipping, and none of the tanh grit a static
clipper adds to a hot single voice. Allocation-free (results go to scratch
fields, not a Pair) and NaN-guarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:50:39 +02:00
Reactorcoremeltdown
b7ae8cfbff Glissando: slide across stacked keyboards to play (v0.7.3)
Input moves from per-key gestures to one octave-level multi-touch handler in
PianoOctave, so a finger can slide across keys and each note retriggers as it
crosses a key boundary (black keys win in their upper region via geometry-aware
hit-testing); multiple fingers still play independently for chords. Out-of-scale
keys are skipped mid-slide. onNoteOn carries a `slid` flag so the tracker
punch-in keyboard records only the initial press (a slide auditions a glide
without spraying a run of notes into the pattern), while the toolbox / editor
audition keyboards play every key crossed. StackedKeyboard and SlotAuditionKeyboard
both migrate to the new callback API; PianoOctave now renders the keys itself.

Verified on-device: a slow slide lights each in-scale key as the finger reaches
it and skips out-of-scale keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:19:00 +02:00
Reactorcoremeltdown
3c0b93855b Scale-filter toolbox keyboards; pin sampler pads at keyboard height (v0.7.2)
- Scale/root filtering on the Toolbox audition keyboards, matching the tracker
  punch-in keyboard: out-of-scale keys are dimmed and inert (Chromatic = all
  playable). StackedKeyboard now filters in both punch-in and audition mode,
  and SlotAuditionKeyboard (synth / SF2 editors) gained the same filter.
- Sampler editor restructured so the 16-pad grid is pinned at the bottom at the
  same height the audition keyboards use, consistent across every instrument
  editor. The per-pad volume faders moved to a second swipe page (waveform on
  page 1, volumes on page 2, with dot pagination), and pad cells now fill the
  pinned region instead of a fixed 46dp. Selected-pad state is hoisted into
  ParamEditor so the detail area and the pinned grid stay in sync.

Verified on-device: audition keyboard dims to F harmonic minor; sampler pads
fill the pinned bottom region with volumes on the swipe page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:05:53 +02:00
14 changed files with 698 additions and 164 deletions

View File

@@ -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"

View File

@@ -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
/** /**

View File

@@ -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). */

View File

@@ -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

View File

@@ -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")
} }
} }

View File

@@ -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 = {

View File

@@ -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(),
) )
}
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}
},
) )
}
} }

View File

@@ -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

View File

@@ -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)
}
}