From 48460b88ad7140628a4ec8bbebc20b2729e85c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:23:56 -0400 Subject: [PATCH 1/3] Reduce audio meter XAML churn and stop unloaded timers --- .github/workflows/ci.yml | 7 + docs/BACKLOG.md | 13 ++ docs/xaml-lifetime-hardening-2026-09-14.md | 63 ++++++++ .../CoreVideoPro.WinUI.LifetimeTests.csproj | 26 ++++ .../Program.cs | 124 ++++++++++++++++ .../Controls/AudioLevelMeter.xaml | 9 +- .../Controls/AudioLevelMeter.xaml.cs | 134 +++++++++++------- 7 files changed, 321 insertions(+), 55 deletions(-) create mode 100644 docs/xaml-lifetime-hardening-2026-09-14.md create mode 100644 native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj create mode 100644 native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edcc1c8c..e57ababd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,6 +321,13 @@ jobs: ./scripts/provision-winui-test-runtime.ps1 - name: MediaCore, Control, WinUI and bridge tests run: npm run test:native-shell + - name: WinUI control lifetime regression (no window) + timeout-minutes: 5 + run: | + dotnet build native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj -c Release -p:Platform=x64 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & ./native-shell/CoreVideoPro.WinUI.LifetimeTests/bin/x64/Release/net9.0-windows10.0.19041.0/win-x64/CoreVideoPro.WinUI.LifetimeTests.exe + exit $LASTEXITCODE - name: Upload Windows test results if: always() uses: actions/upload-artifact@v4 diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 0443f2f1..1cf8eb83 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -44,6 +44,19 @@ TDD + review loop). Estimate for tiers 1-4 ≈ 3-4 working days; tier 5 ≈ 5-8 --- +## Current implementation order — owner approved 2026-09-14 + +The owner approved the ISO recorder work in merged PR #531 and selected this +next sequence: #513 idle XAML crash investigation/hardening; #516 long command +lock hold; #532 RTMP startup recovery; then #456 media In/Out points. This is the +current implementation order ahead of the older ranking below. The In/Out UI +still needs its concrete design reviewed before implementation. + +For #513, the first change removes audio-meter control recreation on level and +decay updates and prevents unloaded meters from restarting timers. This reduces +XAML lifetime churn; it does **not** establish the cause or closure of the idle +fail-fast. See [evidence and validation](xaml-lifetime-hardening-2026-09-14.md). + ## Tier 0 — owner actions (start the clocks) **New observations, awaiting owner ranking (2026-09-14):** diff --git a/docs/xaml-lifetime-hardening-2026-09-14.md b/docs/xaml-lifetime-hardening-2026-09-14.md new file mode 100644 index 00000000..2f177720 --- /dev/null +++ b/docs/xaml-lifetime-hardening-2026-09-14.md @@ -0,0 +1,63 @@ +# Audio-meter lifetime hardening — 2026-09-14 + +Issue: [#513](https://github.com/iamfatness/CoreVideoPro/issues/513). + +## Evidence boundary + +The original idle crash released a native Border from a WinRT wrapper on the GC +finalizer thread. The [owner's follow-up](https://github.com/iamfatness/CoreVideoPro/issues/513#issuecomment-5652789273) +explicitly retracts the proposed immediate forced-GC reproduction: multiple +collections, Takes, and recording cycles survived, and thousands of wrappers +finalized normally. Finalizer-thread release alone is not a bug. The faulting +object's earlier history and the framework release-order failure remain unknown. + +This change is mitigation and regression prevention, not a claim that #513 is +fixed. No framework package versions changed. Other dynamic control factories +remain outside this patch. + +## Concrete changes + +Previously AudioLevelMeter.RenderSegments created a new StackPanel, every segment +Border, and optional scale Grid/Canvas/ticks/labels, then cleared RootGrid. Both +level updates and the 33 ms decay timer used that path. + +The host, panel, and scale now persist. The segment pool grows only to its maximum +requested size (48), and the scale pool only to seven marks. Smaller layouts +collapse surplus entries, preserving their identity for later reuse. Steady level +updates change brushes; geometry and scale text update only when layout changes. +Calibration, fit, peak hold, release ballistics, and muted-input coloring are retained. + +Level updates cannot start a timer while unloaded. Unload stops the timer, +unsubscribes its handler, and releases the timer reference; load refreshes the +level and resumes ballistics using a fresh timer when needed. + +## Validation + +- Release WinUI shell build: passed, zero errors (existing warnings remain). +- Existing WinUI suite: 1,477 passed, zero failed/skipped. +- New standalone WinUI lifetime test: passed 10,000 updates, changing orientation, + level, mute, scale visibility, segment count, and size. All 48 segment and 14 + scale-child identities remain stable. Checks responsive scale labels, segment + fit, normal/muted/pre-mute coloring, hidden scale, and unloaded timer suppression. +- Full collection/finalization runs off the UI thread while the dispatcher stays + available; the retained control remains usable afterward. + +The executable compiles the production control and XAML into an isolated WinUI +application with no window. It starts no media engine or control API and writes no +operator settings. It exercises real XAML objects and layout; it does not simulate +Loaded/Unloaded by attaching a window. Live page navigation and the original idle +crash still need observation; a passing finite run cannot prove the crash absent. +The regression executable is included in the Windows CI job with a five-minute +timeout so a native hang cannot block the runner indefinitely. + +Run locally: + +```powershell +dotnet build native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj -c Release -p:Platform=x64 +& ./native-shell/CoreVideoPro.WinUI.LifetimeTests/bin/x64/Release/net9.0-windows10.0.19041.0/win-x64/CoreVideoPro.WinUI.LifetimeTests.exe +``` + +Next evidence for #513: retain matching binaries and any new idle-crash dump, +compare the failing reference-tracker path and object history, and investigate +other factories independently. Do not close the issue on reduced allocation +pressure or forced-GC survival alone. diff --git a/native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj b/native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj new file mode 100644 index 00000000..eb89f7c1 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj @@ -0,0 +1,26 @@ + + + Exe + net9.0-windows10.0.19041.0 + 10.0.17763.0 + x64 + win-x64 + true + None + true + enable + enable + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + + + + + + + + + + + + diff --git a/native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs b/native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs new file mode 100644 index 00000000..2c4e6218 --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs @@ -0,0 +1,124 @@ +using System.Reflection; +using CoreVideoPro.WinUI.Controls; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.Foundation; + +// Run on a real WinUI dispatcher, without opening a window or touching the +// operator's running application. This tests reuse, not reproduction of #513. +internal static class Program +{ + private const BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic; + private static int _exitCode = 1; + + [STAThread] + private static int Main() + { + WinRT.ComWrappersSupport.InitializeComWrappers(); + Application.Start(_ => + { + SynchronizationContext.SetSynchronizationContext( + new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread())); + var app = new Application(); + DispatcherQueue.GetForCurrentThread().TryEnqueue(async () => + { + try + { + await RunAsync(); + Console.WriteLine("PASS: meter identity, bounded pools, layout, fill, mute, and unloaded timer checks"); + _exitCode = 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + finally { app.Exit(); } + }); + }); + return _exitCode; + } + + private static async Task RunAsync() + { + var meter = new AudioLevelMeter { SegmentCount = 48, IsVertical = true, ShowDbfsScale = true }; + var root = (Grid)meter.Content; + var panel = (StackPanel)root.Children[0]; + var scale = (Canvas)root.Children[1]; + var render = typeof(AudioLevelMeter).GetMethod("RenderSegments", PrivateInstance)!; + void Render(double width, double height) + { + meter.Measure(new Size(width, height)); + meter.Arrange(new Rect(0, 0, width, height)); + render.Invoke(meter, null); + } + Render(80, 600); + Check(panel.Children.Count == 48, "48-segment warmup"); + Check(scale.Children.Count == 14, "seven scale marks"); + var segments = panel.Children.ToArray(); + var marks = scale.Children.ToArray(); + + for (var i = 0; i < 10000; i++) + { + meter.IsVertical = i % 2 == 0; + meter.ShowDbfsScale = i % 3 != 0; + meter.SegmentCount = 8 + i % 41; + meter.IsMuted = i % 5 == 0; + meter.ShowLevelWhileMuted = i % 7 == 0; + meter.Level = i % 101; + Render(80 + i % 300, new double[] { 40, 80, 600 }[i % 3]); + Check(ReferenceEquals(root, meter.Content), "root retained"); + Check(panel.Children.Count == 48 && scale.Children.Count == 14, "bounded pools"); + Check(segments.SequenceEqual(panel.Children), "segment identities retained"); + Check(marks.SequenceEqual(scale.Children), "scale identities retained"); + Check(panel.Orientation == (meter.IsVertical ? Orientation.Vertical : Orientation.Horizontal), "orientation"); + Check(typeof(AudioLevelMeter).GetField("_decayTimer", PrivateInstance)!.GetValue(meter) is null, + "level changes cannot start unloaded timer"); + } + + meter.IsVertical = true; + meter.ShowDbfsScale = true; + meter.SegmentCount = 36; + foreach (var height in new[] { 40d, 80d, 600d }) + { + Render(80, height); + var expected = height < 60 ? new[] { "0", "-30", "-60" } + : height < 100 ? new[] { "0", "-12", "-24", "-36", "-48", "-60" } + : new[] { "0", "-6", "-12", "-24", "-36", "-48", "-60" }; + Check(scale.Children.OfType().Where(x => x.Visibility == Visibility.Visible) + .Select(x => x.Text).SequenceEqual(expected), "responsive scale labels"); + var visible = panel.Children.OfType().Where(x => x.Visibility == Visibility.Visible).ToArray(); + Check(Math.Abs(visible.Sum(x => x.Height) + panel.Spacing * (visible.Length - 1) - height) < 0.01, + "segments fit available height"); + } + + meter.IsVertical = false; + meter.IsMuted = false; + meter.Level = 100; + Render(600, 40); + var first = (Border)panel.Children[0]; + Check(((SolidColorBrush)first.Background).Color.G == 210, "green low segment"); + meter.IsMuted = true; + meter.ShowLevelWhileMuted = false; + Render(600, 40); + Check(((SolidColorBrush)first.Background).Color.G == 30, "mute immediately darkens meter"); + meter.ShowLevelWhileMuted = true; + Render(600, 40); + Check(((SolidColorBrush)first.Background).Color.G == 122, "pre-mute input color"); + Check(scale.Visibility == Visibility.Collapsed, "horizontal scale hidden"); + Check(Grid.GetColumnSpan(panel) == 2, "unscaled meter spans host"); + + // Leave the UI dispatcher free while finalizers run; blocking it can + // deadlock ordinary apartment-marshaled releases and is not a valid test. + await Task.Run(() => { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); }); + Render(600, 40); + Check(segments.SequenceEqual(panel.Children), "control remains usable after full GC"); + GC.KeepAlive(meter); + } + + private static void Check(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} diff --git a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml index 9ce7131c..e182fe80 100644 --- a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml +++ b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml @@ -3,5 +3,12 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + + + + + + + + diff --git a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs index 52051d4f..5efd35a8 100644 --- a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs +++ b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs @@ -72,6 +72,12 @@ public sealed partial class AudioLevelMeter : UserControl private static readonly TimeSpan PeakHold = TimeSpan.FromMilliseconds(800); private const double PeakFallPerTick = 4.0; + // Keep generated XAML objects attached for this control's lifetime. Level + // updates must only change brushes, not replace dozens of tracked objects. + private readonly List _segments = []; + private readonly List<(Border Tick, TextBlock Label)> _scaleMarks = []; + private (bool Vertical, bool Scale, int Count, double Main, double Spacing, double Available)? _layout; + private double _displayedLevel; private double _peakLevel; private DateTimeOffset _peakSetAt = DateTimeOffset.MinValue; @@ -80,7 +86,7 @@ public sealed partial class AudioLevelMeter : UserControl public AudioLevelMeter() { InitializeComponent(); - Loaded += (_, _) => RenderSegments(); + Loaded += (_, _) => OnLevelChanged(); // Re-fit on ANY size change: segments scale to the available column (see // RenderSegments). Without this, a window restored from fullscreen kept // the fullscreen-sized stack and clipped the green end of every meter. @@ -91,7 +97,15 @@ public AudioLevelMeter() RenderSegments(); } }; - Unloaded += (_, _) => StopDecayTimer(); + Unloaded += (_, _) => + { + StopDecayTimer(); + if (_decayTimer is { } timer) + { + timer.Tick -= OnDecayTimerTick; + _decayTimer = null; + } + }; } public double Level @@ -199,7 +213,7 @@ private void OnLevelChanged() private void EnsureDecayTimer() { - if (_decayTimer is { IsRunning: true }) + if (!IsLoaded || _decayTimer is { IsRunning: true }) { return; } @@ -219,10 +233,12 @@ private void EnsureDecayTimer() var timer = queue.CreateTimer(); timer.Interval = TimeSpan.FromMilliseconds(33); timer.IsRepeating = true; - timer.Tick += (_, _) => DecayTick(); + timer.Tick += OnDecayTimerTick; return timer; } + private void OnDecayTimerTick(DispatcherQueueTimer sender, object args) => DecayTick(); + private void StopDecayTimer() => _decayTimer?.Stop(); private void DecayTick() @@ -313,13 +329,7 @@ private void RenderSegments() } } - var panel = new StackPanel - { - Orientation = IsVertical ? Orientation.Vertical : Orientation.Horizontal, - HorizontalAlignment = IsVertical ? HorizontalAlignment.Center : HorizontalAlignment.Stretch, - VerticalAlignment = IsVertical ? VerticalAlignment.Bottom : VerticalAlignment.Center, - Spacing = spacing - }; + UpdateLayout(count, segMain, spacing, availableMain); for (var visualIndex = 0; visualIndex < count; visualIndex++) { @@ -328,78 +338,94 @@ private void RenderSegments() var isPeakHold = lowToHighIndex == peakSegment && !isActive; var normalized = (lowToHighIndex + 1) / (double)count; - panel.Children.Add(new Border - { - Width = IsVertical ? 14 : segMain, - Height = IsVertical ? segMain : 10, - CornerRadius = new CornerRadius(1.5), - Background = isActive || isPeakHold - ? (IsMuted && ShowLevelWhileMuted ? MutedInputBrush : BrushFor(normalized)) - : DimBrush - }); + _segments[visualIndex].Background = isActive || isPeakHold + ? (IsMuted && ShowLevelWhileMuted ? MutedInputBrush : BrushFor(normalized)) + : DimBrush; } + } - RootGrid.Children.Clear(); - if (IsVertical && ShowDbfsScale && availableMain > 0) + private void UpdateLayout(int count, double segMain, double spacing, double availableMain) + { + var showScale = IsVertical && ShowDbfsScale && availableMain > 0; + var layout = (IsVertical, showScale, count, segMain, spacing, availableMain); + if (_layout == layout) { - var host = new Grid - { - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Stretch - }; - host.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(14) }); - host.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - Grid.SetColumn(panel, 0); - host.Children.Add(panel); - - var scale = BuildVerticalDbfsScale(availableMain); - Grid.SetColumn(scale, 1); - host.Children.Add(scale); - RootGrid.Children.Add(host); + return; } - else + _layout = layout; + + SegmentPanel.Orientation = IsVertical ? Orientation.Vertical : Orientation.Horizontal; + SegmentPanel.HorizontalAlignment = IsVertical ? HorizontalAlignment.Center : HorizontalAlignment.Stretch; + SegmentPanel.VerticalAlignment = IsVertical ? VerticalAlignment.Bottom : VerticalAlignment.Center; + SegmentPanel.Spacing = spacing; + Grid.SetColumnSpan(SegmentPanel, showScale ? 1 : 2); + + // A bounded high-water pool (at most 48). Shrinking the window hides + // surplus segments; growing it reuses them without detaching controls. + while (_segments.Count < count) { - RootGrid.Children.Add(panel); + var segment = new Border { CornerRadius = new CornerRadius(1.5) }; + _segments.Add(segment); + SegmentPanel.Children.Add(segment); + } + for (var i = 0; i < _segments.Count; i++) + { + var segment = _segments[i]; + segment.Visibility = i < count ? Visibility.Visible : Visibility.Collapsed; + segment.Width = IsVertical ? 14 : segMain; + segment.Height = IsVertical ? segMain : 10; + } + + ScaleCanvas.Visibility = showScale ? Visibility.Visible : Visibility.Collapsed; + if (showScale) + { + UpdateVerticalDbfsScale(availableMain); } } - private static Canvas BuildVerticalDbfsScale(double height) + private void UpdateVerticalDbfsScale(double height) { - var canvas = new Canvas { Height = height }; - // Short master rails cannot legibly carry the full broadcast scale. - // Keep the endpoints and midpoint there; progressively add the - // standard marks as physical height permits. + ScaleCanvas.Height = height; + // Short rails keep only legible marks; all marks reuse the same slots. IReadOnlyList ticks = height < 60 ? [0, -30, -60] : height < 100 ? [0, -12, -24, -36, -48, -60] : Models.AudioMeterScale.MajorTicksDbfs; - foreach (var dbfs in ticks) + while (_scaleMarks.Count < ticks.Count) { - var level = Models.AudioMeterScale.ToLevel(dbfs) / 100.0; - var y = Math.Clamp((1 - level) * height, 0, height); var tick = new Border { Width = 4, Height = 1, Background = new SolidColorBrush(Windows.UI.Color.FromArgb(180, 126, 145, 156)) }; - Canvas.SetLeft(tick, 0); - Canvas.SetTop(tick, Math.Clamp(y, 0, Math.Max(0, height - 1))); - canvas.Children.Add(tick); - var label = new TextBlock { - Text = dbfs.ToString("0"), FontSize = 7, Foreground = new SolidColorBrush(Windows.UI.Color.FromArgb(220, 160, 177, 187)) }; + Canvas.SetLeft(tick, 0); Canvas.SetLeft(label, 6); + ScaleCanvas.Children.Add(tick); + ScaleCanvas.Children.Add(label); + _scaleMarks.Add((tick, label)); + } + for (var i = 0; i < _scaleMarks.Count; i++) + { + var (tick, label) = _scaleMarks[i]; + var visible = i < ticks.Count; + tick.Visibility = label.Visibility = visible ? Visibility.Visible : Visibility.Collapsed; + if (!visible) + { + continue; + } + var dbfs = ticks[i]; + var y = Math.Clamp((1 - Models.AudioMeterScale.ToLevel(dbfs) / 100.0) * height, 0, height); + label.Text = dbfs.ToString("0"); + Canvas.SetTop(tick, Math.Clamp(y, 0, Math.Max(0, height - 1))); Canvas.SetTop(label, Math.Clamp(y - 6, 0, Math.Max(0, height - 12))); - canvas.Children.Add(label); } - - return canvas; } private static SolidColorBrush BrushFor(double normalized) => From ea291e351f55cb249b6ddd503472ceecd5a49d18 Mon Sep 17 00:00:00 2001 From: John Wallace Date: Tue, 15 Sep 2026 21:37:00 -0400 Subject: [PATCH 2/3] Retain meter visuals, harden animation lifecycle, and refresh Zoom mute state --- docs/audio-meter-stability.md | 115 ++++++ .../AudioMeterBallisticsTests.cs | 104 +++++ .../ParticipantMuteRefreshTests.cs | 64 +++ native-shell/CoreVideoPro.WinUI/App.xaml.cs | 15 +- .../Controls/AudioLevelMeter.xaml.cs | 372 ++++++------------ .../Controls/ShowMultiviewHost.xaml.cs | 7 + .../Models/AudioMeterBallistics.cs | 51 +++ .../Models/AudioMeterScale.cs | 14 + native-shell/CoreVideoPro.WinUI/Program.cs | 9 +- .../Services/AudioMeterStressProbe.cs | 203 ++++++++++ .../Services/ParticipantMapper.cs | 13 +- .../ViewModels/StudioViewModel.cs | 22 +- package.json | 1 + scripts/test-audio-meter-stability.ps1 | 33 ++ 14 files changed, 768 insertions(+), 255 deletions(-) create mode 100644 docs/audio-meter-stability.md create mode 100644 native-shell/CoreVideoPro.WinUI.Tests/AudioMeterBallisticsTests.cs create mode 100644 native-shell/CoreVideoPro.WinUI.Tests/ParticipantMuteRefreshTests.cs create mode 100644 native-shell/CoreVideoPro.WinUI/Models/AudioMeterBallistics.cs create mode 100644 native-shell/CoreVideoPro.WinUI/Services/AudioMeterStressProbe.cs create mode 100644 scripts/test-audio-meter-stability.ps1 diff --git a/docs/audio-meter-stability.md b/docs/audio-meter-stability.md new file mode 100644 index 00000000..3619627c --- /dev/null +++ b/docs/audio-meter-stability.md @@ -0,0 +1,115 @@ +# Audio-meter crash hardening and event validation + +## Failure and fix + +The September 15, 2026 crash of installed build `aac2d98` occurred in +`WinRT.IObjectReference.Finalize` → `ctl::ComObject::Release`. +The stowed error was `0x8000000e`: the border had already been queued for +UI-affine final release (`m_ignoreReleases = 1`). Its size, rounded corners, +and detached Border → vertical StackPanel → Grid hierarchy match an old +audio-meter segment. The dump establishes the invalid final release; it +does not identify every earlier reference-count operation. + +The meter used to create and discard its segment tree on changing levels, +including a 33 ms decay timer. It now creates a bounded visual pool once: +48 segments, eight scale ticks and eight labels. Level changes update brushes; +resizing, orientation, segment count and scale changes reuse the same controls. +There is no Children.Clear/reparent operation in the update path. + +The timer only runs while the meter is loaded and its level or held peak needs +animation. Unload stops it, unsubscribes the handler and releases the timer. +Reload starts from the current telemetry. Muting output clears the level and +peak immediately; muted input monitoring keeps its distinct color. +Ballistics use monotonic elapsed time, and invalid telemetry is sanitized. + +The adjacent multiview meters already update existing fill rectangles. Their +clock now also refuses to restart from binding changes while unloaded. +Participant audio rows already update in place when channel IDs are stable. +The separate Zoom mute patch keeps roster telemetry current before the +structural refresh gate, including the latest pending coalesced snapshot. + +## Repeatable verification + +Build/test the real shell (requires the Windows WinUI toolchain): + +```powershell +dotnet test native-shell/CoreVideoPro.WinUI.Tests/CoreVideoPro.WinUI.Tests.csproj -c Release -p:Platform=x64 -p:WindowsAppSDKBootstrapAutoInitializeOptions_Default=false -p:WindowsAppSDKBootstrapAutoInitializeOptions_None=true +``` + +Run the real-XAML stress gate, using that build: + +```powershell +./scripts/test-audio-meter-stability.ps1 -Seconds 300 +# Longer release rehearsal: +./scripts/test-audio-meter-stability.ps1 -Seconds 3600 +``` + +`-Executable` can point to a packaged candidate to test its actual runtime. +The runner launches an isolated off-screen window without activation; it does +not instantiate MainWindow, connect to Zoom/native media, or load/save a show. +The production executable only enters this path with the explicit +`--verify-audio-meters SECONDS REPORT` arguments. + +The probe checks 16 meters under changing levels, output/input mute, resizing, +orientation/scale changes, NaN input, and repeated unload/reload. It forces +compacting GC on a worker while the real UI dispatcher runs. It asserts: + +- Every retained visual keeps its identity and the pool size stays bounded. +- Output mute is dark; pre-mute input has a different color. +- Unloaded meters cannot restart timers; silence eventually stops animation. +- Twenty separately created/destroyed meters become collectible after unload. +- Native fail-fast, nonzero exit, timeout, missing report or a failed assertion + all fail the gate. An existing report cannot be reused as evidence. + +The ordinary unit suite covers ballistics/peak timing, finite input handling, +compact layout, repeated mute/unmute, and mixer mute preservation. + +## Eight-guest live-event release gate + +### Local validation completed September 15, 2026 + +- Release WinUI build succeeded; all 1,493 WinUI unit tests passed. +- The five-minute real-XAML probe passed: 9,377 update cycles across 16 meters, + 156 unload/reload cycles, 1,164 forced collections, and 85,289 observations + of active animation. All 20 destroyed test meters became collectible. +- No retained meter visuals were replaced during the tested level/layout + changes. Post-silence and post-unload timers were stopped. +- Test output: `artifacts/test-results/meter-stress-300s.json` and + `artifacts/test-results/stability-tests-final.log`. +- Tested shell DLL SHA-256: + `549528C1933DD234C2B9CE56B570638EF7159FE0E30B3EFBF6B127D0EA1CD8F4`. + +This is local regression evidence. The fixed build has not been installed into +the operator's application, and the full eight-guest event rehearsal below has +not been run. + +The requested target is eight Zoom guests. Event duration and simultaneous +outputs have not yet been specified. Pending those details, use a conservative +rehearsal of at least two hours at the intended output resolution/frame rate, +with program recording and a private test stream. Enable only the real show's +additional ISO, virtual camera, plug-in and monitor configuration, and record +the exact configuration with the result. These are test requirements, not +claims that this rehearsal has already passed. + +1. Use a packaged candidate containing this fix and record its build identity, + machine, driver/runtime versions, participants and output configuration. +2. Keep eight guests connected with active video/audio. Exercise guest + mute/unmute, camera off/on, leave/rejoin and screen sharing repeatedly. + Check that source mute and the operator's mixer mute remain distinct. +3. Operate the show: scene takes, Tiles layout changes, mixer controls, + monitor/bus routing, repeated page navigation, resize/maximize/restore, + and the plug-ins actually used for the event. +4. Run simultaneous configured outputs for the full rehearsal. Verify recorded + files with playback and probe their timestamps/duration; inspect the stream + receiver for uninterrupted audio/video and sync. Exercise supported + start/stop/restart paths and a controlled network interruption. +5. Capture crash/hang records, memory trend after warmup, frame/encoder drops, + audio underruns, output faults and UI responsiveness. A crash, hang, lost + output, silent audio, growing retained controls, or unrecovered fault blocks + release. Investigate any degradation rather than treating survival as pass. +6. Close cleanly, relaunch, restore the show, and repeat essential routing and + output checks. Preserve diagnostics and playback evidence with the build. + +The isolated meter gate cannot establish Zoom, GPU, plug-in, encoder, recording +or network reliability. Do not label the complete application production-validated +until the live workload above has passed on the intended event hardware. diff --git a/native-shell/CoreVideoPro.WinUI.Tests/AudioMeterBallisticsTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/AudioMeterBallisticsTests.cs new file mode 100644 index 00000000..2319789c --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.Tests/AudioMeterBallisticsTests.cs @@ -0,0 +1,104 @@ +using CoreVideoPro.WinUI.Models; +using Xunit; + +namespace CoreVideoPro.WinUI.Tests; + +public sealed class AudioMeterBallisticsTests +{ + [Fact] + public void AttackIsImmediateAndReleaseIsTimeBased() + { + var meter = new AudioMeterBallistics(); + meter.Reset(0, false, false, 0); + meter.SetInput(100, false, false, 0); + Assert.Equal(100, meter.Level); + meter.SetInput(0, false, false, 0); + meter.Advance(300); + Assert.InRange(meter.Level, 33, 37); + Assert.Equal(100, meter.Peak); + Assert.True(meter.NeedsAnimation); + meter.Advance(2000); + Assert.Equal(0, meter.Level); + Assert.Equal(0, meter.Peak); + Assert.False(meter.NeedsAnimation); + } + + [Fact] + public void PeakHoldKeepsTimerNeededAfterBarHasReachedSilence() + { + var meter = new AudioMeterBallistics(); + meter.Reset(1, false, false, 0); + meter.SetInput(0, false, false, 0); + meter.Advance(400); + Assert.Equal(0, meter.Level); + Assert.Equal(1, meter.Peak); + Assert.True(meter.NeedsAnimation); + meter.Advance(833); + Assert.Equal(0, meter.Peak); + Assert.False(meter.NeedsAnimation); + } + + [Fact] + public void MuteDropsOutputAndPeakImmediatelyButCanShowInput() + { + var meter = new AudioMeterBallistics(); + meter.Reset(90, false, false, 0); + meter.SetInput(90, true, false, 10); + Assert.Equal(0, meter.Level); + Assert.Equal(0, meter.Peak); + Assert.False(meter.NeedsAnimation); + meter.SetInput(90, true, true, 20); + Assert.Equal(90, meter.Level); + meter.SetInput(0, true, true, 30); + Assert.True(meter.NeedsAnimation); + meter.Reset(0, true, true, 5000); // unloaded/reloaded page + Assert.Equal(0, meter.Peak); + Assert.False(meter.NeedsAnimation); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(-100)] + public void InvalidTelemetryIsSilentAndFinite(double value) + { + var meter = new AudioMeterBallistics(); + meter.Reset(value, false, false, 0); + meter.SetInput(value, false, false, 33); + Assert.Equal(0, meter.Level); + Assert.Equal(0, meter.Peak); + Assert.False(meter.NeedsAnimation); + } + + [Fact] + public void BurstUpdatesStayBoundedAndEventuallyIdle() + { + var meter = new AudioMeterBallistics(); + for (var i = 0; i < 100000; i++) + { + meter.SetInput((i * 17) % 150, i % 3 == 0, i % 7 == 0, i * 17L); + Assert.InRange(meter.Level, 0, 100); + Assert.InRange(meter.Peak, 0, 100); + } + meter.SetInput(0, false, false, 1700000); + meter.Advance(1710000); + Assert.False(meter.NeedsAnimation); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ResizedMeterAlwaysFitsEvenAtOnePixel(bool vertical) + { + foreach (var size in new[] { 0.5, 1, 2, 12, 35, 72, 129, 324, 900 }) + foreach (var requested in new[] { int.MinValue, 8, 18, 36, 48, int.MaxValue }) + { + var fit = vertical ? AudioMeterScale.FitVerticalSegments(size, requested) + : AudioMeterScale.FitHorizontalSegments(size, requested); + Assert.InRange(fit.SegmentCount, 1, 48); + Assert.InRange(fit.SegmentSize, 0, size); + Assert.True(fit.OccupiedSize <= size + 0.000001); + } + } +} diff --git a/native-shell/CoreVideoPro.WinUI.Tests/ParticipantMuteRefreshTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/ParticipantMuteRefreshTests.cs new file mode 100644 index 00000000..3765f9dd --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI.Tests/ParticipantMuteRefreshTests.cs @@ -0,0 +1,64 @@ +using CoreVideoPro.WinUI.Models; +using CoreVideoPro.WinUI.Services; +using Xunit; + +namespace CoreVideoPro.WinUI.Tests; + +public sealed class ParticipantMuteRefreshTests +{ + [Theory] + [InlineData(FeedHealth.Live, false)] + [InlineData(FeedHealth.Live, true)] + [InlineData(FeedHealth.VideoOff, false)] + [InlineData(FeedHealth.VideoOff, true)] + public void MuteOnlyChangesReachMixerWithoutChangingParticipantSet(FeedHealth health, bool consoleMuted) + { + Participant Guest(bool muted) => new() + { + Id = "guest", Name = "Guest", Health = health, IsMuted = muted + }; + IReadOnlyList previous = [Guest(false)]; + var settings = new Dictionary + { + ["guest"] = new() + { + ParticipantId = "guest", Muted = consoleMuted, ManualGainDb = 3, + OutputLevel = 0, GainDb = 0, NoiseSuppression = false, Status = "native-pcm" + } + }; + var row = new AudioParticipantRow(); + var notifications = new List(); + row.PropertyChanged += (_, args) => notifications.Add(args.PropertyName); + + foreach (var muted in new[] { true, false, true, false }) + { + var current = ParticipantMapper.ParticipantsInRoom([Guest(muted)], "main"); + Assert.True(ParticipantMapper.HasMuteChanges(previous, current)); + var mix = Assert.Single(ProductionStateHelper.BuildAudioMixChannels(current, settings)); + row.SourceMuted = mix.SourceMuted; + Assert.Equal(muted, row.SourceMuted); + Assert.Equal(consoleMuted, mix.Muted); + Assert.Equal(3, mix.ManualGainDb); + Assert.Contains(nameof(AudioParticipantRow.SourceMuted), notifications); + notifications.Clear(); + previous = current; + } + } + + [Fact] + public void ReorderingOrLevelChangesDoNotTriggerMuteRefresh() + { + Participant Guest(string id, int level) => new() { Id = id, AudioLevel = level }; + Assert.False(ParticipantMapper.HasMuteChanges( + [Guest("a", 0), Guest("b", 30)], [Guest("b", 70), Guest("a", 10)])); + } + + [Fact] + public void JoiningLeavingAndReplacedIdsRefreshMuteState() + { + var guest = new Participant { Id = "a" }; + Assert.True(ParticipantMapper.HasMuteChanges([], [guest])); + Assert.True(ParticipantMapper.HasMuteChanges([guest], [])); + Assert.True(ParticipantMapper.HasMuteChanges([guest], [new Participant { Id = "b" }])); + } +} diff --git a/native-shell/CoreVideoPro.WinUI/App.xaml.cs b/native-shell/CoreVideoPro.WinUI/App.xaml.cs index 4c96b04f..8818e406 100644 --- a/native-shell/CoreVideoPro.WinUI/App.xaml.cs +++ b/native-shell/CoreVideoPro.WinUI/App.xaml.cs @@ -15,10 +15,17 @@ public partial class App : Application private readonly DispatcherQueue _dispatcher = DispatcherQueue.GetForCurrentThread() ?? throw new InvalidOperationException("Application requires a UI dispatcher."); private static int _firstChanceWrongThread; + private readonly AudioMeterStressProbe? _meterProbe; - public App() + public App() : this(null) { } + + internal App(AudioMeterStressProbe? meterProbe) { + _meterProbe = meterProbe; InitializeComponent(); + // Use the real generated XAML metadata/resources in the isolated probe, + // but do not register activation, open a show, or swallow test failures. + if (_meterProbe is not null) return; ApplicationLifecycle.BindActivation(Activation); // DIAGNOSTIC (limited to 5 writes so it can't destabilize): the recurring // CoreMessagingXP 0xc000027b crash is a cross-thread UI access surfacing as @@ -65,6 +72,12 @@ or System.ObjectDisposedException protected override async void OnLaunched(LaunchActivatedEventArgs args) { + if (_meterProbe is not null) + { + await _meterProbe.RunAsync(); + Exit(); + return; + } try { if (await Activation.TryRedirectToPrimaryAsync().ConfigureAwait(true)) diff --git a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs index 52051d4f..576ee52a 100644 --- a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs +++ b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs @@ -54,46 +54,75 @@ public sealed partial class AudioLevelMeter : UserControl typeof(AudioLevelMeter), new PropertyMetadata(false, OnMutedPropertyChanged)); - private static readonly SolidColorBrush DimBrush = new(Windows.UI.Color.FromArgb(255, 21, 30, 34)); - private static readonly SolidColorBrush GreenBrush = new(Windows.UI.Color.FromArgb(255, 46, 210, 116)); - private static readonly SolidColorBrush YellowBrush = new(Windows.UI.Color.FromArgb(255, 245, 190, 69)); - private static readonly SolidColorBrush RedBrush = new(Windows.UI.Color.FromArgb(255, 237, 76, 68)); + private readonly SolidColorBrush DimBrush = new(Windows.UI.Color.FromArgb(255, 21, 30, 34)); + private readonly SolidColorBrush GreenBrush = new(Windows.UI.Color.FromArgb(255, 46, 210, 116)); + private readonly SolidColorBrush YellowBrush = new(Windows.UI.Color.FromArgb(255, 245, 190, 69)); + private readonly SolidColorBrush RedBrush = new(Windows.UI.Color.FromArgb(255, 237, 76, 68)); // Dimmed blue-gray: distinguishes "muted, but this is the input level" from // a normal live green/yellow/red bar at the same height. - private static readonly SolidColorBrush MutedInputBrush = new(Windows.UI.Color.FromArgb(255, 92, 122, 145)); - - // Console meter ballistics (audio overhaul spec 4.4): the snapshot delivers - // INSTANTANEOUS per-tick levels, and the audio quanta vary per tick, so a - // meter bound raw to Level strobes instead of dancing. Attack is instant - // (a transient must show immediately); release decays exponentially with - // ~300ms time constant; a peak-hold segment lingers ~800ms then falls. - private const double ReleaseFactorPerTick = 0.896; // exp(-33ms / 300ms) - private const double ReleaseFloorKick = 0.25; // finishes the decay tail - private static readonly TimeSpan PeakHold = TimeSpan.FromMilliseconds(800); - private const double PeakFallPerTick = 4.0; - - private double _displayedLevel; - private double _peakLevel; - private DateTimeOffset _peakSetAt = DateTimeOffset.MinValue; + private readonly SolidColorBrush MutedInputBrush = new(Windows.UI.Color.FromArgb(255, 92, 122, 145)); + + private readonly Models.AudioMeterBallistics _ballistics = new(); + private readonly Border[] _segments = new Border[48]; + private readonly StackPanel _segmentPanel = new(); + private readonly Canvas _scale = new(); + private readonly double[] _tickValues = [0, -6, -12, -24, -30, -36, -48, -60]; + private readonly Border[] _ticks = new Border[8]; + private readonly TextBlock[] _labels = new TextBlock[8]; + private readonly ColumnDefinition _barColumn = new(); private DispatcherQueueTimer? _decayTimer; + private (bool Vertical, bool Scale, int Count, double Size, double Spacing, double Available)? _layout; public AudioLevelMeter() { InitializeComponent(); - Loaded += (_, _) => RenderSegments(); - // Re-fit on ANY size change: segments scale to the available column (see - // RenderSegments). Without this, a window restored from fullscreen kept - // the fullscreen-sized stack and clipped the green end of every meter. - SizeChanged += (_, e) => + // A bounded, retained visual tree. No level, resize, orientation or + // scale update removes controls for the finalizer to race releasing. + RootGrid.ColumnDefinitions.Add(_barColumn); + RootGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + RootGrid.Children.Add(_segmentPanel); + Grid.SetColumn(_scale, 1); + RootGrid.Children.Add(_scale); + for (var i = 0; i < _segments.Length; i++) { - if (e.NewSize.Height > 0 || e.NewSize.Width > 0) - { - RenderSegments(); - } - }; - Unloaded += (_, _) => StopDecayTimer(); + var segment = new Border { CornerRadius = new CornerRadius(1.5), Visibility = Visibility.Collapsed }; + _segments[i] = segment; + _segmentPanel.Children.Add(segment); + } + var tickBrush = new SolidColorBrush(Windows.UI.Color.FromArgb(180, 126, 145, 156)); + var labelBrush = new SolidColorBrush(Windows.UI.Color.FromArgb(220, 160, 177, 187)); + for (var i = 0; i < _tickValues.Length; i++) + { + _ticks[i] = new Border { Width = 4, Height = 1, Background = tickBrush }; + _labels[i] = new TextBlock { Text = _tickValues[i].ToString("0"), FontSize = 7, Foreground = labelBrush }; + Canvas.SetLeft(_labels[i], 6); + _scale.Children.Add(_ticks[i]); + _scale.Children.Add(_labels[i]); + } + Loaded += OnLoaded; + SizeChanged += (_, _) => { if (IsLoaded) RenderSegments(); }; + Unloaded += OnUnloaded; + } + + private void OnLoaded(object sender, RoutedEventArgs args) + { + _ballistics.Reset(Level, IsMuted, ShowLevelWhileMuted, Environment.TickCount64); + RenderSegments(); } + private void OnUnloaded(object sender, RoutedEventArgs args) + { + if (_decayTimer is not null) + { + _decayTimer.Stop(); + _decayTimer.Tick -= OnDecayTick; + _decayTimer = null; + } + } + + // Exposed only inside this assembly for the isolated real-XAML stress probe. + internal bool AnimationRunning => _decayTimer?.IsRunning == true; + public double Level { get => (double)GetValue(LevelProperty); @@ -148,260 +177,105 @@ private static void OnMeterPropertyChanged(DependencyObject dependencyObject, De private static void OnMutedPropertyChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) { - if (dependencyObject is not AudioLevelMeter meter) - { - return; - } - - if (meter.IsMuted && !meter.ShowLevelWhileMuted) - { - // Mute is a routing discontinuity, not a release-ballistics event. - // Drop both the live bar and peak hold immediately so the console - // never implies that muted audio is reaching Program. - meter._displayedLevel = 0; - meter._peakLevel = 0; - meter._peakSetAt = DateTimeOffset.MinValue; - meter.StopDecayTimer(); - if (meter.IsLoaded) - { - meter.RenderSegments(); - } - return; - } - - // #481: muted but showing the pre-mute input level - keep ballistics - // running off Level like a normal (unmuted) meter; RenderSegments picks - // the dim color because IsMuted is still true. - meter.OnLevelChanged(); + if (dependencyObject is AudioLevelMeter meter) meter.OnLevelChanged(); } private void OnLevelChanged() { - var target = IsMuted && !ShowLevelWhileMuted ? 0 : Math.Clamp(Level, 0, 100); - if (target >= _displayedLevel) - { - _displayedLevel = target; // instant attack - } - - if (target >= _peakLevel) - { - _peakLevel = target; - _peakSetAt = DateTimeOffset.UtcNow; - } - - if (IsLoaded) - { - RenderSegments(); - } - - EnsureDecayTimer(); + // Binding updates can still arrive for an unloaded page. Loaded will + // reconcile the newest Level; hidden meters must not restart timers. + if (!IsLoaded) return; + _ballistics.SetInput(Level, IsMuted, ShowLevelWhileMuted, Environment.TickCount64); + RenderSegments(); + UpdateTimer(); } - private void EnsureDecayTimer() + private void UpdateTimer() { - if (_decayTimer is { IsRunning: true }) + if (!IsLoaded || !_ballistics.NeedsAnimation) { + _decayTimer?.Stop(); return; } - - _decayTimer ??= CreateDecayTimer(); - _decayTimer?.Start(); - } - - private DispatcherQueueTimer? CreateDecayTimer() - { - var queue = DispatcherQueue; - if (queue is null) + if (_decayTimer is null) { - return null; + _decayTimer = DispatcherQueue.CreateTimer(); + _decayTimer.Interval = TimeSpan.FromMilliseconds(33); + _decayTimer.IsRepeating = true; + _decayTimer.Tick += OnDecayTick; } - - var timer = queue.CreateTimer(); - timer.Interval = TimeSpan.FromMilliseconds(33); - timer.IsRepeating = true; - timer.Tick += (_, _) => DecayTick(); - return timer; + if (!_decayTimer.IsRunning) _decayTimer.Start(); } - private void StopDecayTimer() => _decayTimer?.Stop(); - - private void DecayTick() + private void OnDecayTick(DispatcherQueueTimer sender, object args) { - var target = IsMuted && !ShowLevelWhileMuted ? 0 : Math.Clamp(Level, 0, 100); - var changed = false; - - if (_displayedLevel > target) - { - _displayedLevel = Math.Max(target, _displayedLevel * ReleaseFactorPerTick - ReleaseFloorKick); - changed = true; - } - - if (_peakLevel > _displayedLevel && DateTimeOffset.UtcNow - _peakSetAt > PeakHold) - { - _peakLevel = Math.Max(_displayedLevel, _peakLevel - PeakFallPerTick); - changed = true; - } - - if (changed) - { - if (IsLoaded) - { - RenderSegments(); - } - - return; - } - - // Converged and peak expired: idle the timer (a page can host a dozen - // meters; they should cost nothing while silent). - StopDecayTimer(); + if (!IsLoaded) { sender.Stop(); return; } + _ballistics.Advance(Environment.TickCount64); + RenderSegments(); + UpdateTimer(); } private void RenderSegments() { - var count = Math.Clamp(SegmentCount, 8, 48); - var level = Math.Clamp(_displayedLevel, 0, 100); - var activeSegments = (int)Math.Round(level / 100.0 * count, MidpointRounding.AwayFromZero); - var peakSegment = _peakLevel > 0 - ? (int)Math.Round(Math.Clamp(_peakLevel, 0, 100) / 100.0 * count, MidpointRounding.AwayFromZero) - 1 + var availableMain = IsVertical ? RootGrid.ActualHeight : RootGrid.ActualWidth; + var fit = IsVertical + ? Models.AudioMeterScale.FitVerticalSegments(availableMain, SegmentCount) + : Models.AudioMeterScale.FitHorizontalSegments(availableMain, SegmentCount); + var count = fit.SegmentCount; + var segMain = fit.SegmentSize; + var spacing = fit.Spacing; + var activeSegments = (int)Math.Round(_ballistics.Level / 100.0 * count, MidpointRounding.AwayFromZero); + var peakSegment = _ballistics.Peak > 0 + ? (int)Math.Round(_ballistics.Peak / 100.0 * count, MidpointRounding.AwayFromZero) - 1 : -1; - // SCALE TO THE SPACE WE HAVE. Fixed 7px segments + 2px spacing need a - // 324px column at 36 segments; any smaller window CLIPPED the stack — - // and because the low/green segments sit at the visual bottom, exactly - // the audible part of the meter vanished ("meters don't work when not - // in full screen", 2026-08-09). Shrink spacing first, then segment - // size, then segment COUNT — never overflow, never render nothing. - var availableMain = IsVertical ? RootGrid.ActualHeight : RootGrid.ActualWidth; - double spacing = 2; - double segMain = IsVertical ? 7 : 4; - if (availableMain > 0) + var scaleVisible = IsVertical && ShowDbfsScale && availableMain > 0; + var layoutKey = (IsVertical, scaleVisible, count, segMain, spacing, availableMain); + if (_layout != layoutKey) { - if (IsVertical) + _layout = layoutKey; + _segmentPanel.Orientation = IsVertical ? Orientation.Vertical : Orientation.Horizontal; + _segmentPanel.HorizontalAlignment = IsVertical ? HorizontalAlignment.Center : HorizontalAlignment.Stretch; + _segmentPanel.VerticalAlignment = IsVertical ? VerticalAlignment.Bottom : VerticalAlignment.Center; + _segmentPanel.Spacing = spacing; + _barColumn.Width = scaleVisible ? new GridLength(14) : new GridLength(1, GridUnitType.Star); + Grid.SetColumnSpan(_segmentPanel, scaleVisible ? 1 : 2); + _scale.Visibility = scaleVisible ? Visibility.Visible : Visibility.Collapsed; + for (var i = 0; i < _segments.Length; i++) { - // Keep the segment bar and the dB scale on the same responsive - // height instead of pinning a fixed-size bar to the bottom. - var layout = Models.AudioMeterScale.FitVerticalSegments(availableMain, count); - count = layout.SegmentCount; - segMain = layout.SegmentSize; - spacing = layout.Spacing; - activeSegments = (int)Math.Round(level / 100.0 * count, MidpointRounding.AwayFromZero); - peakSegment = _peakLevel > 0 - ? (int)Math.Round(Math.Clamp(_peakLevel, 0, 100) / 100.0 * count, MidpointRounding.AwayFromZero) - 1 - : -1; - } - else - { - if ((segMain + spacing) * count > availableMain) - { - spacing = 1; - } - var perSegment = (availableMain - spacing * (count - 1)) / count; - if (perSegment < segMain) - { - segMain = Math.Max(2, Math.Floor(perSegment)); - } - var fits = (int)Math.Floor((availableMain + spacing) / (segMain + spacing)); - if (fits < count && fits >= 4) - { - count = fits; - activeSegments = (int)Math.Round(level / 100.0 * count, MidpointRounding.AwayFromZero); - peakSegment = _peakLevel > 0 - ? (int)Math.Round(Math.Clamp(_peakLevel, 0, 100) / 100.0 * count, MidpointRounding.AwayFromZero) - 1 - : -1; - } + _segments[i].Visibility = i < count ? Visibility.Visible : Visibility.Collapsed; + _segments[i].Width = IsVertical ? 14 : segMain; + _segments[i].Height = IsVertical ? segMain : 10; } + if (scaleVisible) UpdateScale(availableMain); } - - var panel = new StackPanel - { - Orientation = IsVertical ? Orientation.Vertical : Orientation.Horizontal, - HorizontalAlignment = IsVertical ? HorizontalAlignment.Center : HorizontalAlignment.Stretch, - VerticalAlignment = IsVertical ? VerticalAlignment.Bottom : VerticalAlignment.Center, - Spacing = spacing - }; - for (var visualIndex = 0; visualIndex < count; visualIndex++) { var lowToHighIndex = IsVertical ? count - visualIndex - 1 : visualIndex; - var isActive = lowToHighIndex < activeSegments; - var isPeakHold = lowToHighIndex == peakSegment && !isActive; - var normalized = (lowToHighIndex + 1) / (double)count; - - panel.Children.Add(new Border - { - Width = IsVertical ? 14 : segMain, - Height = IsVertical ? segMain : 10, - CornerRadius = new CornerRadius(1.5), - Background = isActive || isPeakHold - ? (IsMuted && ShowLevelWhileMuted ? MutedInputBrush : BrushFor(normalized)) - : DimBrush - }); - } - - RootGrid.Children.Clear(); - if (IsVertical && ShowDbfsScale && availableMain > 0) - { - var host = new Grid - { - HorizontalAlignment = HorizontalAlignment.Stretch, - VerticalAlignment = VerticalAlignment.Stretch - }; - host.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(14) }); - host.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - Grid.SetColumn(panel, 0); - host.Children.Add(panel); - - var scale = BuildVerticalDbfsScale(availableMain); - Grid.SetColumn(scale, 1); - host.Children.Add(scale); - RootGrid.Children.Add(host); - } - else - { - RootGrid.Children.Add(panel); + var lit = lowToHighIndex < activeSegments || lowToHighIndex == peakSegment; + var brush = lit + ? (IsMuted && ShowLevelWhileMuted ? MutedInputBrush : BrushFor((lowToHighIndex + 1) / (double)count)) + : DimBrush; + if (!ReferenceEquals(_segments[visualIndex].Background, brush)) + _segments[visualIndex].Background = brush; } } - private static Canvas BuildVerticalDbfsScale(double height) + private void UpdateScale(double height) { - var canvas = new Canvas { Height = height }; - // Short master rails cannot legibly carry the full broadcast scale. - // Keep the endpoints and midpoint there; progressively add the - // standard marks as physical height permits. - IReadOnlyList ticks = height < 60 - ? [0, -30, -60] - : height < 100 - ? [0, -12, -24, -36, -48, -60] - : Models.AudioMeterScale.MajorTicksDbfs; - foreach (var dbfs in ticks) + _scale.Height = height; + for (var i = 0; i < _tickValues.Length; i++) { - var level = Models.AudioMeterScale.ToLevel(dbfs) / 100.0; - var y = Math.Clamp((1 - level) * height, 0, height); - var tick = new Border - { - Width = 4, - Height = 1, - Background = new SolidColorBrush(Windows.UI.Color.FromArgb(180, 126, 145, 156)) - }; - Canvas.SetLeft(tick, 0); - Canvas.SetTop(tick, Math.Clamp(y, 0, Math.Max(0, height - 1))); - canvas.Children.Add(tick); - - var label = new TextBlock - { - Text = dbfs.ToString("0"), - FontSize = 7, - Foreground = new SolidColorBrush(Windows.UI.Color.FromArgb(220, 160, 177, 187)) - }; - Canvas.SetLeft(label, 6); - Canvas.SetTop(label, Math.Clamp(y - 6, 0, Math.Max(0, height - 12))); - canvas.Children.Add(label); + var dbfs = _tickValues[i]; + var visible = height < 60 ? dbfs is 0 or -30 or -60 + : height < 100 ? dbfs != -6 && dbfs != -30 : dbfs != -30; + _ticks[i].Visibility = _labels[i].Visibility = visible ? Visibility.Visible : Visibility.Collapsed; + var y = (1 - Models.AudioMeterScale.ToLevel(dbfs) / 100.0) * height; + Canvas.SetTop(_ticks[i], Math.Clamp(y, 0, Math.Max(0, height - 1))); + Canvas.SetTop(_labels[i], Math.Clamp(y - 6, 0, Math.Max(0, height - 12))); } - - return canvas; } - private static SolidColorBrush BrushFor(double normalized) => + private SolidColorBrush BrushFor(double normalized) => normalized >= 0.9 ? RedBrush : normalized >= 0.7 ? YellowBrush : GreenBrush; } diff --git a/native-shell/CoreVideoPro.WinUI/Controls/ShowMultiviewHost.xaml.cs b/native-shell/CoreVideoPro.WinUI/Controls/ShowMultiviewHost.xaml.cs index 7f939183..d43cc37b 100644 --- a/native-shell/CoreVideoPro.WinUI/Controls/ShowMultiviewHost.xaml.cs +++ b/native-shell/CoreVideoPro.WinUI/Controls/ShowMultiviewHost.xaml.cs @@ -520,6 +520,13 @@ private void ApplyMeterLevels() private void StartOrStopClock() { + // Bindings can change on a cached, unloaded page. Only Loaded may + // rearm the display clock after Unloaded stopped it. + if (!IsLoaded) + { + StopClock(); + return; + } if (ShowClock) { ClockChrome.Visibility = Visibility.Visible; diff --git a/native-shell/CoreVideoPro.WinUI/Models/AudioMeterBallistics.cs b/native-shell/CoreVideoPro.WinUI/Models/AudioMeterBallistics.cs new file mode 100644 index 00000000..dde92e1c --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Models/AudioMeterBallistics.cs @@ -0,0 +1,51 @@ +namespace CoreVideoPro.WinUI.Models; + +/// Meter state driven by monotonic milliseconds; owns no UI objects or timers. +public sealed class AudioMeterBallistics +{ + private double _target; + private long _lastTick; + private long _peakAt; + public double Level { get; private set; } + public double Peak { get; private set; } + public bool NeedsAnimation => Level > _target || Peak > _target; + + public void Reset(double level, bool muted, bool showInput, long now) + { + Level = Peak = _target = Target(level, muted, showInput); + _lastTick = _peakAt = now; + } + + public void SetInput(double level, bool muted, bool showInput, long now) + { + // Account for elapsed time using the previous target before accepting + // the new sample. Snapshot rate must not change the release envelope. + Advance(now); + _target = Target(level, muted, showInput); + if (muted && !showInput) + { + Reset(0, true, false, now); + return; + } + Level = Math.Max(Level, _target); + if (_target >= Peak) + { + Peak = _target; + _peakAt = now; + } + } + + public void Advance(long now) + { + var elapsed = Math.Max(0, now - _lastTick); + if (Level > _target) + Level = Math.Max(_target, Level * Math.Exp(-elapsed / 300.0) - 0.25 * elapsed / 33.0); + var peakElapsed = Math.Max(0, now - Math.Max(_lastTick, _peakAt + 800)); + if (Peak > Level && peakElapsed > 0) + Peak = Math.Max(Level, Peak - 4.0 * peakElapsed / 33.0); + _lastTick = now; + } + + private static double Target(double level, bool muted, bool showInput) => + (muted && !showInput) || !double.IsFinite(level) ? 0 : Math.Clamp(level, 0, 100); +} diff --git a/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs b/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs index c15f1369..785bbbdf 100644 --- a/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs +++ b/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs @@ -70,6 +70,20 @@ public static AudioMeterSegmentLayout FitVerticalSegments(double availableHeight return new AudioMeterSegmentLayout(count, segmentSize, spacing); } + + public static AudioMeterSegmentLayout FitHorizontalSegments(double availableWidth, int requestedCount) + { + var count = Math.Clamp(requestedCount, 8, 48); + if (!double.IsFinite(availableWidth) || availableWidth <= 0) + return new(count, 4, 2); + var spacing = 2d; + if (count * 4 + (count - 1) * spacing <= availableWidth) + return new(count, 4, spacing); + spacing = 1; + count = Math.Min(count, Math.Max(1, (int)Math.Floor((availableWidth + 1) / 3))); + if (count == 1) spacing = 0; + return new(count, Math.Min(4, Math.Max(0, (availableWidth - spacing * (count - 1)) / count)), spacing); + } } public readonly record struct AudioMeterSegmentLayout(int SegmentCount, double SegmentSize, double Spacing) diff --git a/native-shell/CoreVideoPro.WinUI/Program.cs b/native-shell/CoreVideoPro.WinUI/Program.cs index 60cefce5..fc434244 100644 --- a/native-shell/CoreVideoPro.WinUI/Program.cs +++ b/native-shell/CoreVideoPro.WinUI/Program.cs @@ -14,8 +14,12 @@ public static void Main(string[] args) LaunchLog.Write($"base={AppContext.BaseDirectory}"); var runtimeProbe = args.Length == 2 && args[0] == "--verify-runtime"; + var meterProbe = args.Length == 3 && args[0] == "--verify-audio-meters"; + var probeSeconds = meterProbe ? int.Parse(args[1]) : 0; + if (meterProbe && probeSeconds is < 5 or > 86400) + throw new ArgumentOutOfRangeException(nameof(args), "Meter probe duration must be 5–86400 seconds."); #if !COREVIDEO_SELF_CONTAINED - var options = runtimeProbe ? Bootstrap.InitializeOptions.None : Bootstrap.InitializeOptions.OnNoMatch_ShowUI; + var options = runtimeProbe || meterProbe ? Bootstrap.InitializeOptions.None : Bootstrap.InitializeOptions.OnNoMatch_ShowUI; if (!Bootstrap.TryInitialize(0x00020004, null, new PackageVersion(), options, out var bootstrapHr)) { LaunchLog.Write($"Bootstrap.TryInitialize failed hr=0x{bootstrapHr:X8}"); @@ -35,7 +39,8 @@ public static void Main(string[] args) { var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); SynchronizationContext.SetSynchronizationContext(context); - new App(); + if (meterProbe) new App(new Services.AudioMeterStressProbe(probeSeconds, args[2])); + else new App(); }); } catch (Exception ex) diff --git a/native-shell/CoreVideoPro.WinUI/Services/AudioMeterStressProbe.cs b/native-shell/CoreVideoPro.WinUI/Services/AudioMeterStressProbe.cs new file mode 100644 index 00000000..c9585d6a --- /dev/null +++ b/native-shell/CoreVideoPro.WinUI/Services/AudioMeterStressProbe.cs @@ -0,0 +1,203 @@ +using System.Diagnostics; +using System.Text.Json; +using CoreVideoPro.WinUI.Controls; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Graphics; + +namespace CoreVideoPro.WinUI.Services; + +/// +/// Opt-in, isolated real-XAML regression host. Never constructs the production +/// window, connects to media/Zoom, or reads/writes an operator's show settings. +/// A native fail-fast produces no success report; the runner also checks exit code. +/// +internal sealed class AudioMeterStressProbe(int seconds, string reportPath) +{ + private Window? _window; + + internal async Task RunAsync() + { + using var stopGc = new CancellationTokenSource(); + Task? gcTask = null; + var elapsed = Stopwatch.StartNew(); + var frames = 0; + var unloads = 0; + var collections = 0; + var animationChecks = 0; + var destroyedMetersCollected = 0; + var passed = false; + string? error = null; + try + { + _window = new Window { Title = "CoreVideo meter stability probe" }; + _window.AppWindow.MoveAndResize(new RectInt32(-20000, -20000, 900, 700)); + var root = new Grid(); + for (var i = 0; i < 4; i++) + { + root.RowDefinitions.Add(new RowDefinition()); + root.ColumnDefinitions.Add(new ColumnDefinition()); + } + var meters = new AudioLevelMeter[16]; + for (var i = 0; i < meters.Length; i++) + { + var meter = new AudioLevelMeter { Width = 160, Height = 129, IsVertical = i % 2 == 0, + SegmentCount = 24, ShowDbfsScale = true }; + Grid.SetRow(meter, i / 4); + Grid.SetColumn(meter, i % 4); + root.Children.Add(meter); + meters[i] = meter; + } + _window.Content = root; + // Show without activation, outside the desktop viewport. XAML still + // performs real layout/Loaded/Unloaded work on its dispatcher. + _window.AppWindow.Show(false); + await Task.Delay(200); + Require(meters.All(m => m.IsLoaded), "Probe meters did not load"); + var trees = meters.Select(CaptureTree).ToArray(); + + // Mute semantics and the retained pool are checked on actual controls. + var first = meters[0]; + first.Level = 100; + var panel = (StackPanel)((Grid)first.Content).Children[0]; + var lit = ((Border)panel.Children[0]).Background; + first.IsMuted = true; + var dim = ((Border)panel.Children[0]).Background; + Require(!ReferenceEquals(lit, dim), "Output mute did not darken meter"); + Require(!first.AnimationRunning, "Output mute left animation running"); + first.ShowLevelWhileMuted = true; + var input = ((Border)panel.Children[0]).Background; + Require(!ReferenceEquals(input, dim) && !ReferenceEquals(input, lit), "Muted input is not visually distinct"); + + gcTask = Task.Run(async () => + { + while (!stopGc.IsCancellationRequested) + { + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + Interlocked.Increment(ref collections); + try { await Task.Delay(250, stopGc.Token); } + catch (OperationCanceledException) { break; } + } + }); + while (elapsed.Elapsed.TotalSeconds < seconds) + { + for (var i = 0; i < meters.Length; i++) + { + var meter = meters[i]; + meter.IsMuted = (frames + i) % 11 == 0; + meter.ShowLevelWhileMuted = (frames + i) % 3 == 0; + meter.Level = frames % 97 == 0 ? double.NaN : (frames * 13 + i * 17) % 101; + if (frames % 10 == 0) + { + meter.Width = 1 + (frames * 7 + i * 31) % 180; + meter.Height = 1 + (frames * 11 + i * 23) % 160; + meter.IsVertical = (frames / 40 + i) % 2 == 0; + meter.ShowDbfsScale = (frames / 30 + i) % 2 == 0; + meter.SegmentCount = (frames / 10 + i) % 4 == 0 ? int.MaxValue : 8 + i * 3; + } + VerifyTree(meter, trees[i]); + if (meter.AnimationRunning) animationChecks++; + } + if (frames % 60 == 59) + { + // Real unload/reload, plus binding updates while detached. + _window.Content = new Grid(); + await Task.Delay(40); + foreach (var meter in meters) + { + Require(!meter.IsLoaded, "Detached meter stayed loaded"); + meter.Level = 100; + meter.Level = 0; + Require(!meter.AnimationRunning, "Unloaded meter restarted timer"); + } + _window.Content = root; + await Task.Delay(40); + Require(meters.All(m => m.IsLoaded), "Meter did not reload"); + unloads++; + } + frames++; + await Task.Delay(16); + } + Require(animationChecks > 0 && unloads > 0, "Animation/lifecycle paths were not exercised"); + foreach (var meter in meters) { meter.IsMuted = false; meter.Level = 0; } + await Task.Delay(2500); + Require(meters.All(m => !m.AnimationRunning), "Silent meters did not become idle"); + for (var i = 0; i < meters.Length; i++) VerifyTree(meters[i], trees[i]); + + // Release entire meter trees too, so page destruction—not only + // retained-page navigation—runs through the real WinRT finalizers. + _window.Content = null; + await Task.Delay(50); + Require(meters.All(m => !m.AnimationRunning), "Shutdown left timers running"); + var discarded = new List(); + for (var i = 0; i < 20; i++) discarded.Add(await CreateDiscardedMeterAsync()); + // Allow XAML reference tracking and UI-affine release work to run + // between collections. Never wait for finalizers on the UI thread. + for (var i = 0; i < 5; i++) + { + await Task.Run(() => { GC.Collect(); GC.WaitForPendingFinalizers(); }); + await Task.Delay(100); + } + destroyedMetersCollected = discarded.Count(reference => !reference.IsAlive); + Require(destroyedMetersCollected == discarded.Count, "Discarded meter was retained after unload/GC"); + passed = true; + } + catch (Exception ex) + { + error = ex.ToString(); + } + finally + { + stopGc.Cancel(); + if (gcTask is not null) await gcTask; + var report = new { passed, error, frames, unloads, collections, animationChecks, destroyedMetersCollected, + elapsedSeconds = elapsed.Elapsed.TotalSeconds, processId = Environment.ProcessId }; + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(reportPath))!); + await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); + Environment.ExitCode = passed ? 0 : 1; + _window?.Close(); + } + } + + private async Task CreateDiscardedMeterAsync() + { + var meter = new AudioLevelMeter { Width = 80, Height = 129, IsVertical = true, ShowDbfsScale = true }; + _window!.Content = meter; + await Task.Delay(40); + Require(meter.IsLoaded, "Destruction-test meter did not load"); + meter.Level = 100; + meter.Level = 0; + Require(meter.AnimationRunning, "Destruction-test timer did not start"); + _window.Content = null; + await Task.Delay(40); + Require(!meter.IsLoaded && !meter.AnimationRunning, "Destroyed page retained its meter timer"); + return new WeakReference(meter); + } + + private static DependencyObject[] CaptureTree(AudioLevelMeter meter) + { + var root = (Grid)meter.Content; + var panel = (StackPanel)root.Children[0]; + var scale = (Canvas)root.Children[1]; + return new DependencyObject[] { root, panel, scale } + .Concat(panel.Children.Cast()).Concat(scale.Children.Cast()).ToArray(); + } + + private static void VerifyTree(AudioLevelMeter meter, DependencyObject[] original) + { + var current = CaptureTree(meter); + Require(current.Length == 67 && current.Length == original.Length, "Meter visual pool is not bounded"); + for (var i = 0; i < current.Length; i++) + Require(ReferenceEquals(current[i], original[i]), "Meter replaced a retained visual"); + var panel = (StackPanel)current[1]; + var visible = panel.Children.Cast().Where(b => b.Visibility == Visibility.Visible).ToArray(); + Require(visible.Length is > 0 and <= 48, "Invalid visible segment count"); + Require(visible.All(b => double.IsFinite(b.Width) && double.IsFinite(b.Height)), "Invalid meter geometry"); + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } +} diff --git a/native-shell/CoreVideoPro.WinUI/Services/ParticipantMapper.cs b/native-shell/CoreVideoPro.WinUI/Services/ParticipantMapper.cs index 43498c62..1fbc7df6 100644 --- a/native-shell/CoreVideoPro.WinUI/Services/ParticipantMapper.cs +++ b/native-shell/CoreVideoPro.WinUI/Services/ParticipantMapper.cs @@ -26,6 +26,17 @@ public static IReadOnlyList ToParticipants( IReadOnlyList participants) => participants.Select(ToParticipant).ToList(); + public static bool HasMuteChanges( + IReadOnlyList previous, + IReadOnlyList current) + { + if (previous.Count != current.Count) return true; + var previousMute = previous.ToDictionary(participant => participant.Id, participant => participant.IsMuted, + StringComparer.Ordinal); + return current.Any(participant => + !previousMute.TryGetValue(participant.Id, out var muted) || muted != participant.IsMuted); + } + // All participants in the room (video on OR off). Used for the Sources/Inputs // picker so an operator can assign a participant to an Input slot even when // their camera is momentarily off — the video flows once they enable it. @@ -67,4 +78,4 @@ private static FeedHealth ParseHealth(string healthLabel) => "recovering" => FeedHealth.Recovering, _ => FeedHealth.Live }; -} \ No newline at end of file +} diff --git a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs index 9a91d4f5..c46ebbf4 100644 --- a/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs +++ b/native-shell/CoreVideoPro.WinUI/ViewModels/StudioViewModel.cs @@ -10612,14 +10612,32 @@ private void ApplyLiveParticipants(IReadOnlyList operator-visible stutter // (present-stutter-fix-spec P1). Live video/audio/tally update via their own - // binding paths, not these rebuilds, so when the set is unchanged there is nothing - // to do here. The signature captures per-participant health/screen-share + the + // binding paths, including the telemetry refresh above. The signature captures video/screen-share + the // device set, so a genuine change (join/leave/health/screen-share) still refreshes. // Signature must flip ONLY on genuinely structural changes: which participants // are present, whether each is video-OFF (VideoOff is filtered out of the video diff --git a/package.json b/package.json index 1b90945f..49c5dcb1 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:native-recording-proof": "node scripts/native-recording-proof.mjs", "test:native-shell": "dotnet test native-shell/CoreVideoPro.MediaCore.Tests/CoreVideoPro.MediaCore.Tests.csproj --logger trx --results-directory artifacts/test-results/mediacore && dotnet test native-shell/CoreVideoPro.Control.Tests/CoreVideoPro.Control.Tests.csproj --logger trx --results-directory artifacts/test-results/control && dotnet test native-shell/CoreVideoPro.WinUI.Tests/CoreVideoPro.WinUI.Tests.csproj -p:WindowsAppSDKBootstrapAutoInitializeOptions_Default=false -p:WindowsAppSDKBootstrapAutoInitializeOptions_None=true --blame-hang-timeout 2m --blame-hang-dump-type mini --logger trx --results-directory artifacts/test-results/winui && powershell -ExecutionPolicy Bypass -File native-shell/test-media-core-bridge.ps1", "test:native-shell-smoke": "powershell -ExecutionPolicy Bypass -File scripts/test-native-shell-smoke.ps1", + "test:audio-meter-stability": "powershell -ExecutionPolicy Bypass -File scripts/test-audio-meter-stability.ps1", "test:native-shell-dev-readiness": "powershell -ExecutionPolicy Bypass -File scripts/test-native-shell-dev-readiness.ps1", "test:studio-workflow": "powershell -ExecutionPolicy Bypass -File scripts/test-studio-workflow.ps1", "alpha:preflight": "powershell -ExecutionPolicy Bypass -File scripts/alpha-preflight.ps1", diff --git a/scripts/test-audio-meter-stability.ps1 b/scripts/test-audio-meter-stability.ps1 new file mode 100644 index 00000000..596514f4 --- /dev/null +++ b/scripts/test-audio-meter-stability.ps1 @@ -0,0 +1,33 @@ +param( + [ValidateRange(5, 86400)][int]$Seconds = 180, + [string]$Executable, + [string]$Report +) +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path $PSScriptRoot -Parent +if (-not $Executable) { + $Executable = Join-Path $repoRoot 'native-shell/CoreVideoPro.WinUI/bin/x64/Release/net9.0-windows10.0.19041.0/win-x64/CoreVideoPro.WinUI.exe' +} +$Executable = (Resolve-Path -LiteralPath $Executable).Path +if (-not $Report) { + $Report = Join-Path $repoRoot ('artifacts/test-results/meter-stress-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.json') +} +$Report = [IO.Path]::GetFullPath($Report) +if (Test-Path -LiteralPath $Report) { throw "Use a new report path; an old success must not mask a failed run: $Report" } +New-Item -ItemType Directory -Force (Split-Path $Report -Parent) | Out-Null +$arguments = @('--verify-audio-meters', "$Seconds", ('"' + $Report + '"')) +$probe = Start-Process -FilePath $Executable -ArgumentList $arguments -WindowStyle Hidden -PassThru +Write-Output "Meter stress PID=$($probe.Id), duration=$Seconds seconds, report=$Report" +$deadline = [DateTime]::UtcNow.AddSeconds($Seconds + 90) +while (-not $probe.WaitForExit(1000)) { + if ([DateTime]::UtcNow -gt $deadline) { + # Only terminate this script's own isolated probe, never the operator app. + $probe.Kill() + throw "Meter probe timed out (PID $($probe.Id))." + } +} +if ($probe.ExitCode -ne 0) { throw "Meter probe failed or crashed: exit=$($probe.ExitCode), PID=$($probe.Id)." } +if (-not (Test-Path -LiteralPath $Report)) { throw 'Probe exited without writing its report.' } +$result = Get-Content -LiteralPath $Report -Raw | ConvertFrom-Json +if (-not $result.passed) { throw "Meter probe failed: $($result.error)" } +$result | Format-List From 929767d049740a84dbc341bbcd3009743a1493ec Mon Sep 17 00:00:00 2001 From: John Wallace Date: Tue, 15 Sep 2026 21:37:54 -0400 Subject: [PATCH 3/3] Remove superseded windowless meter probe --- .../CoreVideoPro.WinUI.LifetimeTests.csproj | 26 ---- .../Program.cs | 124 ------------------ 2 files changed, 150 deletions(-) delete mode 100644 native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj delete mode 100644 native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs diff --git a/native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj b/native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj deleted file mode 100644 index eb89f7c1..00000000 --- a/native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - Exe - net9.0-windows10.0.19041.0 - 10.0.17763.0 - x64 - win-x64 - true - None - true - enable - enable - $(DefineConstants);DISABLE_XAML_GENERATED_MAIN - - - - - - - - - - - - diff --git a/native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs b/native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs deleted file mode 100644 index 2c4e6218..00000000 --- a/native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Reflection; -using CoreVideoPro.WinUI.Controls; -using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using Windows.Foundation; - -// Run on a real WinUI dispatcher, without opening a window or touching the -// operator's running application. This tests reuse, not reproduction of #513. -internal static class Program -{ - private const BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic; - private static int _exitCode = 1; - - [STAThread] - private static int Main() - { - WinRT.ComWrappersSupport.InitializeComWrappers(); - Application.Start(_ => - { - SynchronizationContext.SetSynchronizationContext( - new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread())); - var app = new Application(); - DispatcherQueue.GetForCurrentThread().TryEnqueue(async () => - { - try - { - await RunAsync(); - Console.WriteLine("PASS: meter identity, bounded pools, layout, fill, mute, and unloaded timer checks"); - _exitCode = 0; - } - catch (Exception ex) - { - Console.Error.WriteLine(ex); - } - finally { app.Exit(); } - }); - }); - return _exitCode; - } - - private static async Task RunAsync() - { - var meter = new AudioLevelMeter { SegmentCount = 48, IsVertical = true, ShowDbfsScale = true }; - var root = (Grid)meter.Content; - var panel = (StackPanel)root.Children[0]; - var scale = (Canvas)root.Children[1]; - var render = typeof(AudioLevelMeter).GetMethod("RenderSegments", PrivateInstance)!; - void Render(double width, double height) - { - meter.Measure(new Size(width, height)); - meter.Arrange(new Rect(0, 0, width, height)); - render.Invoke(meter, null); - } - Render(80, 600); - Check(panel.Children.Count == 48, "48-segment warmup"); - Check(scale.Children.Count == 14, "seven scale marks"); - var segments = panel.Children.ToArray(); - var marks = scale.Children.ToArray(); - - for (var i = 0; i < 10000; i++) - { - meter.IsVertical = i % 2 == 0; - meter.ShowDbfsScale = i % 3 != 0; - meter.SegmentCount = 8 + i % 41; - meter.IsMuted = i % 5 == 0; - meter.ShowLevelWhileMuted = i % 7 == 0; - meter.Level = i % 101; - Render(80 + i % 300, new double[] { 40, 80, 600 }[i % 3]); - Check(ReferenceEquals(root, meter.Content), "root retained"); - Check(panel.Children.Count == 48 && scale.Children.Count == 14, "bounded pools"); - Check(segments.SequenceEqual(panel.Children), "segment identities retained"); - Check(marks.SequenceEqual(scale.Children), "scale identities retained"); - Check(panel.Orientation == (meter.IsVertical ? Orientation.Vertical : Orientation.Horizontal), "orientation"); - Check(typeof(AudioLevelMeter).GetField("_decayTimer", PrivateInstance)!.GetValue(meter) is null, - "level changes cannot start unloaded timer"); - } - - meter.IsVertical = true; - meter.ShowDbfsScale = true; - meter.SegmentCount = 36; - foreach (var height in new[] { 40d, 80d, 600d }) - { - Render(80, height); - var expected = height < 60 ? new[] { "0", "-30", "-60" } - : height < 100 ? new[] { "0", "-12", "-24", "-36", "-48", "-60" } - : new[] { "0", "-6", "-12", "-24", "-36", "-48", "-60" }; - Check(scale.Children.OfType().Where(x => x.Visibility == Visibility.Visible) - .Select(x => x.Text).SequenceEqual(expected), "responsive scale labels"); - var visible = panel.Children.OfType().Where(x => x.Visibility == Visibility.Visible).ToArray(); - Check(Math.Abs(visible.Sum(x => x.Height) + panel.Spacing * (visible.Length - 1) - height) < 0.01, - "segments fit available height"); - } - - meter.IsVertical = false; - meter.IsMuted = false; - meter.Level = 100; - Render(600, 40); - var first = (Border)panel.Children[0]; - Check(((SolidColorBrush)first.Background).Color.G == 210, "green low segment"); - meter.IsMuted = true; - meter.ShowLevelWhileMuted = false; - Render(600, 40); - Check(((SolidColorBrush)first.Background).Color.G == 30, "mute immediately darkens meter"); - meter.ShowLevelWhileMuted = true; - Render(600, 40); - Check(((SolidColorBrush)first.Background).Color.G == 122, "pre-mute input color"); - Check(scale.Visibility == Visibility.Collapsed, "horizontal scale hidden"); - Check(Grid.GetColumnSpan(panel) == 2, "unscaled meter spans host"); - - // Leave the UI dispatcher free while finalizers run; blocking it can - // deadlock ordinary apartment-marshaled releases and is not a valid test. - await Task.Run(() => { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); }); - Render(600, 40); - Check(segments.SequenceEqual(panel.Children), "control remains usable after full GC"); - GC.KeepAlive(meter); - } - - private static void Check(bool condition, string message) - { - if (!condition) throw new InvalidOperationException(message); - } -}