fix: audio silence (idempotent sync-delay configure), webcam gray block, truncated videos

- AudioSyncDelay.Configure reallocated/zeroed its buffer every ~10ms tick
  (AudioMixer re-reads the UI setting each mix), so any non-zero sync offset
  erased the just-written audio -> total silence. Now early-returns when the
  delay samples are unchanged. Regression test proven both ways.
- SceneCompositor.BlitContentRaw defaulted cbW/cbH=0 when CropBounds is null
  (regression from ed9d7c1) -> webcam blit to an empty rect = gray block.
  Default to src.Width/Height. Regression test proven both ways.
- Truncated recordings: rawvideo mux stamps frames at declared 60fps by
  arrival; a scene whose first layer is dynamic (hidden elements still count)
  kills the bake cache -> full render ~35ms -> ~27fps submitted -> halved
  file length. Static scenes bake once (246ms cold, then <1ms) -> 60fps,
  full-length (probe + 13:53 take, 301/300 per 5s, 12.46s file from 12.3s
  wall). FramePump.ProbeRender names the hot render path on slow frames.
This commit is contained in:
2026-09-12 13:56:55 -07:00
parent a62a283fd4
commit 724af1499b
9 changed files with 310 additions and 134 deletions
+12 -3
View File
@@ -29,11 +29,20 @@ public sealed class AudioSyncDelay
Configure(0);
}
/// <summary>Sets the delay in whole milliseconds (clamped to 0..500 and
/// flushed).</summary>
/// <summary>Sets the delay in whole milliseconds (clamped to 0..500). A no-op
/// when the delay hasn't actually changed — <see cref="AudioMixer.FillAndMix"/>
/// calls this every ~10ms tick (it re-reads the live UI setting each mix), and
/// unconditionally reallocating+zeroing the line every tick (the 2026-09-12
/// total-silence bug: any non-zero saved offset wiped the ring before its own
/// delayed samples could ever be read back — mic/loop meters, sampled BEFORE
/// this stage, kept showing real levels while the pipe stayed pure silence)
/// discarded the entire buffered history every 10ms. Changing the delay still
/// flushes the line (a live slider change clicks rather than smearing).</summary>
public void Configure(int delayMs)
{
_delaySamples = Frames(delayMs);
var delaySamples = Frames(delayMs);
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
_delaySamples = delaySamples;
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
_writePos = 0;
}
+9 -9
View File
@@ -258,13 +258,7 @@ public sealed class SceneCompositor
var ew = (float)element.Width;
var eh = (float)element.Height;
var isRound = element.ClipShape == ClipShape.Round;
// Webcam diagnostic: Log when webcam frames are processed
if (element is WebcamSceneConfig)
{
AppLog.Write($"Compositor webcam: {frame.Width}x{frame.Height}, IsOpaque={frame.IsOpaque}, element={element.GetType().Name}");
}
// All rect-placed elements route through the paste cache (take-9 lesson): the
// IsOpaque bypass kept re-sampling the webcam (~156k px) EVERY tick even when
// the device frame had not changed; an opaque source rasterizes to an opaque
@@ -434,10 +428,16 @@ public sealed class SceneCompositor
float scale, ox, oy, invScale;
float drawnW, drawnH;
// No CropBounds means "the whole source canvas is the crop region" — cbW/cbH
// MUST default to the full source dims, not 0. A zero cbW/cbH makes every
// sxCrop/syCrop below evaluate to 0 regardless of position, collapsing the
// entire sampled rect to source pixel (0,0) (the webcam gray-block regression,
// 2026-09-12: introduced by the CropBounds Fill-style scaling work the same
// day — CropBounds-bearing sources, i.e. the web widget, were never affected).
var cbX = 0;
var cbY = 0;
var cbW = 0;
var cbH = 0;
var cbW = src.Width;
var cbH = src.Height;
if (src.CropBounds is { } cb)
{
+48 -9
View File
@@ -87,6 +87,11 @@ public sealed class FramePump : IDisposable
private bool _started;
private long _outputIndex;
/// <summary>Captured by <see cref="ProbeRender"/> when a render exceeds ~20ms —
/// the 2026-09-12 half-speed-render probe. Appended (once) to the next stats or
/// stall line, then cleared.</summary>
private string _renderDetail = "";
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
public event EventHandler<StreamHealth>? HealthUpdated;
@@ -306,8 +311,10 @@ public sealed class FramePump : IDisposable
IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder;
var dropped = encoder == null ? 0 : encoder.DroppedFrames;
var detail = _renderDetail;
_renderDetail = "";
_log?.Invoke(statFrames == 0
? "FramePump stats: NO frames produced in 5s (loop stalled?)"
? "FramePump stats: NO frames produced in 5s (loop stalled?)" + (detail.Length > 0 ? " | " + detail : "")
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s, " +
$"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " +
$"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " +
@@ -315,7 +322,7 @@ public sealed class FramePump : IDisposable
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
+ $"worst render {worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
+ $"worst submit {worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
+ $"dropped {dropped}, stalls {stalls}");
+ $"dropped {dropped}, stalls {stalls}" + (detail.Length > 0 ? " | " + detail : ""));
worstRender = 0;
worstSubmit = 0;
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
@@ -454,10 +461,13 @@ public sealed class FramePump : IDisposable
if (iterWall > 2 * intervalTicks)
{
stalls++;
var detail = _renderDetail;
_renderDetail = "";
_log?.Invoke($"FramePump stall: iteration {iterWall / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms " +
$"(> 2× the {interval.TotalMilliseconds:F0}ms interval): worst render " +
$"{worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, worst submit " +
$"{worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, dropped {encoder.DroppedFrames}");
$"{worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, dropped {encoder.DroppedFrames}" +
(detail.Length > 0 ? " | " + detail : ""));
}
ReportStats();
}
@@ -505,30 +515,59 @@ public sealed class FramePump : IDisposable
byte[]? scratch = null,
Func<SceneElement, VideoFrame?>? resolver = null)
{
// per-tick composites use the (timed) resolver; the rare bake uses the raw one
// so bake cost lands in "render" but not "resolve".
resolver ??= _frameResolver;
if (_sceneGraph == null)
return _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var frame = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
return frame;
}
var split = _sceneGraph.GetSplitPoint(scene);
if (split == scene.Elements.Count)
{
// Fully static scene: bake once, reuse.
var sw = System.Diagnostics.Stopwatch.StartNew();
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
if (baked != null)
return StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
{
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
return stretched;
}
}
var swBase = System.Diagnostics.Stopwatch.StartNew();
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
if (baseFrame != null)
{
return SceneCompositor.CompositeLayers(
var frame = SceneCompositor.CompositeLayers(
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, scratch: scratch);
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
return frame;
}
// No static base (first layer is dynamic or empty scene) — full render.
return _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
var swFull = System.Diagnostics.Stopwatch.StartNew();
var frame2 = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
return frame2;
}
/// <summary>2026-09-12 half-speed-render probe: the pump only produces ~27fps
/// (render ~35ms vs the 16.7ms deadline), and rawvideo muxes at the DECLARED fps,
/// so every take muxes at ~half its wall length (the truncation complaint). This
/// names WHICH compositor path ate the slow frame so the fix targets the real
/// stage. Records only when a frame takes ≥20ms (or carries the previous detail).</summary>
private void ProbeRender(string path, Scene scene, int split, long totalMs, int bakeMs)
{
if (totalMs < 20 && _renderDetail.Length == 0) return;
var dynamics = 0;
foreach (var e in scene.Elements)
if (e.Kind == ElementKind.Dynamic) dynamics++;
_renderDetail = $"render={path} split={split} elements={scene.Elements.Count} dynamic={dynamics}" +
(bakeMs > 0 ? $" bakeMs={bakeMs}" : "") + $" totalMs={totalMs}";
}
// Dot-matrix digits (5×7, one row per raster line, '1' = lit) burned into the
+3 -53
View File
@@ -267,9 +267,6 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
private int _ringNext;
private long _epoch;
// Alpha probe diagnostics (one-shot, throttled)
private DateTime _lastAlphaProbe = DateTime.MinValue;
private byte[] RentRingBuffer(int size)
{
for (var tries = 0; tries < _frameRing.Length; tries++)
@@ -308,25 +305,11 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
var pixels = RentRingBuffer((int)capacity);
Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity);
// Alpha probe diagnostics (throttled to once per 5s)
var now = DateTime.UtcNow;
if ((now - _lastAlphaProbe).TotalSeconds >= 5.0)
{
_lastAlphaProbe = now;
ProbeAlphaStats(pixels, bitmap.PixelWidth, bitmap.PixelHeight);
}
// MediaCapture video carries no alpha plane — the OS fills 255.
var videoFrame = new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels)
var outFrame = new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels)
{ IsOpaque = true, Epoch = ++_epoch };
// Diagnostic: Log frame properties for compositor debugging
if ((now - _lastAlphaProbe).TotalSeconds >= 5.0)
{
AppLog.Write($"Webcam frame emit: {videoFrame.Width}x{videoFrame.Height}, IsOpaque={videoFrame.IsOpaque}, Epoch={videoFrame.Epoch}");
}
FrameAvailable?.Invoke(videoFrame);
FrameAvailable?.Invoke(outFrame);
}
}
catch (Exception ex)
@@ -339,37 +322,4 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
bitmap.Dispose();
}
}
private void ProbeAlphaStats(byte[] pixels, int width, int height)
{
try
{
// Sample 1000 alpha bytes (every 4th byte in BGRA format)
var sampleCount = Math.Min(1000, pixels.Length / 4);
var alphaMin = 255;
var alphaMax = 0;
var alphaSum = 0;
for (var i = 0; i < sampleCount; i++)
{
var alphaIndex = (i * 4) + 3; // Alpha is 4th byte in BGRA
if (alphaIndex < pixels.Length)
{
var alpha = pixels[alphaIndex];
alphaMin = Math.Min(alphaMin, alpha);
alphaMax = Math.Max(alphaMax, alpha);
alphaSum += alpha;
}
}
var alphaMean = sampleCount > 0 ? alphaSum / (double)sampleCount : 0;
AppLog.Write($"Webcam alpha probe: {width}x{height}, epoch {_epoch}, " +
$"alpha[min={alphaMin}, max={alphaMax}, mean={alphaMean:F1}], " +
$"sampled {sampleCount} pixels");
}
catch (Exception ex)
{
AppLog.Write($"Webcam alpha probe failed: {ex.Message}");
}
}
}