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>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 21:53:35 +02:00
parent a5b9837bbc
commit b07d758609
4 changed files with 149 additions and 25 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 = 24 versionCode = 25
versionName = "0.8.1" versionName = "0.9.0"
// 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

@@ -172,6 +172,17 @@ class AudioEngine(
private var limOutL = 0f private var limOutL = 0f
private var limOutR = 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
@@ -577,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
@@ -609,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
@@ -625,8 +640,25 @@ class AudioEngine(
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 * Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread. * [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.
@@ -1307,6 +1339,11 @@ class AudioEngine(
private const val LIM_HOLD_EXTRA = 8 // extra hold beyond the window so the peak fully clears 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 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

@@ -212,6 +212,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.

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,23 @@ 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.
val levels = remember { List(Pattern.TRACK_COUNT) { mutableFloatStateOf(0f) } }
val clips = remember { List(Pattern.TRACK_COUNT) { mutableStateOf(false) } }
LaunchedEffect(Unit) {
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 +89,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],
Modifier.weight(1f).fillMaxHeight())
} }
} }
} }
@@ -124,7 +152,13 @@ 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>,
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 +182,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) }
} }
} 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)
} }
} }
} }
@@ -205,13 +239,20 @@ 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>,
) {
@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,
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 +269,67 @@ 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>,
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.
val lvl = level.floatValue.coerceIn(0f, 1f)
val meterFrac = if (lvl < vol) lvl else vol // stay inside the filled area
if (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 +339,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