Files
LlamaCasty/Services/index.md
T
gramps 9c0c40c688 feat(settings): make slide-out alerts persist until dismissed
Add a persisted global App Settings switch. Off keeps the existing per-severity
lifetimes (errors remain sticky); on makes every alert non-expiring and closes
it when its body is clicked. The NotificationService supplies the current
setting to each toast request.

StickyNotificationIntegrationTests covers persistence and both request modes.
618 tests green, clean build with 0 warnings.

Reference: Notification.Wpf exposes a CloseOnClick request option:
https://github.com/G-Stas/Notification.Wpf
2026-11-11 10:19:20 -08:00

30 KiB
Raw Blame History

Services — index

External-facing logic: YouTube API, persistence. See schema.md for the memory-map conventions.

File Purpose
YouTubeAuthService.cs OAuth2 via Google: loopback callback (http://localhost:8765/oauth2/callback), token exchange, refresh, channel fetch. Constructor takes optional HttpClient + sessionChanged callback (test seam + save hook); session persists via Helpers/TokenStore (DPAPI); ClearSession() signs out (called by MainViewModel.StopStream on End Livestream)
YouTubeStreamService.cs Broadcast/stream management via the v3 API. CreateBroadcast always sends privacyStatus = "private" (ship step 7 — private-only until v1) and the one-click v3 flags (enableAutoStart/Stop, enableMonitorStream=false, latencyPreference=low); injectable HttpClient? http = null ctor seam so tests can fake the API. GetOrCreateReusableStreamAsync (TASK 9, shipped 2026-08-16) returns the channel's ReusableStream — lists liveStreams?mine=true and reuses the existing cdn.isReusable stream, inserting once only on first use (resolution=variable, frameRate=variable, isReusable=true); a stream id given to CreateBroadcast binds at insert via contentDetails.boundStreamId. RTMP URL = ingestionAddress + "/" + streamName. GetStreamHealthAsync(streamId) (TASK 9 item 3, shipped 2026-08-16) polls liveStreams?part=status → StreamHealth with parsed configurationIssues[] (NOTE 2026-09-22: the real API nests status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]} as an OBJECT — the parse reads the nested .status and tolerates the old flat-string shape; the object shape used to crash every poll)
YouTubeStreamService.cs (2026-10-09 additions) GetBroadcastPrivacyAsync(id) (liveBroadcasts.list part=status → privacyStatus) and DeleteFinishedBroadcastAsync(id) (videos.delete, fallback liveBroadcasts.delete on 404; never throws) — the private-broadcast keep-or-delete close-out
StreamHealthReporter.cs Pure report-by-exception decision (TASK 9 item 3): BannerFor(issues) → HealthIssueReport(Text?, IsError) — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color
YouTubeChatService.cs Polls liveChat/messages, raises MessageReceived; IDisposable. TASK 43 rewrite (2026-09-24): re-arms its one-shot poll on the server's pollingIntervalMillis (streamList semantics — reconnect on that cadence with nextPageToken, clamp 1000–6000ms, maxResults=2000); ParsePage (internal static seam) decodes all six event types into ChatMessage.Kind/fields (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat + milestone months best-effort regex); optional HttpClient ctor seam endures; InjectSimulatedMessage (TASK 41) preserved
ChatBoxRenderer.cs Chat overlay rasterizer (WPF FormattedText → BGRA VideoFrame), live rows + Super Chat/member styling
ChatOverlayLayer.cs The chat component (Commit G): owns the message buffer, ChatBoxRenderer, fade/mock timers, per-source preview renders, live RenderFrame; raster-on-change cache pattern (OBS text-source contract)
TextOverlayRenderer.cs TASK 3.17 static TextOverlay rasterizer/cache: WPF FormattedText runs on the dispatcher only when content/style/size changes; one straight-alpha BGRA VideoFrame drives both the XAML preview bitmap and compositor output, while pump reads are cache-only
AlertRenderer.cs The alert rasterizer (TASK 43, 2026-09-24): renders one event's celebration animation into a BGRA VideoFrame — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. Still the fallback when no alert clip is playable (TASK 47)
AlertOverlayLayer.cs The native alert-box component (TASK 43; video branch TASK 47): event queue (cap 10) + 33ms dispatch ticker + RenderFrame cache-first live path + UpdatePreview; Advance(double) is the deterministic test clock (tests never start the timer); ChatEventKind.None rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns IAlertClipDecoder per play — ctor seams (clipDecoderFactory, clipPathResolver, audioSink), fade in/out over FadeDurationSeconds = 0.30 (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain. The shared ticker renderer serves the AlertBox's active event and scene-local SourceType.Ticker layers' current+queued feed (transient; no history)
AlertClipDecoder.cs Per-play alert video decoder (TASK 47): IAlertClipDecoder (Start/Stop/Dispose + FrameAvailable/AudioReady/Completed) + ffmpeg child emitting rawvideo bgra (-vf scale=W:H, read via RawVideoFrameReader) and f32le -ar 48000 -ac 2 on a carry-buffer audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake
LayoutStore.cs SQLite persistence (Microsoft.Data.Sqlite) at %APPDATA%\ytLlive\ytLlive.db; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema user_version 10 (Source.ClipShape/IsMirrored via ALTER TABLE for pre-v2 DBs; v3 = singleton Webcam + per-scene WebcamSceneConfig, migrated idempotently without backfill — the stale Source.DeviceId column remains but is no longer read/written; v4 = WebcamSceneConfig.RectWidth/RectHeight, the pre-Round rect for the round-to-rect restore; v5 = Source.IsBackdrop + Source.CaptureKey, the live-capture backdrop; v6 = Scene.HasBackdrop — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = YppSnapshot history table; all later columns stay additive guarded ALTERs — TASK 51 adds WebcamSceneConfig.BackgroundEffect/BackgroundStrength; TASK 57 (the "vv" feature) adds the seven WebcamSceneConfig.Vertical* columns (VerticalX/VerticalY/VerticalWidth/VerticalHeight/VerticalClipShape/VerticalRectWidth/VerticalRectHeight — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the 16:9 layout: while the vertical tier is live the save writes the model's parked Landscape* values there and the live ones into the Vertical* columns); TASK 47 adds the five alert Source columns (AlertVideoAssetId/AlertVideoPath/AlertUseDefaultVideo/AlertShowTicker/AlertVideoVolume) without a version bump; TASK 3.17 adds nine additive Source text-layer columns (TextContent/font family/size/color/bold/italic/underline/horizontal alignment/vertical alignment), also without a version bump; MainViewModel.NormalizeBackgrounds re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned BackgroundUseDefault_*/BackgroundPath_* Settings keys purged at save). Settings key/value table holds app state: MicSourceName, ReusableStream (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), AlertDefaultVideoAssetId (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION)
VideoFrame.cs Normalized CPU frame seam (Width/Height/tightly-packed BGRA byte[]) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam
BackgroundEffects/IPersonSegmenter.cs TASK 51 seam: Segment(VideoFrame) → SegmentationMask? (SegmentationMask(Width, Height, float[] Values), 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes
BackgroundEffects/OnnxPersonSegmenter.cs TASK 51 default segmenter: MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever)
BackgroundEffects/WebcamEffectKey.cs TASK 51: (Effect, Strength) variant key, strength rounded to 0.01; None normalizes to strength 0 (the raw path)
BackgroundEffects/BackgroundEffectProcessor.cs TASK 51 pure pixel stage: Apply(frame, BackgroundEffect) — None = same instance, Remove = copy with alpha = feathered mask × source alpha (IsOpaque=false), Blur = background blurred at ¼ res (3 box passes) and mask-blended, person untouched; strength 0..1; blur radius is quadratic via BlurRadius(strength) — 1→6 small px (default 0.5 = 2, was a linear 1→12 ≈ 48 full px; the "blur heavy" fix 2026-10-09); TrySegment once + Render per effect; no mask/exception → passthrough + one log
BackgroundEffects/MaskSmoother.cs TASK 51 jitter fix (2026-10-09): temporally blends each raw mask with the previous smoothed one — CurrentWeight 0.75 × new + 0.25 × previous — so a per-frame segmentation verdict that wavers at an edge doesn't flutter the cut-out. Null input resets history; first frame/size change pass through; the returned mask never aliases the caller's array and the two buffers ping-pong (no per-frame LOH alloc: a 256×144 mask is 147 KB). State is per camera session, not in the segmenter (which is shared app-wide and contract-pinned to raw output). Weights from the OBS/Jitsi consensus — see docs/ai/capture.md → "Webcam background effects"
CameraDeviceInfo.cs (Id, DisplayName) for a physical capture device
ICameraEnumerator.cs GetCamerasAsync() — seam so the picker/CameraManager never touch WinRT (tests inject fakes)
ICameraFrameSource.cs StartAsync/StopAsync/FrameAvailable(VideoFrame) + SourceFailed(string) — seam for a running capture source (tests inject fakes)
MediaCaptureCameraEnumerator.cs WinRT enumeration via DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)
MicrophoneDeviceInfo.cs (Id, DisplayName) for an audio capture (mic) device
IMicrophoneEnumerator.cs GetMicrophonesAsync() — seam so the mic picker never touches WinRT (tests inject fakes)
WinRtMicrophoneEnumerator.cs WinRT mic enumeration via DeviceInformation.FindAllAsync(DeviceClass.AudioCapture) (no NAudio needed — capture libs stay deferred to the audio pipeline milestone)
MediaCaptureFrameSource.cs CPU-first MediaCapture source (MemoryPreference = Cpu, BGRA8 via CreateFrameReaderAsync). Validated, not trusted: post-init checks (VideoDeviceId match, FrameSources non-empty, VideoDeviceController.GetAvailableMediaStreamProperties ≥ 1); reader.StartAsync() status read (throws on non-Success); capture.Failed + CameraStreamStateChanged subscribed → SourceFailed event. Fallback ladder: VideoPreview → VideoRecord retry. SharingMode.Exclusive + DeviceLost not in 19041 SDK projection; CameraStreamState.Failed compared by (int)2 (projection omits member names)
CameraManager.cs Webcam capture ownership: refcounted by DeviceId, one shared WriteableBitmap per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); PreviewBitmapChanged/CameraFailed events. First-frame proof (4s timeout, configurable via ctor TimeSpan?): AcquireAsync returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces CameraFailed with suspect-app names (CameraConflictProbe) instead of a silent empty box. SourceFailed forwarded from the frame source as async death signal. TASK 51: the one background-effects processing point — SetRequestedEffects(device, keys) + per-variant GetLatestFrame/GetPreviewBitmap(device, key) + EffectPreviewBitmapChanged; a background worker segments once per raw frame (mask temporally smoothed per camera — MaskSmoother — see docs/ai/capture.md → "Webcam background effects") and renders every variant (latest-frame-drop); raw path untouched
IFullScreenDetector.cs Seam for the win32 full-screen detector: int? GetForegroundFullScreenMonitorIndex(), int PrimaryMonitorIndex(), IReadOnlyList<DisplayInfo> GetDisplays()
Win32FullScreenDetector.cs GetForegroundWindow + DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS) + MonitorFromWindow + GetMonitorInfo; monitor covers all four edges → full-screen; own process excluded; monitor order = EnumDisplayMonitors order (static GetMonitorHandle(int) maps index→HMONITOR for the capture factory). GetDisplays() returns DisplayInfo (index/name/resolution/bounds/IsPrimary, friendly name via EnumDisplayDevices) for the in-app "Capture Display" picker; PrimaryMonitorIndex() is the auto-key fallback when no full-screen game is detected
IScreenCaptureSource.cs Key + StartAsync/StopAsync/FrameAvailable(VideoFrame) — seam so ScreenCaptureManager never touches WinRT (tests inject fakes)
Direct3D11Helper.cs The one shared IDirect3DDevice per process: P/Invoke d3d11.dll!D3D11CreateDevice (hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QI IDXGIDevice → the WinRT interop export CreateDirect3D11DeviceFromDXGIDevice → MarshalInterface<IDirect3DDevice>.FromAbi. CsWinRT does not project Windows.Graphics.Direct3D11.Direct3D11Helper, so this hand-rolls it. Do not revert to QI-for-IDirect3DDxgiInterfaceAccess — the device no longer exposes it on newer Windows (E_NOINTERFACE on build 26200)
CaptureInterop.cs ComImport bridges the WinRT-projection gap: IGraphicsCaptureItemInterop (CreateForMonitor/CreateForWindow), IInitializeWithWindow (GraphicsCapturePicker owner window so the OS picker shows)
ScreenCaptureFrameSource.cs One Direct3D11CaptureFramePool (free-threaded, 2 buffers) + session per target; frames → SoftwareBitmap.CreateCopyFromSurfaceAsync (BGRA, alpha ignored) → VideoFrame, bytes read via WindowsRuntimeMarshal.TryGetDataUnsafe (CsWinRT-safe — the IMemoryBufferByteAccess ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). CreateForMonitor/CreateForWindow/CreateForPicker
ScreenCaptureManager.cs Screen-capture ownership mirroring CameraManager: refcounted by target key, one shared WriteableBitmap, dispatcher-coalesced latest-frame copies; PreviewBitmapChanged/CaptureFailed events; ReleaseAllAsync used on re-designation
ScreenCaptureSourceFactory.cs Resolve(key) parses monitor:<n> / window:<hwnd> / picker:<name> into a source; PickAsync() shows the OS GraphicsCapturePicker and returns the picker: key (transient — a reload falls back to auto-detection)
WebView2Manager.cs Web-source ownership (TASK 17, composition-capture since 2026-09-14): per registered Source { Type: WebSource } it owns a CoreWebView2CompositionController + its visual tree (RootVisualTarget child under a 1920×1080 root ContainerVisual on one Windows.UI.Composition.Compositor created on the UI thread via the CoreMessaging CreateDispatcherQueueController P/Invoke), a wrapped WebCaptureFrameSource, and the shared crop-sized WriteableBitmap preview (dispatcher-coalesced copy at Render priority, PreviewBitmapChanged raised only on recreation). Nav + TransparentBackgroundScript injection unchanged. Public ctor (Dispatcher); internal seam ctor (Dispatcher, Func<string, IScreenCaptureSource>?) for hermetic tests (no WinRT). Caller: MainViewModel.InitWebView2() (now invoked from MainWindow_Loaded — the parent HWND must exist). See ../ai.md "Slice 14"
WebCaptureFrameSource.cs The IScreenCaptureSource for a web session: owns the GraphicsCaptureItem (CreateFromVisual) + a free-threaded Direct3D11CaptureFramePool (2 buffers) + session; readback via SoftwareBitmap.CreateCopyFromSurfaceAsync (BGRA, BitmapAlphaMode.Straight — the compositor blends straight alpha); per-frame FindContentBounds alpha-bbox (internal static, unit-tested) stamps VideoFrame.CropBounds; ring up to 2 fresh frames + Epoch reuse; IsOpaque=false. Mirror of ScreenCaptureFrameSource
Compositor/SceneCompositor.cs The output compositor (TASK 4 ship step 1): renders a scene into the encoder's master VideoFrame (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (UniformToFill cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional socialBarFrame + socialBarTop in master space, blitted last so the bar sits above the flash; the old BlitFlash generalized to BlitOverlay with source offsets). TASK 47: Render + CompositeLayers take a trailing tickerFrame blitted at (0,0) after the bar — the alert ticker is a message-scrolling dynamic overlay, never baked. Pure and WPF-free: frames injected via a Func<SceneElement, VideoFrame?> resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by CompositorOptions (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see ai.md "Scene compositor". 2026-10-16 (measurement only): Render/CompositeLayers/BakeStaticBase take a trailing optional CompositeTimings? — every stage and every element timed (#<i> <name>, nested raster) and reported by the pump's parts line; zero cost for callers that pass nothing
Compositor/CompositeTimings.cs Per-element composite timing (2026-10-16, measurement only): the collector the pump owns for a stats window — Add(stage, ticks) accumulates, Describe(renders, minMs = 0.1) prints the averaged breakdown slowest-first. Stage names: backdrop/base/flash/social/ticker/zoom/scale/overlay, bake backdrop/bake overlays/bake #i name, and one entry per element #<z-index> <name> with the nested raster sub-stage (paste-cache rasterize miss) — the line that names WHICH of the six elements eats the render budget. See docs/ai/frame-pipeline.md → "Per-element composite timing + scene dump"
Compositor/CompositorOptions.cs The active tier's output rect (source space over the 1920×1080 master, integer-aligned — MainViewModel.OutputRectX can be 656.5) + target W×H
Compositor/StretchMath.cs Pure pixel math: the WPF UniformToFill cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor)
Compositor/StaticPixelCache.cs Asset bytes → cached BGRA8 VideoFrame, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's BitmapImage)
Encoder/IFfmpegLocator.cs TASK 4 ship step 2 seam: resolves an absolute path to ffmpeg.exe (PATH first → cached %APPDATA%\ytLlive\tools\ffmpeg.exe → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it
SocialValidator.cs ISocialValidator seam + HttpSocialValidator default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse @user@domain additionally does a best-effort nodeinfo lookup (/.well-known/nodeinfo → software.name) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution probes well-known subdomains (mastodon., social., ... order in FediverseSubdomainCandidates) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; ResolveFediverseSoftwareAsync(domain, ct) on the seam powers the load-time heal. Constructor takes optional HttpClient + Action<string> log for tests
Compositor/SocialBarRenderer.cs Social bar output strip (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 VideoFrame strip (1920 wide, 40px content + 24px glow pad) via RenderTargetBitmap — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green #2ecc71 glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the FramePump reads it from its own thread
Compositor/AlertTickerRenderer.cs Shared ticker renderer (TASK 47): marquee frame rendered per call by pure byte-math; the text pill is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha). Supports AlertBox active-event tickers and ordinary scene-local ticker sources, with per-source Ticker settings. Dynamic output — never baked (unlike the social bar)
Encoder/EncoderOptions.cs One go-live's encoder config: full RtmpUrl (ingest + stream key), raw input W×H from the compositor crop, final output W×H/FPS/bitrate from the quality tier, VideoEncoder (null = probe), audio sample rate/channels, GopSize = FPS×4 (the ≤4s keyframe bound)
Encoder/IFfmpegEncoder.cs TASK 4 ship step 3 seam: start/feed/stop the live encoder; HealthUpdated (parsed StreamHealth), ProcessFailed on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both)
Encoder/FfmpegEncoder.cs The default encoder: spawn ffmpeg.exe (probe -encoders first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse -stats lines into StreamHealth, graceful stop via stdin EOF + 10s kill watchdog. Constructed by MainViewModel since ship step 5 (new FfmpegEncoder(new FfmpegLocator())), driven by the FramePump
Encoder/IEncoderProcess.cs Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches Process directly
Encoder/FfmpegEncoderProcess.cs The real process wrapper (Process with all three std streams redirected)
Encoder/FfmpegArgs.cs Pure FFmpeg command-line builder: rawvideo pipe:0 input, anullsrc silent audio (WASAPI replaces it), H.264+AAC, closed GOP (-g fps×4 -keyint_min -sc_threshold 0 -bf 0, yuv420p), FLV → RTMP
Encoder/FfmpegProgressParser.cs Pure parser for frame=/fps=/size=/time=/bitrate= stats lines → FfmpegProgress
Encoder/FfmpegEncoderPicker.cs Pure H.264 encoder picker from -encoders output: NVENC → QSV → AMF → OpenH264; never returns libx264 (GPL)
Encoder/FramePump.cs TASK 4 ship step 5: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (Func<SceneElement, VideoFrame?> resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. StartAsync never throws (failures log + Failed); no RTMP URL = encoder skipped; StopAsync stops the encoder before awaiting the loop (backpressure deadlock); ProcessFailed self-stops. Since the bar bug-fix branch it takes an optional socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>? seam — a pre-rendered bar strip composited last (above the flash) at the top edge or SourceRectHeight − bar height. TASK 47 + scene ticker: the alertTicker: Func<Scene, VideoFrame?>? seam selects the active scene's AlertBox ticker, threaded through RenderScene/RenderFull + mixed into BuildFullRenderSignature; standalone tickers are resolved as per-element dynamic sources. See docs/ai/frame-pipeline.md → "Live frame pipeline". 2026-10-16: owns a CompositeTimings collector, passes it through RenderScene/RenderFull/GetBakedBase/CompositeLayers, logs FramePump parts (N renders/5s): … (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — FramePump scene dump: header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change
Encoder/FfmpegLocator.cs The default locator: PATH probe wins, then the cache, then pull+extract (ffmpeg.exe + the libav*.dll family via a staging dir, failures via AppLog). Pinned to BtbN autobuild-2026-08-09-13-03 lgpl-shared (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see ai.md → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5
INotificationService.cs Toast seam (TASK 24): Show(title, message, AppNotificationSeverity) + AppNotificationSeverity { Info, Success, Warning, Error } + Info/Success/Warning/Error extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread
NotificationService.cs The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library NotificationManager; routes to the in-window NotificationArea named ToastArea (position configurable in App Settings; bottom-right default); Info/Success ~4s, Warning ~8s by default, Error sticky; the persisted App Settings switch makes all severities sticky and body-click dismissible. Dark card tints match the health-banner language; all Show calls marshal to the captured Dispatcher. See ../ai.md "Toast notifications"
Audio/IAudioSource.cs TASK 4 ship step 4 seam: live capture source — Start/Stop/Started/SampleReady(AudioSample)/Failed(Exception), IDisposable. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes
Audio/AudioSample.cs One captured chunk: interleaved PCM float (-1..1) + sample rate + channels
Audio/WasapiLoopbackAudioSource.cs Desktop/game capture: NAudio WasapiLoopbackCapture on the default render device — automatic at unity; the game bar's meter consumes it
Audio/WasapiMicAudioSource.cs Mic capture: NAudio WasapiCapture with the device resolved by FriendlyName matching MicSourceName (DisplayName only), default capture endpoint fallback; name provider Func<string?> re-read at each Start so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick)
Audio/AudioMixer.cs Owns both sources; capture starts once at startup (MainViewModel.StartMicCaptureAsync) and stops on Shutdown — NOT go-live (preview monitoring). Mic samples → AudioLevelMeter → MicLevelChanged; loopback samples → the game bar's meter via LoopbackLevelChanged. Surfaces mic connection state: MicConnected/MicFailed (drives the status dot) + RestartMic() (device swap mid-session, keeps loopback). Failures log via AppLog; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter Push is unconditional (the ?. on the event would otherwise skip the argument when nothing is subscribed). TASK 47 — alert audio: EnqueueAlertAudio writes a dedicated 8s stereo-48k ring (AlertBufferSeconds), drained in FillAndMix pre-limiter and added at unity (never ducked); StartLive clears the ring
Audio/AudioLevelMeter.cs Pure smoothed RMS level (0..1): Push(AudioSample) + Reset — the unit-tested math behind AudioLevel and GameAudioLevel. ToDisplay(float) maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1 — honest since 2026-10-09: the old +10 dB display boost made a −30 dBFS voice look dialed in; leveled speech (−20 dBFS) now sits at 2/3 of the bar) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise)
Audio/WaveToFloat.cs Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, WaveFormatExtensible IEEE-float subformat GUID, trailing partial samples ignored
Audio/MasterLimiter.cs Master output limiter: a −1 dBFS ceiling (Ceiling = 0.891) applied after the live mix so the honest gains can never sum past 0 dBFS and clip the AAC encode. Instant attack per frame (a hot frame is scaled exactly to the ceiling — no overshoot), smoothed release toward unity so loud passages don't pump. Pure — unit-tested

Related: constructed in ViewModels/MainViewModel.cs (no DI container yet). Models in Models/index.md. OAuth credentials live in Helpers/OAuthCredentials.cs.