diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 76d159a..bb9f923 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index a353694..f261e03 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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 /** diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index 2ac506b..802d82f 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -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. diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt index e5cfd52..7767b4e 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/mixer/MixerScreen.kt @@ -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, + 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, +) { @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, + 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