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:
@@ -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"
|
||||
|
||||
@@ -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,7 +640,24 @@ 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
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user