Files
LlamaCasty/HANDOFF.md
T
2026-08-16 18:16:10 -07:00

298 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HANDOFF — session state
> Current operational state, read right after `TASKS.md`. Trust this file as the
> truth of what is in flight — do not re-derive from git/fs unless it points at
> a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session
> end, compaction, or any interruption.
## Session state (last updated: 2026-08-16, renumbering pass)
- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 9 reusable-stream slice** is
committed + pushed (details below). Local branches `social-bar`/`webcam-validation` untouched (no secrets).
- **TASK 9 — REUSABLE STREAM + HEALTH BANNER — SHIPPED 2026-08-16 (items 1–3).**
- **Items 1–2 (reusable stream):** `_rtmpUrlProvider` now yields a real RTMP URL.
`YouTubeStreamService` gains `GetOrCreateReusableStreamAsync` (lists `liveStreams?mine=true`,
reuses the existing `cdn.isReusable` stream, inserts once on first use with
`resolution=variable`/`frameRate=variable`); `CreateBroadcast(..., streamId)` binds at insert via
`boundStreamId` + the one-click v3 flags (`enableMonitorStream=false`, `latencyPreference=low`).
Cached via `LayoutStore` Settings (`SaveReusableStream`/`LoadReusableStream`). **Go-live order
changed** (the pump reads the URL once at start — `FramePump.StartAsync`): `BeginGoLive` →
`PrepareAndStartLiveAsync` = ensure stream → create+bind broadcast → THEN start the pump.
- **Item 3 (report-by-exception health):** `GetStreamHealthAsync(streamId)` polls
`liveStreams?part=status`; the pure `Services/StreamHealthReporter.BannerFor` decides (null text on
good/ok/noData/info-only; warning/error issue → its type text, error beats warning). The VM polls
every **30s while live** (`_healthPollTimer`, first poll right after go-live, stopped on End/Error
via `UpdateLiveVisuals`; failures log-only). UI = full-width banner strip under the top bar,
`HealthIssueBanner` + `HealthIssueBackground` (amber `#b8860b` warning / dark red `#8f1f1f` error),
hidden by `NotNullToVis`; cleared in `ResetHealth`.
- **Tests (8 new this branch, 207 total, 0 warnings):** 3 service units (parse, good→no issues,
no-session→null) + 4 `StreamHealthReporterTests` + the ONE integration test
`GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error` (real service JSON parse
→ real reporter: good → no banner, error issue → banner text + error color).
- **CONFIDENTIAL files created (gitignored):** `MONETIZATION.md` (pricing, billing research, unlock
mechanics) and `MARCOM.md` (launch marketing strategy, positioning, platform strategy). These are
NOT committed to the public repo. `Helpers/OAuthCredentials.cs` was already gitignored.
- **TASK 9 items 6-7 scoped (not built):** visibility unlock (remove temporary "always Private" enforcement)
and full broadcast form (Core + Advanced tabs with all YouTube API-supported fields). These are the
next technical tasks after live chat (item 4) and error handling (item 5).
- **TASK 12 trimmed** to technical scope only (billing decision, unlock mechanism, bug report, alerts gating).
Business details moved to `MONETIZATION.md`.
- **TASK 15 added** — marcom/launch kit. Business details moved to `MARCOM.md`. Queued after all v1 features.
- **Backlog updated** — removed v0.3 (stream scheduling, not needed for casual streamers). Multi-destination
restreaming noted as "congrats, you're ready for OBS" moment.
- **Resume point (next branch):** **TASK 9 item 4 — live chat** (`liveChat/messages` poll, right-panel
render, Super Chat + membership badges). Then item 5 (error handling), item 6 (visibility unlock),
item 7 (full broadcast form), then TASK 12 (billing + unlock + support).
- **TASK 14 — MASTER LIMITER — COMMITTED + PUSHED 2026-08-15.** Queued from the TRAX discussion: the
live mix summed mic + loopback with no ceiling, so hot gains could pass 0 dBFS and clip the AAC
encode. New pure `Services/Audio/MasterLimiter.cs` (−1 dBFS ceiling, instant attack per frame,
smoothed release) applied at the end of `AudioMixer.FillAndMix`. ONE integration test
(`MasterLimiter_CapsTheLiveMix_OnThePipe`). The review also confirmed file size needs no guard
(`MediaFoundationReader` streams) and the music **0.20 cap is already relative by construction**
(music rides the same loopback gain as the game → always exactly 20% of the desktop volume). Build
**0 warnings**, full suite green.
- **GAME AUDIO BAR ALWAYS VISIBLE — COMMITTED + PUSHED 2026-08-15.** The TASK 4 show/hide gating was a
UX bug: the desktop/game meter kept vanishing whenever no full-screen game with sound was up (or no
TRAX music played). The whole `IGameAudioDetector`/`GameAudioDetector`/`GameAudioHysteresis` stack +
the VM's 250ms poll timer + its two test files were **deleted**; the bar is now permanently overlaid
at the bottom of the preview. Build **0 warnings**, full suite green.
- **TASK 13 — POST-PAUSE POLISH BATCH — SHIPPED + COMMITTED + PUSHED 2026-08-15.** All 8 creator
review issues fixed in one branch (details below). Build **0 warnings**, full suite **197 passing**
(ONE integration test for that branch: `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`).
- **AUDIO MILESTONE (TASK 8) — SHIPPED + COMMITTED + PUSHED.** Real stream audio + voice filters +
auto-duck + free TRAX background music. Commits `6d71ace` (milestone) + `f2f6401` (secrets cleanup),
force-pushed (`725f5a7...f2f6401`).
- **MONETIZATION LOCKED (2026-08-14, creator) — committed `86fcd86`.** One paid line = **annual
subscription**: early access **$49.99/yr** → **$99/yr list at GA**, **grandfather-while-subscribed**,
lapse → list on renewal. Free tier unchanged (branding flash = the billboard + no Alerts; Alerts is
the paid feature). **Billing NOT itch-locked**; candidates Gumroad (native affiliates =
tiebreaker) / Lemon Squeezy. Support = in-app bug-report → git issues. Full policy in `ai.md` →
Monetization; scoped plan in `TASKS.md` **TASK 12**.
- **Secrets scrubbed from git history.** The DO token + passwords were purged via `git filter-branch`
+ `git gc --prune=now --aggressive` (all-refs scan = 0 hits); values still exist in chat — keep
treating as **compromised**; rotate the DO API token.
- **Shipped, all pushed:** TASK 9 items 1–3 (reusable stream + health banner, 2026-08-16), TASK 14
master limiter, TASK 13 polish batch, TASK 11 (creator-hub About, `3d92bd0`), TASK 7 (meter +10 dB),
TASK 4 ship step 7 (one-click go-live + private-only), TASK 8 audio milestone (`6d71ace`), secrets
cleanup (`f2f6401`), monetization docs (`86fcd86`).
## TASK 9 — reusable stream + health banner 2026-08-16 (what changed, items 1–3)
The recorded resume point: the "last blocker" was that go-live ran the visual flow but never pushed —
`_rtmpUrlProvider` returned null, so `FramePump.StartAsync` skipped the encoder entirely.
- **`Services/YouTubeStreamService.cs`:** new `GetOrCreateReusableStreamAsync()` lists
`liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once per channel
only on first use (`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`) and
returning a `ReusableStream(Id, IngestionAddress, StreamName)` record (`RtmpUrl` =
`ingestionAddress/streamName`). `CreateBroadcast` takes an optional `streamId` and binds at insert
via `contentDetails.boundStreamId` (no second bind round-trip) and now always sends the full
one-click v3 flag set (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`).
The old per-broadcast `BindStream` (throwaway 1080p/60fps stream + `contentDetails.streamId`) is
**gone**.
- **`Services/LayoutStore.cs`:** `SaveReusableStream`/`LoadReusableStream` — the Settings key/value
table caches the stream id/address/name so the pump has its RTMP URL at startup, no round-trip.
- **`ViewModels/MainViewModel.cs`:** `_rtmpUrlProvider` returns `_reusableStreamUrl` (loaded from the
cache in the ctor, set fresh on go-live). `BeginGoLive` → `PrepareAndStartLiveAsync`: ensure the
stream → cache it → create the broadcast bound to it → **then** `_framePump.StartAsync()`. The
ordering matters because the pump reads `_encoderOptions()` once at startup.
- **Tests (6 new, 199 total, 0 warnings):** `GetOrCreateReusableStreamAsync_Reuses_Existing_Reusable_Stream`
(list path, no POST), `..._Creates_When_None_Exists` (insert path with variable/isReusable),
`CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert` (boundStreamId + new v3 flags),
`..._Without_Session_Returns_Null`, `ReusableStream_Cache_RoundTrips`, and the ONE integration test
`FramePumpTests.ReusableStream_Url_From_Service_Feeds_Encoder_Startup` (real service + real pump +
hermetic HTTP: the reusable stream's URL lands in `EncoderOptions.RtmpUrl` and the encoder starts).
**Out of scope this branch (next branches):** TASK 9 item 4 live chat, item 5 the YouTube error-code
mappings, and the design's bottom-strip YouTube logo/green-red dot (clickable → dialog).
### Item 3 — report-by-exception health banner (same session, second branch-worth of scope)
- **`Services/YouTubeStreamService.cs`:** `GetStreamHealthAsync(streamId)` polls
`liveStreams?part=status&id={id}` → `StreamHealth` with parsed `healthStatus` (`good|ok|bad|noData`)
+ `configurationIssues[]` (severity `info|warning|error` + type). The old `GetStreamHealth(broadcastId)`
(wrong endpoint — `liveBroadcasts.lifeCycleStatus` — zero callers) is **gone**.
- **`Services/StreamHealthReporter.cs` (new, pure):** `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` —
null text on good/ok/noData/info-only; the first warning/error issue produces its type text
(comma-joined, blank types dropped); error beats warning for color.
- **`ViewModels/MainViewModel.cs`:** `_reusableStream` (the record, not just the URL) is stashed by
`PrepareAndStartLiveAsync` + loaded from the cache in the ctor; a 30s `DispatcherTimer`
(`_healthPollTimer`) polls while live — first poll fires right after the pump starts, the tick
handler fire-and-forgets `PollHealthAsync()` (fully try/caught, failures log-only), and
`UpdateLiveVisuals`' offline/error branch stops the timer. `ApplyHealthIssue` sets
`HealthIssueBanner` + `HealthIssueBackground` from the reporter; `ResetHealth` clears both.
- **`MainWindow.xaml`:** a full-width banner strip in its own window-grid row (below the top bar,
above the content) bound via `NotNullToVis` to `HealthIssueBanner`, background to
`HealthIssueBackground` (amber `#b8860b` warning / dark red `#8f1f1f` error); rows shifted
(content → row 2, footer → row 3).
- **Tests (8 new this branch, 207 total, 0 warnings):** 3 service units + 4 `StreamHealthReporterTests`
+ the ONE integration test `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error`
(real service JSON parse → real reporter: good → no banner, error issue → banner text + error color).
## TASK 13 — the 8-issue polish batch — SHIPPED 2026-08-15 (what changed)
The creator's review of the TASK 8 build, all fixed in one branch (full record in `TASKS.md` TASK 13).
1. ✅ **Desktop/game audio volume slider had no effect** — the `AudioMixer` gain seams defaulted to
unity (the VM never passed them): the slider/mute were decorative, stream AND local. Fixed with a
new **`AudioGainProvider`** (`Services/Audio/`) wired into the mixer at construction, read live
each mix tick.
2. ✅ **Desktop/game mute had no effect** — same wiring; `GameMuted` now zeroes the loopback on the
stream AND silences the music locally via new `MusicPlayer.LocalGain` (scaled by the game bar on
every volume/mute change + on TRAX load) — the creator hears the control work in the headphones.
3. ✅ **TRAX played once then stopped** — `OnPlaybackStopped` only looped when `Position >= Length`
(unreliable for `MediaFoundationReader`); now any clean stop rewinds + replays. Dropped the unused
`using System.Runtime.InteropServices;` too.
4. ✅ **TRAX/MIC buttons swapped** — footer mic cluster is now MIC + meter + mute + volume (TRAX is out
of the mic cluster entirely).
5. ✅ **Mic source persists across restarts** — `LayoutStore` gained a `Settings` key/value table
(`SaveMicSourceName`/`LoadMicSourceName`); the VM saves on pick and restores before the mixer's
first `Start` → same already-vetted device reconnects green on restart, missing → yellow.
6. ✅ **TRAX moved right of Socials** — both centered under the scenes/sources listboxes (footer line
1, left cluster).
7. ✅ **Backdrop icons shifted right** — new `HiddenBoolToVisibilityConverter` keeps the trash column
reserved (`Hidden`, not `Collapsed`) so edit/eye stay in fixed columns for every source row.
8. ✅ **No separation between scenes and sources** — a 1px hairline with top/bottom padding now sits
between the two left-panel listboxes.
**THE ONE integration test:** `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — real mixer +
`AudioGainProvider` gains through the pipe harness: scaled loopback audible at 0.5, silence after mute.
## Game audio bar — always visible 2026-08-15 (what changed)
The creator reported the desktop/game sound meter "lost" — it was the TASK 4 show/hide gating
(`_gameAudioBarActive || IsMusicPlaying`): the bar only rendered while a full-screen game produced
sound or TRAX played, so it sat hidden during normal use. Fix = the bar is now **always visible**:
- **Deleted** `Services/IGameAudioDetector.cs`, `Services/GameAudioDetector.cs`,
`Services/GameAudioHysteresis.cs`, `ytLive.Tests/GameAudioDetectorTests.cs`,
`ytLive.Tests/GameAudioHysteresisTests.cs` — the stack's only output (`_gameAudioBarActive`) fed
`IsGameAudioBarVisible`, which no longer exists.
- **MainViewModel.cs:** removed `_gameAudioTimer` (250ms `DispatcherTimer`), `_gameAudioDetector`,
`_gameAudioBarActive`, the constructor wiring, `OnGameAudioActiveChanged`, `OnGameAudioPollTick`,
`IsGameAudioBarVisible`, and the `IsMusicPlaying` → visibility notification. Desktop audio is just
automatic WASAPI loopback now.
- **MainWindow.xaml:** dropped the `Visibility` binding on the preview-overlay game bar; it renders
unconditionally (TRAX button + "Desktop Audio" label + meter + mute + volume unchanged).
## TASK 14 — master limiter 2026-08-15 (what changed)
Came out of the TRAX discussion (file-size guard? 20% cap? sound-event balance?). Verdict: two of the
three instincts were already satisfied, one real gap existed:
- **No file-size guard needed** — `MediaFoundationReader` streams from disk; memory is flat (~a few MB)
whatever the file size.
- **The 0.20 music cap is relative by construction** — music rides the same loopback gain as the game,
so music:game is always exactly 0.20:1 at any slider position; it cannot rise above 20% of the
current desktop volume. (Per-channel hierarchy: voice on top via the ducker −12 dB, then game, then
music at 20%.)
- **The gap:** `FillAndMix` summed mic + loopback with no ceiling — hot gains could pass 0 dBFS and
clip the AAC encode.
- **Fix:** new pure `Services/Audio/MasterLimiter.cs` — **−1 dBFS ceiling** (`Ceiling = 0.891`),
**instant attack per frame** (hot frames scaled exactly to the ceiling, no overshoot), **smoothed
release** toward unity (no pumping); gain never exceeds 1. Applied at the end of `AudioMixer.FillAndMix`.
- **ONE integration test:** `MasterLimiter_CapsTheLiveMix_OnThePipe` — real mixer + pipe harness, a
0.95 loopback bed capped to exactly 0.891 on the wire while staying audible. Plus 3 unit tests for
the pure math.
## TASK 8 audio milestone — SHIPPED 2026-08-14 (what changed)
The creator's feature review settled this as the single next branch ("all the audio issues done and
tested — a huge milestone"). Final spec and full shipped-state records live in `TASKS.md` (TASK 8)
and `ai.md` ("Live audio capture"). Highlights:
- **Real audio into the encoder.** `FfmpegArgs` now builds `-f f32le -ar 48000 -ac 2 -i \\.\pipe\ytllive_audio`
+ explicit `-map 0:v -map 1:a` (replaces `anullsrc` silence). `MainViewModel.BeginGoLive` →
`_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName)`; `StopStream` → `_audioMixer.StopLive()`
before `_framePump.StopAsync()`.
- **2-input mix (mic + loopback)**, honest gains: `micGain = MicVolume` (mute = 0),
`loopbackGain = GameMuted ? 0 : GameAudioVolume` × duck. No third music channel.
- **Voice chain on the mic** (TASK 10), before meter AND mix: bass 120 Hz +4 dB → treble 8 kHz +3 dB →
gate (0.005 / hysteresis 0.5) → compressor (0.5, 4:1). Pure TDF2 DSP, per-sample, always on.
- **Auto-duck:** mic RMS > 0.02 → loopback ×0.25 (−12 dB), attack 0.05 / release 0.005.
- **TRAX (free BGM):** `MusicPlayer` = MediaFoundationReader → `VolumeWaveProvider16` at fixed **0.20** →
`WaveOutEvent`. Plays to the default device → rides the loopback into the stream (ducked with game).
Footer TRAX button (dot red/yellow/green + "TRAX"), left-click toggles/picks, right-click opens the
picker (`OpenFileDialog`), tooltip shows the track name. Track persists via **schema v9** single-row
`Music`. Sound-bar label "Game Audio Capture" → **"Desktop Audio"**;
`IsGameAudioBarVisible = gameDetectorProducingSound || IsMusicPlaying`.
- **Tests:** new `AudioPipelineTests.cs` (DSP/ring-buffer/ducker/resampler units + the ONE integration
test reading real pipe bytes via `NamedPipeClientStream`); `FfmpegEncoderTests` + layout persistence
updated for pipe args / Music roundtrip. Ring-buffer overwrite bug found by the unit test and fixed
(head must NOT advance on eviction — the write itself advances it). Integration test pre-fills the
ring buffers before `StartLive` so the first pipe tick already carries audio (deterministic — a
start-of-stream silence race was seen and eliminated).
**Out of scope this branch:** IP webcam, chat box, alt-key crop, credits, bg removal, music-off-VOD
track (YouTube mutes VODs with copyrighted music — future feature), `PremiumUrl` (TASK 12 seam).
- **Landmines:**
- Never add another test that constructs `new App()` — use `RealAppHost.Run(...)` (shared STA host
for the one WPF App per AppDomain; round-clip + source-naming tests).
- Never set a local `Canvas.SetTop` on the social bar — a local value permanently
overrides `{Binding SocialBarTop}` (the `ClearValue` lesson from 5.5).
- `AudioMixer` meter `Push` is unconditional **by design now**: `OnMicSample`/
`OnLoopbackSample` compute the level first, then raise the event — a
`?.Invoke(meter.Push(...))` short-circuit skipped the meter update when
nothing was subscribed (found by `RestartMic_ResetsLevel`, fixed).
- `MicConnected` comes from the source `Started` event, raised right after
`StartRecording()` succeeds — tests must `MarkStarted()` the fake source
before asserting connection state.
- The mic dot is red until a resource connects (see contract below) — green
only after `Started`, yellow on `Failed`.
- Zero mic devices at startup = red dot AND the mixer is never started, so
loopback + the game bar can't run either (no capture at all) — acceptable.
- The pump reads the active scene on a background thread while the UI can still
edit it — a concurrent-mutation exception is contained (logged + `Failed` +
the pump stops), not a crash.
- `StopAsync` must stop the encoder (closes stdin) **before** awaiting the pump
loop — closing stdin unblocks a write stuck on pipe backpressure; the reverse
order deadlocks. Same rule for audio: `StopLive()` (pipe EOF) before the pump
stop, so ffmpeg's two inputs end in order.
- Tests never instantiate `MainViewModel` directly except via a real `MainWindow`
on the `RealAppHost` STA thread (round-clip + naming), which never go live.
- Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests
only, never the real instance.
- **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); 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
devices at startup → stays red and the mixer is never started; capture failure → yellow.
- **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`);
layout DB `%APPDATA%\ytLlive\ytLlive.db` (**schema v9** — single-row `Music`; the
`SocialEntry.Software` column is a column-presence migration like the others); OAuth callback
`http://localhost:8765/oauth2/callback`; crash log `%APPDATA%\ytLlive\startup.log`.
## Server recovery (llamachile.tube / YunoHost / DO) — 2026-08-14
**Facts (hunted this session — do not re-hunt):**
- Droplet **llamachile.tube**: DO droplet ID `473190301`, IP `143.244.176.131`, sfo3, 4GB/2vCPU/50GB.
**SSH port = 2214** (matches git remote `ssh://llgit.llamachile.tube:2214/gramps/ytLlive.git`);
22/2222/2200/etc all closed. SSH as `gramps` works with `~/.ssh/id_ed25519` (key auth, no password).
- **Root is YunoHost-locked**: `PermitRootLogin no` in `/etc/ssh/sshd_config` (there's a conflicting
cloud-init override section below it, but DO's "Reset root password" email does NOT work on this
box — cloud-init `set-passwords` only ran once at install). Root is reachable via `sudo -i` or the
DO web Recovery Console (Settings → Recovery console — VNC-based; the droplet page's Console button
is SSH-based and fails with "all configured authentication methods failed" when no account has a
working password).
- **gramps is a YunoHost LDAP account** (local `/etc/passwd` entry has `*`, auth via pam_ldap) — its
password is changed with `sudo yunohost user update gramps -p '<pw>'`, NOT `passwd`.
- **fail2ban bans the whole IP for 10-12 min** after repeated failed SSH/root logins (all ports
refuse; `ping` still works; droplet API still shows `active`). Wait it out — do NOT power-cycle.
- Password reset this session: `sudo yunohost tools rootpw -n '<pw>'` sets root (rootpw takes `-n`).
Verification: `getent shadow root` shows a `$y$` yescrypt hash + `passwd -S root` = `P`.
- Mastodon services run as systemd units `mastodon-web/sidekiq/streaming` (all were `active` after
the reboot); gitea/nginx/mariadb/postgres/redis/yunohost-api also systemd units. Disk was **93% full**
(3.6G free) — keep an eye on it before any big upgrade.
- The original outage was an interrupted Mastodon upgrade + a DO `password_reset` reboot; everything
came back on its own after boot. No code/schema damage observed.
**Secrets (removed 2026-08-14 — see git history if a value is ever needed again):** the DO API token,
the gramps/root passwords and the DO password-reset email password were recorded in this file and in
chat. They are treated as **compromised** — the DO API token and every listed password have been or
should be rotated/revoked, and **no new secrets belong in this repo or in chat**. Secret paths that
stay here are the file locations only (OAuth creds → `Helpers/OAuthCredentials.cs`, session token →
`Helpers/TokenStore.cs`).