TASK 4 ship step 6: health stats land in the bottom bar — FramePump.HealthUpdated is now wired to the VM (OnFramePumpHealthUpdated marshals onto the UI thread — the encoder's stderr loop raises on a background thread — and copies the parsed bitrate/FPS/dropped/duration into CurrentHealth, the bottom bar's existing binding); ResetHealth zeroes dropped frames + elapsed duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets); the bar shows real encoder values once TASK 5 fills _rtmpUrlProvider (until then the pump skips the encoder) — 169 tests passing, 0 warnings

This commit is contained in:
2026-08-13 15:56:29 -07:00
parent dc29adf9bb
commit 8292663791
5 changed files with 77 additions and 35 deletions
+27 -30
View File
@@ -7,36 +7,32 @@
## Session state (last updated: 2026-08-13) ## Session state (last updated: 2026-08-13)
- **Branch:** `main`. The audio UX follow-up (`9f9ed34`) and round 2 (`18abe42`) - **Branch:** `main`. The audio UX follow-up (`9f9ed34`), round 2 (`18abe42`),
are **committed and pushed**. and round 3 (`dc29adf`) are **committed and pushed**.
- **This session (round 3 — mic mute icon + REC sign):** - **This session (TASK 4 ship step 6 — health stats in the bottom bar):**
1. **Mic mute icon:** the mic bar gained a **microphone glyph** (same 16px style 1. **Wiring:** `MainViewModel.OnFramePumpHealthUpdated` subscribes to
as the speaker, white → red + slash when muted, `ToolTip=MicMuteText`) `FramePump.HealthUpdated` and marshals onto the UI thread (the encoder's
**between the meter and the speaker** — both mutes adjacent with spacing stderr loop raises on a background thread — same pattern as the audio
between the icons; click reuses the same `MicSpeaker_MouseLeftButtonUp` level handlers), copying the parsed bitrate/FPS/dropped/duration into
handler → `ToggleMicMuteCommand`. No VM/code-behind change. `CurrentHealth`, which the bottom bar's left stats group already binds
2. **REC sign (top center):** the old LIVE badge (hidden when offline) became an (bitrate/FPS/dropped/duration/health-message).
**always-visible REC chip** — dark gray dot (#555) + dim "REC" (#888) offline, 2. **Session hygiene:** `ResetHealth(status)` zeroes dropped frames + the
**bright red (#e94560)** + "REC" + elapsed while live, **darker red (#8f1f1f)** elapsed duration (and clears `LastError`) on go-live and on End so stats
when live with a **private** stream. Driven by new VM props never linger from a previous stream; bitrate/FPS stay on the tier's
`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate` (notified from the targets from `ApplyStreamQuality`.
`StreamStatus` and `StreamVisibility` setters), privacy read off the **dialog's 3. **Reality:** the bar lights up with REAL encoder values once TASK 5 fills
chosen visibility** (`StreamVisibility == "Private"`) — the stream service still `_rtmpUrlProvider` with the reusable stream's ingest URL — until then the
hardcodes `privacyStatus = "public"`, so the private-REC state only lights once pump skips the encoder (logged) and the bar shows the tier's targets.
that's honored (TASK 4 ship step 7). The dot pulses while live; `LiveIndicatorVisible` No new tests needed: `FramePumpTests.HealthUpdated_ForwardsEncoderHealth`
and `LivePulseOpacity` were removed. already covers the pump→event seam; the VM handler is a thin marshal+copy.
3. **Voice filters queued as task 22** — bass boost, treble, noise suppression,
compressor on the mic input channel, KISS, always on (before the meter/encoder mix).
- **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is - **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is
**red until a mic resource is actually connected**. `MicStatus` starts `NotConnected` **red until a mic resource is actually connected**. `MicStatus` starts `NotConnected`
(red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly (red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly
from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero
devices at startup → stays red and the mixer is never started; capture failure → yellow. devices at startup → stays red and the mixer is never started; capture failure → yellow.
Green must never be raised earlier (e.g. on loopback start or on `Start()` being called). Green must never be raised earlier (e.g. on loopback start or on `Start()` being called).
- **Uncommitted:** this round-3 work`ViewModels/MainViewModel.cs` (REC props, - **Uncommitted:** this session`ViewModels/MainViewModel.cs` (health wiring +
`_recDotPulse`, StreamVisibility/StreamStatus notifications, `LiveIndicatorVisible`/ `ResetHealth`), `TASKS.md` (ship step 6 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
`LivePulseOpacity` removed), `MainWindow.xaml` (REC chip + mic mute icon),
`TASKS.md` (task 22 + round-3 audio note), `ai.md`, `ViewModels/index.md`, `HANDOFF`.
- **Verified:** pending (build + full test run right before commit). - **Verified:** pending (build + full test run right before commit).
- **Landmines:** - **Landmines:**
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently - Never set a local `Canvas.SetTop` on the social bar — a local value permanently
@@ -64,12 +60,13 @@
integration test (a real `MainWindow`), which never goes live — keep it that way. integration test (a real `MainWindow`), which never goes live — keep it that way.
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests - Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
only, never the real instance. only, never the real instance.
- **Next step:** commit + push round 3 (one commit). Then TASK 4 ship step 6 — health - **Next step:** commit + push this ship-step-6 work (one commit). Then the remaining
stats: bind `FramePump.HealthUpdated` (bitrate/FPS/duration) into the bottom bar. TASK 4 requirement is **ship step 7 — one-click go live + private-only enforcement**
Also queued: task 21 (logo + About hub), task 22 (voice filters). Nothing else (honor the dialog's chosen visibility / enforce `privacyStatus = "private"`; also
queued — do not expand the task queue on your own. Optional, not queued: fixes the REC sign's private state — see round-3 note above). Also queued: task 21
rewriting the healed entry's `ProfileUrl` to (logo + About hub), task 22 (voice filters). Nothing else queued — do not expand
`https://mastodon.llamachile.tube/@gramps` (user must say the word). the task queue on your own. Optional, not queued: rewriting the healed entry's
`ProfileUrl` to `https://mastodon.llamachile.tube/@gramps` (user must say the word).
- **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; - **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`;
OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`);
+1 -1
View File
@@ -204,7 +204,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
3.**Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below) 3.**Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below)
4.**WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below) 4.**WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below)
5.**Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below) 5.**Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below)
6. **Health stats** bitrate, FPS, dropped frames reported live in the bottom bar (req 5) 6. **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets)
7.**One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable) 7.**One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable)
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
+39
View File
@@ -1078,6 +1078,7 @@ public class MainViewModel : ViewModelBase
log: message => AppLog.Write(message), log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom)); socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
_framePump.Failed += OnFramePumpFailed; _framePump.Failed += OnFramePumpFailed;
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout(); LoadLayout();
_ = LoadSavedSessionAsync(); _ = LoadSavedSessionAsync();
@@ -1965,6 +1966,7 @@ public class MainViewModel : ViewModelBase
? "ytLlive" ? "ytLlive"
: $"{dialog.StreamTitle} — ytLlive"; : $"{dialog.StreamTitle} — ytLlive";
StreamStatus = StreamStatus.Streaming; StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_ = _framePump.StartAsync(); // never throws; failures log + surface via Failed _ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
} }
} }
@@ -1973,6 +1975,7 @@ public class MainViewModel : ViewModelBase
{ {
StreamStatus = StreamStatus.Offline; StreamStatus = StreamStatus.Offline;
WindowTitle = "ytLlive"; WindowTitle = "ytLlive";
ResetHealth(StreamStatus.Offline);
// Audio capture is always-on (preview monitoring); only the frame pump // Audio capture is always-on (preview monitoring); only the frame pump
// and the session stop here. // and the session stop here.
_ = _framePump.StopAsync(); _ = _framePump.StopAsync();
@@ -2114,6 +2117,42 @@ public class MainViewModel : ViewModelBase
if (IsLive) StreamStatus = StreamStatus.Error; if (IsLive) StreamStatus = StreamStatus.Error;
} }
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
// duration) lands in the bottom bar. The stderr loop raises on a background
// thread — marshal to the UI thread like the audio level handlers.
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => ApplyHealth(health));
else
ApplyHealth(health);
}
private void ApplyHealth(StreamHealth health)
{
CurrentHealth.Status = health.Status;
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
CurrentHealth.FPS = health.FPS;
CurrentHealth.DroppedFrames = health.DroppedFrames;
CurrentHealth.StreamDuration = health.StreamDuration;
CurrentHealth.LastError = health.LastError;
CurrentHealth.HealthMessage = health.HealthMessage;
OnPropertyChanged(nameof(CurrentHealth));
}
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
// on the tier's targets — ApplyStreamQuality sets them on pick).
private void ResetHealth(StreamStatus status)
{
CurrentHealth.Status = status;
CurrentHealth.DroppedFrames = 0;
CurrentHealth.StreamDuration = TimeSpan.Zero;
CurrentHealth.LastError = null;
OnPropertyChanged(nameof(CurrentHealth));
}
private void UpdateLiveVisuals() private void UpdateLiveVisuals()
{ {
var live = IsLive; var live = IsLive;
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -3
View File
@@ -402,7 +402,7 @@ devices, no timers).
**Deferred:** the loopback→encoder AAC mix wiring (a later step — the encoder construction + frame **Deferred:** the loopback→encoder AAC mix wiring (a later step — the encoder construction + frame
pipeline shipped in ship step 5). pipeline shipped in ship step 5).
### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, plan in TASKS.md) ### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md)
The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active
scene each tick, resolves every element to its latest frame, composites it into the tier's output frame, scene each tick, resolves every element to its latest frame, composites it into the tier's output frame,
@@ -417,8 +417,14 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
until TASK 5 supplies the reusable stream's ingest URL, so go-live runs the current visual flow. until TASK 5 supplies the reusable stream's ingest URL, so go-live runs the current visual flow.
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits) - **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the **before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
reverse order would deadlock. `ProcessFailed` self-stops the pump. `HealthUpdated` is forwarded reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
(ship step 6 binds it to the bottom bar); `Failed` while live flips `StreamStatus.Error` (minimal). `StreamStatus.Error` (minimal).
- **Health stats (ship step 6, shipped 2026-08-13):** `HealthUpdated` is bound to the bottom bar —
`MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the encoder's stderr loop raises on
a background thread) and copies into `CurrentHealth` (the bottom bar's existing `CurrentHealth.*`
bindings). `ResetHealth(status)` zeroes dropped/duration on go-live and on End so stats never linger
from a previous session; bitrate/FPS stay on the tier's targets (`ApplyStreamQuality`). The bar shows
real encoder values once TASK 5 fills `_rtmpUrlProvider`; until then the pump skips the encoder.
- **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig` → - **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig` →
`CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }` → `CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }` →
`ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/ `ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/