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:
+27
-30
@@ -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`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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/
|
||||||
|
|||||||
Reference in New Issue
Block a user