diff --git a/Helpers/MeterScale.cs b/Helpers/MeterScale.cs
index 7a2991f..a352e99 100644
--- a/Helpers/MeterScale.cs
+++ b/Helpers/MeterScale.cs
@@ -34,6 +34,15 @@ public static class MeterScale
: dbfs >= CautionDb ? MeterBand.Caution
: MeterBand.Nominal;
+ /// Ballistics for a level: reach a new peak quickly, fall back gently. Raw per-block
+ /// peaks sampled by a UI timer look like a stutter; this is what makes the mic feel fluid, and
+ /// TRAX gets the same curve so the two ladders move alike.
+ public static float NextLevel(float current, float incoming, float attack = 0.6f, float decay = 0.12f)
+ {
+ var alpha = incoming > current ? attack : decay;
+ return current + (incoming - current) * alpha;
+ }
+
/// Peak hold: jumps up instantly to a new maximum and falls back at
/// — a moment-to-moment bar hides clipping, which is the one
/// audio fault that cannot be fixed afterwards.
diff --git a/Services/Audio/MusicPlayer.cs b/Services/Audio/MusicPlayer.cs
index 6c70e4d..70acb53 100644
--- a/Services/Audio/MusicPlayer.cs
+++ b/Services/Audio/MusicPlayer.cs
@@ -84,7 +84,8 @@ public sealed class MusicPlayer : ITraxPlayer
_volume = new VolumeWaveProvider16(_reader) { Volume = MusicVolume * _userGain * _localGain };
_metering = new MeteringSampleProvider(_volume.ToSampleProvider());
_metering.StreamVolume += (_, e) =>
- Level = e.MaxSampleValues.Length == 0 ? 0f : e.MaxSampleValues.Max();
+ Level = Helpers.MeterScale.NextLevel(Level,
+ e.MaxSampleValues.Length == 0 ? 0f : e.MaxSampleValues.Max());
_output = new WaveOutEvent();
_output.PlaybackStopped += OnPlaybackStopped;
_output.Init(_metering);
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 3ed8928..6bc4ac5 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -179,10 +179,10 @@ public partial class MainViewModel : ViewModelBase
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
- // Dock meters beat on their own 20 Hz clock instead of riding the mic's level callback.
+ // Dock meters beat on their own ~30 Hz clock instead of riding the mic's level callback.
// Coupling them to capture was a bug: with a silent or absent mic the ladder never
// refreshed, so a playing TRAX showed nothing (creator, 2026-10-30).
- _meterTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) };
+ _meterTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) };
_meterTimer.Tick += (_, _) => TickMeters();
_meterTimer.Start();
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
diff --git a/ytLive.Tests/MeterScaleTests.cs b/ytLive.Tests/MeterScaleTests.cs
index e852d25..b1b587b 100644
--- a/ytLive.Tests/MeterScaleTests.cs
+++ b/ytLive.Tests/MeterScaleTests.cs
@@ -27,6 +27,12 @@ public class MeterScaleTests
Assert.Equal(1f, MeterScale.ToFraction(+12f));
Assert.Equal(0f, MeterScale.ToFraction(-120f));
+ // Level ballistics: a jump is followed quickly, a drop falls gently — the same curve that
+ // makes the mic meter look fluid. Raw peaks at a 30 Hz UI tick stutter without it.
+ Assert.Equal(0.6f, MeterScale.NextLevel(0f, 1f), 3); // attack
+ Assert.True(MeterScale.NextLevel(0.9f, 0.1f) > 0.8f); // decay is gentle
+ Assert.True(MeterScale.NextLevel(0.1f, 0.9f) > 0.5f); // rise is fast
+
// Peak hold rises instantly, then decays at the stated rate.
Assert.Equal(0.8f, MeterScale.NextPeak(0.2f, 0.8f, 0.05f));
Assert.Equal(0.7f, MeterScale.NextPeak(0.8f, 0.1f, 0.1f / 0.35f), 3); // 0.1 fall at 0.35/s