ai.md 1,308 → 228 lines. No prose rewritten: every block was moved as-is so nothing could be lost in translation, then replaced by a pointer. New files: - docs/ai/environment.md — LAN/SSH + Gitea push recipe, Windows dotnet build/run, test-suite map, hermetic real-MainWindow test rule - docs/ai/product.md — brand/naming, AI transparency, GPL licensing prohibitions, free-forever monetization, auth/account assumptions, packaging & distribution - docs/ai/youtube-api.md — go-live ordering, the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, chat insert shape - docs/ai/overlays.md — social bar, native alert box (animations, clip, ticker), chat box conventions - docs/ai/ui-surface.md — control surface, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, brand logo, launch geometry, theme/properties conventions ai.md keeps the response/derivation rules, the architecture table, the four existing invariant stubs, and one pointer per moved subsystem. docs/ai/index.md routes every file with a "read when" column. References falsified by the move were fixed in the same commit: AGENTS.md build note → environment.md → Run, and the "Related" footers in frame-pipeline.md / output.md / capture.md now point at the owning files. Docs-only unit: scope-check run, no build (no source touched).
28 KiB
Output — the ffmpeg bundle, the encoder, recording, and audio
Split out of
ai.md(2026-10-26) so a session reads only what its task touches;ai.mdkeeps a short invariant stub for this file. Directory map:index.md.
Everything that leaves the app: locating and shipping ffmpeg.exe, how the encoder subprocess is
driven (video stdin + audio named pipe), local recording as a first-class mode, and the WASAPI capture
/ filter / mix chain that feeds it. Content is the decisions plus the failures that produced them;
superseded designs are marked inline.
The encoder
FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10; rewritten to BUNDLED 2026-09-28, plan in TASKS.md)
The encoder's one external dependency is ffmpeg.exe. It is BUNDLED — never downloaded, never
fetched at runtime. IFfmpegLocator resolves an absolute path on demand: the bundle first
(<AppContext.BaseDirectory>\ffmpeg\ffmpeg.exe), then PATH as the escape hatch for someone who
deliberately installed their own. The order is the opposite of the original ("the user's own install
wins") on purpose: a PATH ffmpeg may be a different major version or a GPL build, and the
bundle is the one that was pinned, license-vetted and tested. If neither hits, it throws a loud
IOException naming the missing folder and the fetch script — the old "found something on disk"
cache path is gone deliberately, because silently satisfying a missing bundle with a stale copy is
exactly the silent drift a certification-gated dependency must not have.
The bundle is not in git (135 MB of incompressible DLLs would tax every commit on this
9p-bridged tree). scripts/fetch-ffmpeg.sh populates ./ffmpeg from a pinned month-end BtbN
LGPL-shared win64 artifact, verifies SHA-256 before extracting, and is idempotent; set
FFMPEG_ZIP=<path> to reuse a pre-downloaded archive. ytLive.csproj copies ffmpeg\** to the
output (CopyToPublishDirectory="Always" for the MSIX package) and the GuardBundledFfmpeg target
fails the build if the folder is absent, so a fresh clone finds out before the creator presses
Record rather than during a take. ffmpeg.exe + ffprobe.exe + the libav*.dll family must stay in
one folder — Windows resolves a shared build's DLLs from the executable's own directory.
ffplay.exe (18 MB) is deliberately not shipped; nothing calls it.
BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
source offer" with no static-relink (§6) material — see the Licensing guardrails below. libopenh264
is compiled into avcodec-63.dll (verified 2026-09-28: the encoder is registered there, and there
is no separate OpenH264 binary to ship), so Cisco's BSD terms + patent grant ride along inside item 1
of THIRD-PARTY-NOTICES.txt. Pin: autobuild-2026-08-31-13-27 (month-end on purpose — BtbN keeps
~14 dailies but month-end builds ~2 years; a daily pin is what 404'd, see below).
⛔ Why this was rewritten (2026-09-27 → shipped 2026-09-28). The pinned-download design was a defect, not a cold-start edge case. It failed in production on 2026-09-01 — the previous daily pin aged out of BtbN's 14-day retention and the download 404'd on the creator's first real recording attempt. Two facts made "the pin is a const, just bump it" wrong:
- Microsoft Store policy 10.2.2 forbids dynamic code inclusion. Downloading an executable from the internet and running it is the exact pattern the policy names. An MSIX submission is rejected over it.
- Bumping the pin is whack-a-mole against a retention window we do not control.
Bundling deletes the startup network dependency entirely. The LGPL-shared build choice is
unaffected — dynamic linking was chosen for LGPL §6 compliance ("license text + source offer"),
not to enable downloading. Analysis: TASKS/research-store-certification.md §4.
Guardrail: FfmpegLocatorTests.TheLocatorCarriesNoNetworkCapability asserts the locator's
source contains no HttpClient/URL/downloader at all, so the policy cannot rot back.
Every other third-party binary already ships via NuGet — ffmpeg was the only download-and-execute.
Verified 2026-09-28: e_sqlite3.dll and WebView2Loader.dll land in runtimes\win-x64\native\
(SQLitePCLRaw + WebView2 packages) and NAudio/Notification.Wpf/Microsoft.Data.Sqlite/ProtectedData
are managed. Two gaps remain and are not fixed by bundling: the WebView2 Evergreen runtime is
a machine prerequisite (a manifest framework-dependency + graceful-failure path, TASK 48 item 2), and
Velopack's updater downloads and executes — its teardown is a compliance action, not just cleanup.
Live encoder + RTMP push (TASK 4 ship step 3 — shipped 2026-08-12, plan in TASKS.md)
The encoder is a thin orchestrator over ffmpeg.exe — no H.264/AAC code in the app. It spawns the
subprocess (path from IFfmpegLocator), feeds raw BGRA master frames into stdin, and parses -stats
stderr lines into StreamHealth (bitrate/FPS/duration, dropped-from-frame-count). FfmpegEncoder
(IFfmpegEncoder seam) holds: StartAsync (locate → probe -encoders → spawn → stderr loop →
drain loop), SubmitFrameAsync (slice 10: bounded-queue ENQUEUE — never a pipe write; the drain task
owns stdin writes), StopAsync (flush queue → stdin EOF → ffmpeg
finalizes + exits by itself; a 10s watchdog kills it), Dispose (force-kill + wait), and the
HealthUpdated/ProcessFailed events. Pattern: the encoder never touches Process — it drives the
IEncoderProcess seam (FfmpegEncoderProcess wraps the real Process, redirected stdin/stdout/stderr
- exit control); a
Func<IEncoderProcess>factory + the locator are constructor-injected, so the integration test fakes the whole subprocess (probe + encoder) with a Channel-backedTextReaderwhoseComplete()is EOF (null), never aChannelClosedException.
Decisions (locked): args are pure (FfmpegArgs.Build, no string building in the encoder):
-f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0 (NO -re — the FramePump
is the pacer since slice 9, 2026-09-10; -re added a second, fighting clock on the rawvideo demux)
- a real audio input — the
mixer writes IEEE-float stereo to a Windows named pipe (
-f f32le -ar 48000 -ac 2 -i \\.\pipe\ytllive_audio, name viaEncoderOptions.AudioPipeName; replaced the old-f lavfi -i anullsrcsilence in the TASK 8 audio milestone) + explicit-map 0:v -map 1:a+-c:v <enc> -b:v K -maxrate K -bufsize 2K+-g fps×4 -keyint_min fps×4 -sc_threshold 0 -bf 0 -pix_fmt yuv420p(≤4s keyframes, closed GOP, H.264 compliance) +-c:a aac -ar 48000 -ac 2 -f flv <rtmpUrl>. Record output (TASK 18):FfmpegArgs.Buildemits one self-contained block per output, each its own-map 0:v -map 1:a+ codec tags (AddVideoTagshelper). Stream block =-f flv <rtmpUrl>; record block =-f mp4 <RecordPath>.EncoderOptions.StreamEnabled/RecordEnabled/RecordPathgate each block, so the engine runs record-only (no RTMP) or stream-only — stream+record simultaneously is OUT by creator ruling (2026-09-01): the VOD is already the copy, and dual-encoding drags mid-range chassis and degrades BOTH outputs ("we're not them"). The old "cheap on NVENC" claim was an unverified assumption; the UI constraint is landed (2026-09-20): the REC/ON-AIR pills are radio-exclusive — arming one clears the other — while the dual-block machinery stays generic.FfmpegEncoder.StartAsyncthrows unless at least one output is enabled. Encoder choice is probed from the binary's-encoderslisting (FfmpegEncoderPicker, pure): NVENC → QSV → AMF → OpenH264 fallback, never libx264 (GPL; see Licensing).EncoderOptions.VideoEncoderforces one and skips the probe.EncoderOptionsalso carries W×H/FPS/bitrate from the quality tier andGopSize= FPS×4. Constructed byMainViewModelsince ship step 5 (new FfmpegEncoder(new FfmpegLocator())), driven by theFramePumpbelow.
Recording
Local recording (TASK 18 — shipped 2026-08-29, VM + top-bar UX; plan in TASKS.md)
Recording is independent from streaming and needs no YouTube sign-in. A single segmented
REC|ON-AIR switch in the top bar picks the mode (redesigned 2026-09-22 from the old two pills):
REC (local file, works signed-out) / ON-AIR (streaming; armable signed-out — intent may
light while offline, 2026-09-20: a greyed pill was the dead end the guard shipped to forbid). The switch
selection is intent; IsRecording/IsLive are reality — the reality line (dot + word + elapsed) only
lights when a session is actually running.
- State model (
MainViewModel): pillsRecordPillOn/OnAirPillOnstay the routing model (radio-exclusive — arming one clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays armable signed-out (2026-09-20: intent must be able to light while offline)). Top bar redesign (2026-09-22): the bar is ONE surface rendered by the FIRST decision — Record or Stream — and each world is the whole bar (ShowIdleWorld/ShowRecordAction/ShowStreamActions, wired byRaiseTopBarModes()). The old sign-lights + two pill toggles + status dot + PRIVATE/TEST chips are gone; the mode is one segmented REC|ON-AIR switch (SegmentToggle/SegmentLabelstyles), and "going back" is one tap on the other segment. Idle worlds: Record = switch + "Start Recording" (PrimaryStartButtonLabelnames the armed world); Stream = switch- "Go Live" + Test (Test is a child of Stream — visible only stream-armed AND signed-in) plus the
account zone on the right. The account zone is WORLD-INDEPENDENT (2026-09-23):
ShowSignInButton= signed-out andShowAvatarButton= the signed-in avatar, in Record and Stream worlds alike — it's app-level identity, and keeping it put pins the bar height (it no longer pops in and out between worlds). Right-click the avatar = Change Account/Logout. Running retires the switch and worlds; one reality line● word elapsed+ End replace them (RunningVisible/RunningText/RunningDotBrush— TEST/LIVE/REC, gold/red/green). The account status dot is gone — the avatar/Sign In pair carries connectedness.ShowPrimaryStartButtonremains the always-idle face.StopStreamclears both pills (see "Stop ends everything; failures roll back" below).
- "Go Live" + Test (Test is a child of Stream — visible only stream-armed AND signed-in) plus the
account zone on the right. The account zone is WORLD-INDEPENDENT (2026-09-23):
- Filenames (pure
Services/RecordingFile): auto-namety-<yyyyMMdd>-<HHmm start>-<fff>-0000.mp4at start; rename-on-stop toty-…-<hh2mm2 actual length>.mp4(FinalizeRecordingAsyncafter the pump stops & the file closes), numeric-2/-3suffix on collision (UniquePath). Length comes from_liveElapsed, which the session timer walks whileIsLive || IsRecording. The<fff>millisecond segment is load-bearing, not decoration (2026-09-27): both names derive from_recordStartTime, so it survives the rename, and without it two instances started in the same minute compute the same start path and both ffmpeg processes write one file — which is exactly the dev multi-instance case (Helpers/InstanceProfile, one instance recording the other). - Private broadcasts end the same way (creator ruling 2026-10-09). After
StopStream's transition(complete),OfferToKeepPrivateBroadcastAsyncreads the broadcast's privacy from YouTube (GetBroadcastPrivacyAsync— visibility can change mid-stream, local state isn't trusted) and, only if it isprivate, showsKeepBroadcastDialog(repo root): Enter = Keep (IsDefault), Escape / Delete / the X = delete viaDeleteFinishedBroadcastAsync—videos.delete?id=<broadcastId>(a live broadcast's id IS its video id; 204), falling back toliveBroadcasts.deleteon 404. An unreadable status asks nothing and deletes nothing; a failed delete toasts that the video is still on YouTube. Decision logic =MainViewModel.ClosePrivateBroadcastAsync(internal static, tested inPrivateBroadcastCloseOutTests);KeepBroadcastPromptOverrideis the dialog seam. - Save dialog: Cancel DISCARDS (creator ruling 2026-09-26). The
RenameRecordingDialogresult is a two-outcome decision, and it lives in the extractedMainViewModel.CompleteRecordingSave(startPath, dir, autoStem, chosenStem, creatorSaved)(internal, soytLive.Testscan drive it against real files without a frame pump — seeytLive.Tests/RecordingSaveDialogTests.cs):creatorSaved == false(Cancel / Escape / the X) → DELETE the temp file. Nothing is moved, nothing is left in the videos folder. A failed delete returnsDiscardFailedand the toast NAMES the file + folder, because a "discarded" recording still on disk is worse than no report.creatorSaved == true(Save, or Enter on the pre-filled default) →File.Moveto the final name. A blank/whitespace box still means "keep the auto name" — but only on an explicit Save.- The bug this fixed: the caller used to
File.MoveUNCONDITIONALLY and only overwritestemwhen the dialog returned true, soShowDialog() == falsefell straight through into the save — Cancel silently kept the recording under the default name. The modal itself was always correct; the side effect was in the wrong place. Any modal whose result gates a side effect must have every branch handled explicitly — a falsy result must never fall through into the effect.
- Folder: default
%APPDATA%\ytLlive\recordings\, user-overridable viaChooseRecordFolderCommand(OpenFolderDialog), persisted throughLayoutStore.Load/SaveRecordFolder(RecordFolderkey). - Explicit sign-out only:
StopStreamno longer clears the session/token. Sign out via Logout only — Change Account re-authenticates in place and deliberately does not sign out first (2026-09-27; signing out first deletedytLlive.authbefore the browser even opened, so a cancelled switch lost a working session). Stopping a recording leaves the creator signed in. - Stop ends everything; failures roll back (2026-09-01):
StopStreamalso clears both intent pills (RecordPillOn/OnAirPillOn) — a lit pill with no session behind it is a lie. Every frame-pump death and every go-live prep failure now runs the fullStopStreamteardown instead of leaving a limbo (StreamStatus.ErrorwithIsRecording=trueover a dead encoder — the first-launch zombie recording). Toast copy distinguishes "Recording stopped" from "stream pipeline stopped";AudioMixer.StopLiveis idempotent-safe for rollbacks that never reached StartLive. Seams:OnFramePumpFailed+IsRecordingsetter made internal (test-only, InternalsVisibleTo). Test:SessionTeardownTests. EncoderOptions.StreamEnabled/RecordEnabled/RecordPathgate outputs; record+stream is one ffmpeg with two output blocks (the dual-block machinery is unreachable from the UI since the pills are radio-exclusive). Rest of the engine (FramePump/encoder) is unchanged —BuildEncoderOptionsalways returns an options (flags from the pills +_activeRecordPath), so record-only reaches the pump.
Audio
Live audio capture (TASK 4 ship step 4 — shipped 2026-08-12; game audio bar 2026-08-13; TASK 8 audio milestone: real stream audio + filters + duck + TRAX, shipped 2026-08-14, plan in TASKS.md)
Capture runs for the app's lifetime and is KISS by rule: desktop/game audio is automatic (WASAPI
loopback), the mic is the creator's only audio control (meter/mute/volume already shipped). The whole
layer sits behind an IAudioSource seam (Services/Audio/: Start/Stop/Started/SampleReady/
Failed, IDisposable) so the app never touches NAudio directly and tests inject hermetic fakes (no
devices, no timers).
- Sources (NAudio 2.2.1 —
NAudio.Wasapi+NAudio.WinMM(forWaveOutEvent), MIT — item 9 inTHIRD-PARTY-NOTICES.txt):WasapiLoopbackAudioSource=WasapiLoopbackCaptureon the default render device;WasapiMicAudioSource=WasapiCapturewith the device resolved byFriendlyNamematchingMicSourceName(the app only persists the DisplayName), falling back to the default capture endpoint. Both run at the device's own mix format — NAudio 2.2.1 exposes no overridableGetDefaultMixFormatonWasapiCapture, so there is no forced 48 kHz path; the mixer'sTinyResamplernormalizes any rate/channel layout to 48 kHz stereo (this replaced the earlier "force IEEE-float 48k" plan — the resampler IS the design). Mic device resolution re-reads the name providerFunc<string?>at eachStart, so a mic picked mid-session takes effect immediately (the mixer restarts the mic on pick). Both sources raiseStartedonce their capture loop actually begins — the mixer turns that intoMicConnected. AudioMixerowns both sources;StartMicCaptureAsyncstarts the mixer once at startup andShutdowndisposes it — NOT go-live — so the meters preview live. Mic samples: downmix to mono →TinyResampler→VoiceFilterChainper sample (bassLowShelfFilter120 Hz +4 dB → trebleHighShelfFilter8 kHz +3 dB →NoiseGate(threshold 0.005, hysteresis 0.5, attack 0.5, release 0.0005) →SpeechLeveler(2026-10-09: automatic gain steering speech RMS to −20 dBFS over a 0.3 s window, gain −6..+20 dB, rises ≤10 dB/s / falls ≤30 dB/s, learns only while the gate is open and above −54 dBFS so pauses/hum are never pumped) →Compressor(threshold 0.5, ratio 4:1); all pure DSP) → ring buffer → the post-filterAudioLevelMeter(RMS, 0.2 exponential smoothing) →MicLevelChanged→ marshalled to the UI thread →AudioLevel. Loopback samples: resample → ring buffer → second meter →LoopbackLevelChanged→ the game bar'sGameAudioLevel. The raw linear level maps to the meter's display scale byAudioLevelMeter.ToDisplay(−60..0 dBFS → 0..1, honest — the +10 dB display boost was removed 2026-10-09; the mic meter isToDisplay(AudioLevel × MicVolume), true dB math, no ×1.2). Why (2026-10-09): a creator's YouTube upload measured −28.5 LUFS / −8.9 dBFS peak against YouTube's −14 LUFS reference (YouTube only turns loud videos down, never quiet ones up) while the boosted meter looked "dialed in". Nothing in the chain added gain — the compressor folds only above −6 dBFS and the limiter only caps. Re-running that same audio through the new chain (mic volume 0.8): −17.9 LUFS, −0.7 dBFS peak. Recording and streaming share this one mix, so both were (and now are) the same. Measure with the bundled ffmpeg:ffmpeg -i file -af ebur128=peak=true:framelog=quiet -f null -. Mic connection state surfaces as events:MicConnected(sourceStarted),MicFailed(sourceFailed), andRestartMic()re-resolves + restarts just the mic (loopback keeps running). Failures log viaAppLog; a mic failure zeroes the meter, a loopback failure never kills the mic. MeterPushis unconditional (a?.on the event would skip the meter update when nothing is subscribed yet).- Go-live audio (TASK 8):
BeginGoLive→StartLive(pipeName)(idempotent) creates the named pipe server (NamedPipeAudioWriter, behind theIAudioPipeWriterseam) and a live loop task; everymixInterval(default 10 ms)FillAndMixreads a chunk from each ring buffer (silence-fills underruns), computes the post-filter mic RMS →AutoDucker(threshold 0.02, duck ×0.25 / −12 dB, attack 0.05, release 0.005, always on), applies the honest gains viaFunc<double>seams (micGain = MicMuted ? 0 : MicVolume;loopbackGain = GameMuted ? 0 : GameAudioVolume, × duck), mixes mono mic + stereo loopback into interleaved stereo, runs theMasterLimiter(a −1 dBFS ceiling so the honest gains can never sum past 0 dBFS and clip the AAC encode — instant attack per frame, smoothed release toward unity), and writes the floats to the pipe. The gains are wired throughAudioGainProvider(Services/Audio/AudioGainProvider.cs) — the VM hands itsMicGain/LoopbackGainFuncs to the mixer at construction, read live each tick, so the volume sliders and mute buttons are stream-honest. (Before this wiring the seams defaulted to unity and the controls were decorative.)StopStream→StopLive()(closes the pipe → ffmpeg audio EOF) BEFORE stopping the frame pump (video EOF) — the reverse order stalls on pipe backpressure.FfmpegEncoderitself is untouched; the VM owns the pipe lifecycle. - Audio sync offset (TASK 22): after the limiter,
FillAndMixroutes the whole interleaved stereo mix through aAudioSyncDelay(Services/Audio/AudioSyncDelay.cs) — a pure delay line whose offset comes from aFunc<int> syncOffsetMsseam (the VM's globalAudioSyncOffsetMs, −500..+500 ms, persisted asAudio.SyncOffsetMs). This is OBS's documented fix for lip-sync: positive delays audio when it runs ahead of video; negative advances audio when it runs behind by eating the first|N|ms of the live stream head (seeAudioMixer.StartLive; the delay line stays clamped 0..500 internally — negative bypasses it entirely). The advance is armed once atStartLive(eating the head mid-stream is impossible); positive is live-reactive (re-read per tick). A slider on the mic bar ("AUDIO SYNC", locked while live/recording viaIsEditMode) surfaces it (a one-time status dot next to it was removed 2026-09-23 per creator feedback — the slider + numeric tooltip carry the state). Changing the delay flushes the line (a live change clicks rather than smears). - TRAX — free background music (TASK 8):
MusicPlayer= NAudioMediaFoundationReader(mp3/wav/m4a) →VolumeWaveProvider16at the hardcoded 0.20 bed (no slider) →WaveOutEventon the default device, looping on any clean natural end (PlaybackStoppedwithe.Exception == null→ rewind + replay; the earlierPosition >= Lengthcheck was unreliable forMediaFoundationReaderand let tracks play once then stop). The desktop/game bar's mute togglesLocalGain(0 or 1;UpdateTraxLocalGainruns on everyGameMutedchange and on TRAX load), so muting kills TRAX in the headphones — matching the stream, where it rides the loopback channel. The volume slider drives system volume (PushSystemVolume) — which scales the HEADPHONES; the loopback capture is pre-endpoint-volume (disproven-then-fixed 2026-09-01), so the STREAM follows the knob viaAudioGainProvider.LoopbackGain = GameMuted ? 0 : GameAudioVolume, and the game meter multiplies its display by the same. One knob, three honest paths. No third mixer input. The footer TRAX button lives inside the game audio bar (in the preview overlay), left of the "Desktop Audio" label (the creator's pick — it rides the desktop channel, so the music control sits with the desktop audio controls): status dot (red no track / yellow loaded stopped / green playing) + "TRAX"; left-click toggles play/pause (opens the in-app picker viaOpenFileDialogwhen no track is loaded); right-click always opens the picker (TraxButton_PreviewMouseRightButtonUp,e.Handled = true, code-behind pattern); tooltip shows the loaded/playing track name or "No track — right-click to choose background music". The picked track persists via schema v9 single-rowMusic(TrackPath/IsEnabled) inLayoutStore. Known wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on live, off VOD" is tabled.- ⛔ The load is behind a bounded probe, because a share that is gone must never
freeze startup (2026-10-04).
LoadLayout→LoadTraxused to callMusicPlayer.Loadsynchronously on the UI thread, andnew MediaFoundationReader(path)does not fail fast on a dead SMB session — it burns the whole ARP/TCP/SMB timeout (~40s, measured) and then throws0x80070035. Measured:OnStartup08:42:59 →MainWindow loaded08:43:43, with 42 of those 44s insideLoadLayouton that one track. The rule now: a probe may time out; only a bind is allowed to be on the creator's path.IsTrackReachableAsync(path, budget, File.Exists)runs the probe on a thread-pool thread under aTask.WhenAnyceiling (TraxProbeBudget= 2s) and reports offline/missing/slow as a normal state, not an error. Only a probe that passed gets the real bind, and that bind stays on the UI thread with every otherMusicPlayermutation, so no new cross-thread race is introduced. The orphan probe thread stays parked until SMB gives up — deliberate: it is not the thread anyone waits on. Same principle covers the webcam: a device held by another app fails fast (sharing violation) but a virtual-camera driver can stall enumeration, so device probes belong off the startup path too, not behind a long timeout.
- ⛔ The load is behind a bounded probe, because a share that is gone must never
freeze startup (2026-10-04).
- Mic status dot (
Models/MicStatus.cs) on the preview-bottom MIC button: green =MicConnected, yellow =MicFailed(in use/unplugged), red = no mic device at startup (the mixer is never started, so loopback and the game bar can't run either — no capture devices at all). The picked mic persists:LayoutStore'sSettingskey/value table storesMicSourceName(saved on pick viaSaveMicSourceName), and the VM restores it at construction BEFORE the mixer's firstStart— so a restart reconnects the same already-vetted device (green) or reports it missing (yellow), instead of silently falling back to the default endpoint. - Game audio bar (desktop/game, always visible — TASK 4's
IGameAudioDetectorshow/hide gating was removed 2026-08-15: the bar used to appear only while a full-screen game with sound was up, which kept hiding the creator's desktop meter; the detector stack is gone, desktop audio is just automatic WASAPI loopback): overlaid at the bottom of the preview window (bottom-center, dark translucent chip, a mirror of the mic bar: meter + mute + volume slider). It's monitoring UI in the preview grid — the meter is display-only (fill =ToDisplay(GameAudioLevel) × GameAudioVolume— the ×volume term was missing until 2026-09-01: loopback capture does NOT shrink with the endpoint, so an unscaled meter left it pegged at 20% volume;GameMeterHonestyTestsguards it), but its volume slider drives system volume (PushSystemVolume— live headphones follow the knob; the capture side is scaled in the mix, see TRAX note above) and mute kills TRAX (MusicPlayer.LocalGain= 0). Relabelled "Desktop Audio" (TASK 8) since TRAX rides the same channel. WaveToFloat(pure, shared): WASAPI mix formats → interleaved float — IEEE float 32-bit direct, PCM 16-bit normalized to -1..1,WaveFormatExtensiblewith the IEEE-float subformat GUID (NAudio.Dmo.AudioMediaSubtypes.MEDIASUBTYPE_IEEE_FLOAT), trailing partial samples ignored.- Build 0 warnings; 197 passing (DSP/ring-buffer/ducker/resampler/pipe + mixer/hysteresis/
game-detector/meter-scale unit tests + the TASK 8 integration test
AudioPipelineTests.Mix_WithFiltersDuckAndGain_Lands_On_AudioPipe+ the polish-batch integration testMix_HonorsProviderGains_AndGameMute_KillsTheLoopbackproving the provider gains reach the pipe and mute silences the loopback).
Related files
frame-pipeline.md— theFramePumpthat feeds this encoder (pacing, backpressure, teardown order).ui-surface.md— resolution tiers (W×H/FPS/bitrate) and toast notifications.youtube-api.md— go-live/prep ordering and the broadcast lifecycle.product.md— licensing guardrails (the ffmpeg pin, LGPL obligations).TASKS/research-store-certification.md— §4, the ffmpeg bundling analysis.