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.
minSdk = 26
targetSdk = 34
versionCode = 24
versionName = "0.8.1"
versionCode = 25
versionName = "0.9.0"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -172,6 +172,17 @@ class AudioEngine(
private var limOutL = 0f
private var limOutR = 0f
// ---- per-channel VU metering (for the mixer strips' fader VU) ----
// The audio thread tracks each bus's post-fader/post-FX peak within a block, then
// once per block folds it into a smoothly-decaying meter value (instant attack,
// exponential release) and a clip-hold counter (latched when the bus crosses 0 dBFS
// so a brief overload stays visible). The UI polls [channelMeter]/[channelClipped]
// at frame rate. Plain arrays: single writer (audio thread), display-only readers —
// a stale value for one frame is harmless, so no locking/volatility is needed.
private val chBlockPeak = FloatArray(Pattern.TRACK_COUNT) // scratch, reset each block
private val chMeter = FloatArray(Pattern.TRACK_COUNT) // published decaying peak
private val chClipHold = IntArray(Pattern.TRACK_COUNT) // blocks left to show "clipping"
// Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
// the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
// never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
@@ -577,6 +588,7 @@ class AudioEngine(
// note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
blockGen++
actSeqN = 0; actSmpN = 0; actBusN = 0; actBSmpN = 0; actLiveN = 0; actASmpN = 0
for (ch in 0 until Pattern.TRACK_COUNT) chBlockPeak[ch] = 0f // reset per-channel VU accumulators
for (i in seqVoices.indices) if (seqVoices[i].isActive) { seqGen[i] = blockGen; actSeq[actSeqN++] = i }
for (i in sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
@@ -609,6 +621,9 @@ class AudioEngine(
if (!s.isFinite()) s = 0f
// Optional per-bus soft-clip limiter (toolbar toggle).
if (mc.limiter) s = softClip(s)
// Track this bus's peak for the mixer VU (post-fader/post-FX).
val a = if (s < 0f) -s else s
if (a > chBlockPeak[ch]) chBlockPeak[ch] = a
// Voices and inserts are mono; both master channels get the same
// sum. The master output/recording path stays stereo (2 channels).
mixL += s; mixR += s
@@ -625,8 +640,25 @@ class AudioEngine(
out[2 * i + 1] = oR
if (recCapturing) recordSample(proj, oL, oR)
}
// Fold this block's per-channel peaks into the VU meter values: instant attack
// (jump up to a new peak), exponential release (decay back down), plus a clip
// latch that holds when the bus crossed 0 dBFS so a momentary overload is seen.
for (ch in 0 until Pattern.TRACK_COUNT) {
val peak = chBlockPeak[ch]
val decayed = chMeter[ch] * METER_DECAY
chMeter[ch] = if (peak > decayed) peak else decayed
if (peak >= METER_CLIP_LEVEL) chClipHold[ch] = METER_CLIP_HOLD_BLOCKS
else if (chClipHold[ch] > 0) chClipHold[ch]--
}
}
/** Current VU level (peak amplitude, ~0..1+) for mixer bus [ch]; 0 if out of range. */
fun channelMeter(ch: Int): Float = if (ch in chMeter.indices) chMeter[ch] else 0f
/** True while bus [ch] is (or recently was) clipping past 0 dBFS — drives the red VU. */
fun channelClipped(ch: Int): Boolean = ch in chClipHold.indices && chClipHold[ch] > 0
/**
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.
@@ -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_RELEASE = 0.00025f // per-sample rise toward unity (~80 ms release @ 48 kHz)
private const val LIVE_GAIN = 0.5f
// Mixer VU ballistics (per ~4 ms block). Decay ≈ falls to ~5% over ~300 ms;
// clip latches red for ~0.8 s so a brief overload is noticeable.
private const val METER_DECAY = 0.96f
private const val METER_CLIP_LEVEL = 0.999f // bus peak at/above 0 dBFS = clipping
private const val METER_CLIP_HOLD_BLOCKS = 200 // ~0.8 s at 192-frame blocks / 48 kHz
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
/**

View File

@@ -212,6 +212,12 @@ class AppViewModel(
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
/** Live VU level (peak, ~0..1+) for mixer bus [ch] — polled at frame rate by the
* mixer's fader VU meters. */
fun channelMeter(ch: Int): Float = engine.channelMeter(ch)
/** True while mixer bus [ch] is (or just was) clipping past 0 dBFS. */
fun channelClipped(ch: Int): Boolean = engine.channelClipped(ch)
/**
* The core value-editing operation, shared by drag gestures and +/- input.
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.

View File

@@ -18,16 +18,25 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
@@ -36,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.Mixer
import space.rcmd.android.sizzle.model.MixerChannel
import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.ToolboxKind
import space.rcmd.android.sizzle.ui.AppViewModel
import space.rcmd.android.sizzle.ui.isLandscape
@@ -55,6 +65,23 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
fun MixerScreen(vm: AppViewModel) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
// Live per-channel VU state, polled from the audio engine once per frame. Held in
// stable state objects so only the fader's draw phase (which reads them) repaints —
// the strips themselves never recompose from meter movement. Writing an unchanged
// value is a no-op, so an idle (silent) mixer costs nothing but the frame tick.
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)) {
RecordToolbar(vm)
Row(
@@ -62,7 +89,8 @@ fun MixerScreen(vm: AppViewModel) {
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
vm.project.mixer.channels.forEach { channel ->
ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
ChannelStrip(vm, channel, levels[channel.index], clips[channel.index],
Modifier.weight(1f).fillMaxHeight())
}
}
}
@@ -124,7 +152,13 @@ private fun RecordToolbar(vm: AppViewModel) {
}
@Composable
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
private fun ChannelStrip(
vm: AppViewModel,
channel: MixerChannel,
level: MutableFloatState,
clipped: State<Boolean>,
modifier: Modifier,
) {
val c = LocalRetro.current
@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(
Modifier.weight(1f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ChannelLevels(vm, channel) }
) { ChannelLevels(vm, channel, level, clipped) }
}
} else {
// Portrait: one column, the fader filling the leftover height.
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
* leftover height of its column. */
@Composable
private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
private fun ColumnScope.ChannelLevels(
vm: AppViewModel,
channel: MixerChannel,
level: MutableFloatState,
clipped: State<Boolean>,
) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
SectionLabel("Volume")
VerticalFader(
value = channel.volume,
default = Mixer.DEFAULT_VOLUME,
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
level = level,
clipped = clipped,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
// 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.
* Snaps to the [default] level within a small band so returning to the standard
* volume is easy to hit. */
/** A vertical volume fader with a live VU meter drawn inside its filled area: fill
* grows from the bottom to the set [value]; tap or drag to set. The VU ([level], a
* peak amplitude polled each frame) rises from the bottom within the fill, green →
* amber as it gets hotter, and the whole meter turns red while the bus is [clipped]
* (past 0 dBFS). Snaps to the [default] level within a small band so returning to the
* standard volume is easy to hit.
*
* Everything is painted in a single [drawBehind]; [level]/[clipped] are read in the
* draw phase, so meter movement repaints only this fader — no recomposition. */
@Composable
private fun VerticalFader(value: Float, default: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
private fun VerticalFader(
value: Float,
default: Float,
onValueChange: (Float) -> Unit,
level: MutableFloatState,
clipped: State<Boolean>,
modifier: Modifier,
) {
val c = LocalRetro.current
val volFill = c.accent.copy(alpha = 0.30f)
val vuLo = c.playhead // green — nominal level
val vuHi = c.accent // amber — getting hot
val vuClip = c.danger // red — clipping
Box(
modifier
.border(1.dp, c.grid, RectangleShape)
.background(c.background)
.drawBehind {
val h = size.height
val w = size.width
val vol = value.coerceIn(0f, 1f)
val volH = vol * h
val volTop = h - volH
// Volume fill (the set level), translucent.
if (volH > 0f) drawRect(volFill, topLeft = Offset(0f, volTop), size = Size(w, volH))
// VU meter, contained within the fill. Colour maps to ABSOLUTE level
// over the full track height (bottom green → top red), so a taller bar
// is visibly hotter; the gradient's red tip is what "hard clipping turns
// to a red hue" looks like as the signal climbs.
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) {
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))
}
},
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