commit beac84ae23a033aa6d8759bf19945f3d29bedbd4 Author: Meta Vens Date: Mon May 18 10:56:01 2026 +0100 Scaffold local OBS streaming bridge diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..368f737 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +config/localcast.json +logs/ +*.log +*.tmp +__pycache__/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8671d0a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md + +## Purpose + +LocalCastBridge is the local LAN streaming rig for making another Windows PC available inside OBS as video plus separately mixable audio sources, with video encoded by NVENC on the sender. + +Keep this repo blunt. The goal is not to fork OBS for sport. Use existing OBS and FFmpeg surfaces until a real invariant is impossible without native code. + +## Persistence Scaffolding + +- Treat `state/map.yaml` as the canonical current map for this repo. +- Treat `state/scratch.md` as disposable scratch for one bounded subgoal. +- Treat `state/evidence.jsonl` as the distilled durable ledger of decisions, verifications, rejected paths, and scars that change future belief. +- Treat `state/branches.json` as the lightweight branch/hypothesis ledger. +- Treat `notes/fresh-workspace-handoff.md` as the short re-entry packet. +- Treat `notes/current-system-map.md` as the current source-grounded architecture and data-flow map. +- Treat `docs/implementation-plan.md` as the forward plan for what is implemented, temporary, and next. +- Update `state/map.yaml` when project understanding changes. Add evidence only when the lesson changes what the next agent should believe. +- Keep exact branch and HEAD snapshots out of handoff prose. Git commands own volatile truth. + +## Persistence Commands + +Use a real Python if the Windows Store alias gets cute. In this Codex workstation, the bundled runtime is: +`C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe`. + +```powershell +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py add-evidence --type decision --status ok --note "..." +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_prepare_compaction.py +``` + +## Session Bootstrap And Re-entry Protocol + +On a fresh session, do this before editing: + +1. read: + - `state/map.yaml` + - `notes/fresh-workspace-handoff.md` + - `notes/current-system-map.md` + - `docs/implementation-plan.md` +2. run: + - `& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status` +3. restate the current mechanism, intended change, and next bounded move before broad edits + +After compaction, resume, or suspicious continuity loss: + +1. rerun `& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status` +2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md` +3. treat the persisted next action as orientation, not permission to wander + +When the user says to prepare for imminent compaction: + +1. run `tools/localcast_prepare_compaction.py` before editing persistence surfaces +2. use its warnings as the checklist for map, handoff, scratch, evidence, and git hygiene +3. update only the state that actually changed +4. run `tools/localcast_prepare_compaction.py` again after edits +5. commit the completed persistence pass unless the work is deliberately mid-surgery + +## Operating Discipline + +- Before substantial edits, restate the current mechanism and intended change. +- Prefer one clear hypothesis per iteration. +- Verify against OBS ingest behavior and actual sender-side FFmpeg capability, not vibes wearing a lab coat. +- Do not add an OBS plugin, fork, registry, custom transport daemon, or compatibility layer until the script-based SRT design fails a concrete invariant. +- Keep video, audio-source isolation, transport, and OBS composition as separate responsibilities. +- If the diff grows while understanding shrinks, stop implementation and update the map. + +## Product Truths + +- V1 is a LAN bridge, not an internet relay. +- Sender-side video encoding must use NVENC when available. +- Receiver-side OBS should see stable source URLs that can be added as Media Sources. +- Audio must remain separately mixable in OBS. That means separate audio endpoints in v1, even if a later synchronized bundle becomes useful. +- The repo owns repeatable setup, scripts, config, and operating docs. FFmpeg and OBS own media work. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0c816f --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# LocalCastBridge + +LocalCastBridge turns a Windows gaming/desktop PC into LAN sources for another machine's OBS scene. + +V1 does not fork OBS. OBS already accepts SRT Media Sources, and FFmpeg can capture Windows desktop/audio while encoding video with `h264_nvenc`. The sane first machine is: + +```mermaid +flowchart LR + A["Husband's PC"] --> B["FFmpeg capture scripts"] + B --> C["NVENC H.264 video over SRT"] + B --> D["Opus audio source 1 over SRT"] + B --> E["Opus audio source 2 over SRT"] + C --> F["OBS Media Source: video"] + D --> G["OBS Media Source: audio source 1"] + E --> H["OBS Media Source: audio source 2"] +``` + +The video stream is encoded on the sender. Audio sources are separate endpoints so OBS can mix, mute, filter, and monitor them independently. + +## Current Status + +This repo is the first coherent scaffold: architecture docs, persistence machinery, and Windows scripts for device discovery, config validation, and FFmpeg command generation. It is ready for a real two-PC smoke test once FFmpeg with SRT/NVENC support is installed on the sender. + +## Quick Start + +1. Install OBS on the receiver. +2. Install FFmpeg on the sender, with `srt` protocol and `h264_nvenc` encoder enabled. +3. Copy `config/localcast.example.json` to `config/localcast.json`. +4. On the sender, list capture devices: + +```powershell +.\scripts\sender-discover.ps1 +``` + +5. Edit `config/localcast.json` with the receiver IP and chosen DirectShow audio device names. +6. Dry-run the generated FFmpeg commands: + +```powershell +.\scripts\sender-start.ps1 -Config .\config\localcast.json -DryRun +``` + +7. Start the sender: + +```powershell +.\scripts\sender-start.ps1 -Config .\config\localcast.json +``` + +8. In OBS on the receiver, add one Media Source per URL from `docs/obs-receiver-setup.md`. + +## Why SRT Media Sources First + +OBS documents SRT playback through VLC Source or Media Source, and Media Source can listen on `srt://0.0.0.0:PORT?mode=listener&timeout=5000000` with `mpegts` input format. FFmpeg also documents SRT protocol support and Windows capture devices through `dshow`/`gdigrab`. That gives us a standard, inspectable path before native OBS code starts making expensive promises. + +## Repo State + +Rehydrate with the bundled Python from this Codex workstation, or any normal Python 3: + +```powershell +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status +Get-Content .\state\map.yaml +Get-Content .\notes\fresh-workspace-handoff.md +Get-Content .\notes\current-system-map.md +``` diff --git a/config/localcast.example.json b/config/localcast.example.json new file mode 100644 index 0000000..0951b14 --- /dev/null +++ b/config/localcast.example.json @@ -0,0 +1,37 @@ +{ + "receiver": { + "host": "192.168.1.50", + "basePort": 5100, + "srtLatencyMicros": 120000, + "passphrase": "" + }, + "video": { + "enabled": true, + "source": "desktop", + "width": 1920, + "height": 1080, + "framerate": 60, + "bitrate": "12000k", + "maxrate": "12000k", + "bufsize": "24000k", + "preset": "p5", + "tune": "ll", + "portOffset": 0 + }, + "audioSources": [ + { + "name": "desktop", + "device": "CABLE Output (VB-Audio Virtual Cable)", + "portOffset": 1, + "codec": "aac", + "bitrate": "160k" + }, + { + "name": "voice", + "device": "Microphone (USB Audio Device)", + "portOffset": 2, + "codec": "aac", + "bitrate": "128k" + } + ] +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5a0fad8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,48 @@ +# Architecture + +## Objective + +Make one local Windows computer available as OBS sources on another computer, with sender-side NVENC video encoding and independently mixable audio sources. + +## Current Mechanism + +The repo owns configuration and scripts. FFmpeg owns capture, encoding, and SRT transport. OBS owns scene composition. + +Inputs become outputs like this: + +1. `config/localcast.json` declares the receiver IP, SRT ports, video capture shape, and named audio devices. +2. `scripts/sender-discover.ps1` asks FFmpeg what Windows capture devices and encoders exist. +3. `scripts/sender-start.ps1` validates the config and launches one FFmpeg process per source. +4. The video process captures the desktop and encodes H.264 using `h264_nvenc`. +5. Each audio process captures one DirectShow audio device and encodes Opus. +6. Each process sends MPEG-TS over SRT to a stable receiver URL. +7. OBS adds each URL as a Media Source. + +## Invariants + +- Video encoding happens on the sender. +- OBS receives ordinary media-source URLs; it does not need a plugin for v1. +- Audio remains separately mixable in OBS. +- Ports are stable and derived from one base port plus explicit offsets. +- Secrets, if used, stay in local config and are not committed. + +## Intended Change + +The first implementation creates the repeatable operating spine. Future native code is only justified if the SRT Media Source path fails one of the invariants above. + +## Cut Line + +The following do not currently earn their keep: + +- OBS fork +- OBS plugin +- custom receiver daemon +- synchronized multi-track container that hides audio sources from OBS mixing +- homegrown capture stack over Windows APIs + +## Source Notes + +- OBS SRT guide: `https://obsproject.com/kb/srt-protocol-streaming-guide` +- FFmpeg devices docs: `https://www.ffmpeg.org/ffmpeg-devices.html` +- FFmpeg protocols docs: `https://ffmpeg.org/ffmpeg-protocols.html` + diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..3b3a24f --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,26 @@ +# Implementation Plan + +## Implemented + +- Repo-local persistence machinery. +- First architecture map. +- Example config for one video source plus two audio sources. +- Sender device discovery script. +- Sender launch script with dry-run mode and per-source FFmpeg commands. +- OBS receiver setup notes. + +## Temporary + +- Audio and video are separate SRT endpoints. This preserves OBS mixing authority but may need latency tuning. +- Audio defaults to AAC inside MPEG-TS for compatibility; test Opus later only if there is a concrete reason. +- Desktop capture uses `gdigrab` first because it is broadly available. `ddagrab` is a candidate once the installed FFmpeg build is confirmed. +- The scripts assume Windows sender and OBS receiver on the same LAN. + +## Next + +1. Run `scripts/sender-discover.ps1` on the sender and record the actual device names. +2. Smoke-test one video stream into OBS. +3. Smoke-test each audio source independently. +4. Tune SRT latency and FFmpeg buffering for the local network. +5. Decide whether a small OBS scene/source generator is worth adding. +6. Reconsider plugin/fork only if standard OBS Media Source cannot preserve the required behavior. diff --git a/docs/obs-receiver-setup.md b/docs/obs-receiver-setup.md new file mode 100644 index 0000000..001c9ef --- /dev/null +++ b/docs/obs-receiver-setup.md @@ -0,0 +1,42 @@ +# OBS Receiver Setup + +Add each stream as a separate OBS Media Source. + +For every source: + +1. Add `Sources > + > Media Source`. +2. Uncheck `Local File`. +3. Set `Input` to the relevant SRT URL. +4. Set `Input Format` to `mpegts`. +5. Enable `Restart playback when source becomes active`. + +## Default URLs + +These match `config/localcast.example.json` when the receiver is listening locally: + +```text +video: srt://0.0.0.0:5100?mode=listener&latency=120000&timeout=5000000 +desktop: srt://0.0.0.0:5101?mode=listener&latency=120000&timeout=5000000 +voice: srt://0.0.0.0:5102?mode=listener&latency=120000&timeout=5000000 +``` + +If using a passphrase, append: + +```text +&passphrase=YOUR_SECRET&pbkeylen=16 +``` + +OBS should be open before the sender starts if OBS is the SRT listener. If a source gets wedged, deactivate and reactivate the Media Source before changing ports; port churn is a symptom, not a configuration strategy. + +## Source Layout + +Recommended OBS scene: + +```text +Husband PC - Video Media Source, monitor off +Husband PC - Desktop Media Source, audio only, monitor as needed +Husband PC - Voice Media Source, audio only, monitor as needed +``` + +Mute the video source if it carries no audio. Keep audio filters on the separate audio sources. + diff --git a/notes/current-system-map.md b/notes/current-system-map.md new file mode 100644 index 0000000..af4ad82 --- /dev/null +++ b/notes/current-system-map.md @@ -0,0 +1,36 @@ +# Current System Map + +LocalCastBridge is intentionally thin. + +```mermaid +flowchart TD + A["config/localcast.json"] --> B["sender-start.ps1"] + C["FFmpeg on sender"] --> D["Windows desktop capture"] + C --> E["DirectShow audio capture"] + D --> F["h264_nvenc encode"] + E --> G["AAC encode"] + F --> H["SRT MPEG-TS video endpoint"] + G --> I["SRT MPEG-TS audio endpoint(s)"] + H --> J["OBS Media Source"] + I --> K["OBS Media Source per audio source"] + J --> L["OBS scene composition"] + K --> L +``` + +## Ownership + +- Config owns source identity, receiver address, ports, and encoding knobs. +- FFmpeg owns capture, encoding, and network transport. +- OBS owns source activation, layout, filters, monitoring, and recording/streaming. +- This repo owns repeatability and memory. + +## Why Not Plugin First + +An OBS plugin would be justified if OBS could not ingest stable LAN streams, or if a plugin were needed to expose independent audio controls. V1 gets independent audio controls by making each source an OBS Media Source. That is boring. Boring is allowed to win when it is correct. + +## Known Risks + +- Windows audio source names vary by driver and localization. +- Some FFmpeg builds omit SRT or NVENC. +- Separate endpoints can drift; local latency should be tuned before adding a synchronization layer. +- OBS SRT reconnection behavior can be fussy. Use stable ports and source reactivation before treating port changes as a fix. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md new file mode 100644 index 0000000..faa0c4d --- /dev/null +++ b/notes/fresh-workspace-handoff.md @@ -0,0 +1,37 @@ +# Fresh Workspace Handoff + +This is the short re-entry packet for `E:\Projects\LocalCastBridge`. + +Do not trust this file for the exact live HEAD. Exact branch, HEAD, and dirty state are volatile. Always ask git. + +## Rehydrate + +```powershell +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\tools\localcast_state.py status +Get-Content .\state\map.yaml +Get-Content .\notes\fresh-workspace-handoff.md +Get-Content .\notes\current-system-map.md +Get-Content .\docs\implementation-plan.md +git status --short --branch +git log --oneline -5 +Get-Content .\state\evidence.jsonl -Tail 8 +``` + +## Current Shape + +- V1 uses FFmpeg plus OBS Media Source over SRT. +- Sender-side video encoding is `h264_nvenc`. +- Audio sources are separate SRT endpoints so OBS can mix them separately. +- The repo contains scripts and docs, not a native OBS plugin. + +## Current Pressure + +The useful next work is real hardware validation: + +- confirm the sender FFmpeg build has `srt` and `h264_nvenc` +- capture actual audio device names +- tune latency once streams are live in OBS + +## Immediate Re-entry Instruction + +Do not continue implementation automatically from a rehydrate-only request. Rehydrate, then follow the user's next instruction. diff --git a/scripts/sender-discover.ps1 b/scripts/sender-discover.ps1 new file mode 100644 index 0000000..5d9cd46 --- /dev/null +++ b/scripts/sender-discover.ps1 @@ -0,0 +1,33 @@ +param( + [string]$FfmpegPath = "ffmpeg" +) + +$ErrorActionPreference = "Stop" + +function Invoke-Checked { + param( + [string]$Label, + [string[]]$Arguments + ) + + Write-Host "" + Write-Host "== $Label ==" + & $FfmpegPath @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "$Label exited with code $LASTEXITCODE" + } +} + +Write-Host "FFmpeg binary: $FfmpegPath" + +Invoke-Checked -Label "Version" -Arguments @("-hide_banner", "-version") +Invoke-Checked -Label "SRT protocol check" -Arguments @("-hide_banner", "-protocols") +Invoke-Checked -Label "NVENC encoder check" -Arguments @("-hide_banner", "-encoders") +Invoke-Checked -Label "DirectShow devices" -Arguments @("-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy") + +Write-Host "" +Write-Host "Look for:" +Write-Host "- protocol list contains srt" +Write-Host "- encoder list contains h264_nvenc" +Write-Host "- audio device names you can copy into config/localcast.json" + diff --git a/scripts/sender-start.ps1 b/scripts/sender-start.ps1 new file mode 100644 index 0000000..491c275 --- /dev/null +++ b/scripts/sender-start.ps1 @@ -0,0 +1,132 @@ +param( + [string]$Config = ".\config\localcast.json", + [string]$FfmpegPath = "ffmpeg", + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" + +function Resolve-ConfigPath { + param([string]$Path) + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + return $resolved.Path +} + +function Assert-Value { + param( + [object]$Value, + [string]$Message + ) + if ($null -eq $Value -or "$Value".Trim().Length -eq 0) { + throw $Message + } +} + +function Get-SrtUrl { + param( + [object]$Receiver, + [int]$PortOffset + ) + + $port = [int]$Receiver.basePort + $PortOffset + $query = "mode=caller&latency=$($Receiver.srtLatencyMicros)" + if ($Receiver.passphrase -and "$($Receiver.passphrase)".Length -gt 0) { + $escaped = [uri]::EscapeDataString("$($Receiver.passphrase)") + $query = "$query&passphrase=$escaped&pbkeylen=16" + } + return "srt://$($Receiver.host):$($port)?$query" +} + +function Format-CommandLine { + param( + [string]$Executable, + [string[]]$Arguments + ) + $parts = @($Executable) + foreach ($arg in $Arguments) { + if ($arg -match '[\s"&?=]') { + $escaped = $arg.Replace('"', '\"') + $parts += '"' + $escaped + '"' + } else { + $parts += $arg + } + } + return ($parts -join " ") +} + +function Start-SourceProcess { + param( + [string]$Name, + [string[]]$Arguments + ) + + $line = Format-CommandLine -Executable $FfmpegPath -Arguments $Arguments + if ($DryRun) { + Write-Host "" + Write-Host "[$Name]" + Write-Host $line + return + } + + New-Item -ItemType Directory -Force ".\logs" | Out-Null + $stdout = Join-Path ".\logs" "$Name.out.log" + $stderr = Join-Path ".\logs" "$Name.err.log" + $process = Start-Process -FilePath $FfmpegPath -ArgumentList $Arguments -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru -WindowStyle Hidden + Write-Host "$Name started. PID=$($process.Id) stdout=$stdout stderr=$stderr" +} + +$configPath = Resolve-ConfigPath -Path $Config +$settings = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json + +Assert-Value -Value $settings.receiver.host -Message "receiver.host is required" +Assert-Value -Value $settings.receiver.basePort -Message "receiver.basePort is required" +if (-not $settings.receiver.srtLatencyMicros) { + $settings.receiver | Add-Member -NotePropertyName srtLatencyMicros -NotePropertyValue 120000 +} + +if ($settings.video -and $settings.video.enabled) { + $videoUrl = Get-SrtUrl -Receiver $settings.receiver -PortOffset ([int]$settings.video.portOffset) + $videoArgs = @( + "-hide_banner", + "-loglevel", "info", + "-f", "gdigrab", + "-framerate", "$($settings.video.framerate)", + "-video_size", "$($settings.video.width)x$($settings.video.height)", + "-i", "$($settings.video.source)", + "-an", + "-c:v", "h264_nvenc", + "-preset", "$($settings.video.preset)", + "-tune", "$($settings.video.tune)", + "-b:v", "$($settings.video.bitrate)", + "-maxrate", "$($settings.video.maxrate)", + "-bufsize", "$($settings.video.bufsize)", + "-pix_fmt", "yuv420p", + "-g", "$([int]$settings.video.framerate * 2)", + "-f", "mpegts", + $videoUrl + ) + Start-SourceProcess -Name "video" -Arguments $videoArgs +} + +foreach ($audio in @($settings.audioSources)) { + Assert-Value -Value $audio.name -Message "audioSources[].name is required" + Assert-Value -Value $audio.device -Message "audioSources[].device is required" + $audioUrl = Get-SrtUrl -Receiver $settings.receiver -PortOffset ([int]$audio.portOffset) + $audioArgs = @( + "-hide_banner", + "-loglevel", "info", + "-f", "dshow", + "-i", "audio=$($audio.device)", + "-vn", + "-c:a", "$(if ($audio.codec) { $audio.codec } else { "aac" })", + "-b:a", "$($audio.bitrate)", + "-f", "mpegts", + $audioUrl + ) + Start-SourceProcess -Name "audio-$($audio.name)" -Arguments $audioArgs +} + +if ($DryRun) { + Write-Host "" + Write-Host "Dry run complete. Start OBS Media Sources in listener mode before running without -DryRun." +} diff --git a/scripts/sender-stop.ps1 b/scripts/sender-stop.ps1 new file mode 100644 index 0000000..ff88911 --- /dev/null +++ b/scripts/sender-stop.ps1 @@ -0,0 +1,28 @@ +param( + [string]$LogDirectory = ".\logs" +) + +$ErrorActionPreference = "Stop" + +$logs = Resolve-Path -LiteralPath $LogDirectory -ErrorAction SilentlyContinue +if (-not $logs) { + Write-Host "No log directory found at $LogDirectory" + return +} + +$ffmpegProcesses = Get-CimInstance Win32_Process | + Where-Object { + $_.Name -ieq "ffmpeg.exe" -and + $_.CommandLine -like "*srt://*" + } + +if (-not $ffmpegProcesses) { + Write-Host "No LocalCastBridge-looking ffmpeg SRT processes found." + return +} + +foreach ($process in $ffmpegProcesses) { + Stop-Process -Id $process.ProcessId -Force + Write-Host "Stopped ffmpeg PID=$($process.ProcessId)" +} + diff --git a/state/branches.json b/state/branches.json new file mode 100644 index 0000000..27ac0f7 --- /dev/null +++ b/state/branches.json @@ -0,0 +1,4 @@ +{ + "branches": [] +} + diff --git a/state/evidence.jsonl b/state/evidence.jsonl new file mode 100644 index 0000000..26f7c2c --- /dev/null +++ b/state/evidence.jsonl @@ -0,0 +1,3 @@ +{"ts":"2026-05-18T00:00:00+00:00","type":"decision","status":"ok","note":"Use FFmpeg SRT endpoints plus OBS Media Source for v1; OBS plugin or fork is not justified until standard ingest fails a concrete invariant."} + +{"ts": "2026-05-18T09:55:45+00:00", "type": "verification", "status": "ok", "note": "State helper and sender dry-run produce valid repo status and SRT URLs with explicit ports for the example config."} diff --git a/state/map.yaml b/state/map.yaml new file mode 100644 index 0000000..b799863 --- /dev/null +++ b/state/map.yaml @@ -0,0 +1,55 @@ +workspace: + name: LocalCastBridge + status: active + focus: local-obs-lan-source + premise: > + A second Windows PC should appear inside OBS as video plus separately + mixable audio sources. Use ordinary media transport first; native OBS code + only earns its keep after standard OBS/FFmpeg surfaces fail a real invariant. + +objective: + primary: > + Provide a repeatable local streaming environment where the sender captures + desktop/video and audio sources, encodes video with NVENC, and delivers + stable OBS Media Source URLs to the receiver over LAN. + current: > + Establish the script-and-doc spine for a two-PC smoke test. + +constraints: + - Sender is Windows. + - Receiver uses OBS. + - Video encode must happen on the sender using NVENC when available. + - Audio sources must remain separately controllable in OBS. + - V1 is LAN-only. + - Persistent state must clarify rehydrate, not become transcript tax. + +invariants: + - OBS composition authority stays in OBS. + - FFmpeg owns capture, encoding, and transport. + - One stable port equals one OBS source endpoint. + - Video endpoint uses H.264 over MPEG-TS/SRT. + - Audio endpoints default to AAC over MPEG-TS/SRT for broad OBS/FFmpeg compatibility. + - Local secrets remain untracked. + +accepted_design: + - `config/localcast.example.json` defines receiver address, base port, video encode settings, and audio sources. + - `scripts/sender-discover.ps1` lists FFmpeg devices, protocols, and encoders. + - `scripts/sender-start.ps1` launches one FFmpeg process per enabled source. + - OBS receives each source through Media Source URLs documented in `docs/obs-receiver-setup.md`. + - Persistence follows the active repo pattern: map, scratch, evidence, branches, handoff, system map, implementation plan, state helpers. + +active_subgoals: + - Validate the actual sender FFmpeg build and capture device names. + - Smoke-test the video endpoint in OBS. + - Smoke-test each audio endpoint in OBS and tune latency. + - Decide whether an OBS source generator or plugin investigation is justified. + +current_status: + summary: > + First repo scaffold is in place with persistence machinery, architecture + docs, example config, and sender scripts for FFmpeg/SRT/NVENC local OBS + source delivery. + next_action: > + Run sender discovery on the husband's PC, fill config/localcast.json with + actual receiver IP and audio device names, then dry-run sender-start before + opening live streams into OBS. diff --git a/state/scratch.md b/state/scratch.md new file mode 100644 index 0000000..273761b --- /dev/null +++ b/state/scratch.md @@ -0,0 +1,11 @@ +# Scratch + +## Current Subgoal + +No active scratch subgoal. + +## Notes + +- Keep scratch short and disposable. +- Promote only durable lessons into `state/evidence.jsonl` or the map. + diff --git a/tools/localcast_prepare_compaction.py b/tools/localcast_prepare_compaction.py new file mode 100644 index 0000000..88d90b9 --- /dev/null +++ b/tools/localcast_prepare_compaction.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +STATE_DIR = ROOT / "state" +NOTES_DIR = ROOT / "notes" + +MAP_PATH = STATE_DIR / "map.yaml" +SCRATCH_PATH = STATE_DIR / "scratch.md" +BRANCHES_PATH = STATE_DIR / "branches.json" +EVIDENCE_PATH = STATE_DIR / "evidence.jsonl" +HANDOFF_PATH = NOTES_DIR / "fresh-workspace-handoff.md" +PLAN_PATH = ROOT / "docs" / "implementation-plan.md" +SYSTEM_MAP_PATH = NOTES_DIR / "current-system-map.md" +AGENTS_PATH = ROOT / "AGENTS.md" + + +@dataclass +class Finding: + level: str + message: str + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def run_git(args: list[str]) -> str: + completed = subprocess.run( + ["git", *args], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def extract_map_field(name: str) -> str | None: + prefix = f" {name}:" + lines = read_text(MAP_PATH).splitlines() + for index, line in enumerate(lines): + if line.startswith(prefix): + value = line.split(":", 1)[1].strip() + if value != ">": + return value + block: list[str] = [] + for candidate in lines[index + 1 :]: + if candidate.startswith(" "): + block.append(candidate.strip()) + continue + if candidate.strip() == "": + block.append("") + continue + break + return " ".join(part for part in block if part).strip() + return None + + +def extract_active_subgoals() -> list[str]: + lines = read_text(MAP_PATH).splitlines() + results: list[str] = [] + in_section = False + for line in lines: + if not in_section: + if line.strip() == "active_subgoals:": + in_section = True + continue + if line.startswith(" - "): + results.append(line[4:].strip()) + continue + if line and not line.startswith(" "): + break + return results + + +def current_scratch_subgoal() -> str | None: + lines = read_text(SCRATCH_PATH).splitlines() + for index, line in enumerate(lines): + if line.strip() != "## Current Subgoal": + continue + for candidate in lines[index + 1 :]: + stripped = candidate.strip() + if stripped: + return stripped + return None + + +def load_evidence() -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for line_number, line in enumerate(read_text(EVIDENCE_PATH).splitlines(), start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"evidence line {line_number} is invalid JSON: {exc}") from exc + if not isinstance(record, dict): + raise ValueError(f"evidence line {line_number} is not a JSON object") + records.append(record) + return records + + +def load_branches() -> dict[str, Any]: + data = json.loads(read_text(BRANCHES_PATH)) + if not isinstance(data, dict): + raise ValueError("branches.json must be a JSON object") + branches = data.get("branches") + if not isinstance(branches, list): + raise ValueError("branches.json must contain a branches list") + return data + + +def add_required_file_checks(findings: list[Finding]) -> None: + for path in [ + MAP_PATH, + SCRATCH_PATH, + BRANCHES_PATH, + EVIDENCE_PATH, + HANDOFF_PATH, + PLAN_PATH, + SYSTEM_MAP_PATH, + AGENTS_PATH, + ]: + if path.exists(): + findings.append(Finding("ok", f"found {path.relative_to(ROOT)}")) + else: + findings.append(Finding("error", f"missing {path.relative_to(ROOT)}")) + + +def add_content_checks(findings: list[Finding]) -> list[dict[str, Any]]: + summary = extract_map_field("summary") + next_action = extract_map_field("next_action") + if summary: + findings.append(Finding("ok", "state/map.yaml has current_status.summary")) + else: + findings.append(Finding("error", "state/map.yaml is missing current_status.summary")) + if next_action: + findings.append(Finding("ok", "state/map.yaml has current_status.next_action")) + else: + findings.append(Finding("error", "state/map.yaml is missing current_status.next_action")) + + subgoals = extract_active_subgoals() + if subgoals: + findings.append(Finding("ok", f"state/map.yaml has {len(subgoals)} active subgoal(s)")) + else: + findings.append(Finding("warn", "state/map.yaml has no active_subgoals entries")) + + scratch_subgoal = current_scratch_subgoal() + if scratch_subgoal == "No active scratch subgoal.": + findings.append(Finding("ok", "state/scratch.md has no stale active scratch subgoal")) + elif scratch_subgoal: + findings.append(Finding("warn", f"state/scratch.md has active scratch subgoal: {scratch_subgoal}")) + else: + findings.append(Finding("warn", "state/scratch.md has no Current Subgoal value")) + + evidence = load_evidence() + findings.append(Finding("ok", f"state/evidence.jsonl parses ({len(evidence)} record(s))")) + if EVIDENCE_PATH.exists() and EVIDENCE_PATH.stat().st_size > 25_000: + findings.append(Finding("warn", "state/evidence.jsonl is larger than 25 KB; consider distillation")) + + branches = load_branches() + active = [ + branch + for branch in branches.get("branches", []) + if isinstance(branch, dict) and branch.get("status") == "active" + ] + findings.append(Finding("ok", f"state/branches.json parses ({len(active)} active branch(es))")) + return evidence + + +def add_handoff_checks(findings: list[Finding]) -> None: + text = read_text(HANDOFF_PATH) + stale_branch = re.search(r"Current branch before .*ahead \d+", text) + stale_head = re.search(r"Current HEAD before .*\b[0-9a-f]{7,40}\b", text) + if stale_branch or stale_head: + findings.append(Finding("error", "handoff embeds an exact branch or HEAD snapshot; use git commands instead")) + else: + findings.append(Finding("ok", "handoff avoids exact branch/HEAD snapshots")) + + required_phrases = [ + "Do not continue implementation automatically from a rehydrate-only request.", + "Do not trust this file for the exact live HEAD.", + "Immediate Re-entry Instruction", + ] + for phrase in required_phrases: + if phrase in text: + findings.append(Finding("ok", f"handoff contains: {phrase}")) + else: + findings.append(Finding("warn", f"handoff missing: {phrase}")) + + +def add_agents_checks(findings: list[Finding]) -> None: + text = read_text(AGENTS_PATH) + if "localcast_prepare_compaction.py" in text: + findings.append(Finding("ok", "AGENTS.md tells agents to use the compaction helper")) + else: + findings.append(Finding("error", "AGENTS.md does not mention tools/localcast_prepare_compaction.py")) + if "prepare for imminent compaction" in text.lower(): + findings.append(Finding("ok", "AGENTS.md names the imminent-compaction trigger")) + else: + findings.append(Finding("warn", "AGENTS.md does not name the imminent-compaction trigger phrase")) + + +def add_git_checks(findings: list[Finding]) -> tuple[str, str]: + status = run_git(["status", "--short", "--branch"]) + try: + log = run_git(["log", "--oneline", "-5"]) + except subprocess.CalledProcessError: + log = "(no commits yet)" + dirty_lines = [line for line in status.splitlines()[1:] if line.strip()] + if dirty_lines: + findings.append(Finding("warn", "git worktree has uncommitted changes; commit or explain before compaction")) + else: + findings.append(Finding("ok", "git worktree is clean")) + return status, log + + +def render_report(findings: list[Finding], status: str, log: str, evidence: list[dict[str, Any]]) -> str: + summary = extract_map_field("summary") or "(missing)" + next_action = extract_map_field("next_action") or "(missing)" + subgoals = extract_active_subgoals() + + counts = { + "ok": sum(1 for finding in findings if finding.level == "ok"), + "warn": sum(1 for finding in findings if finding.level == "warn"), + "error": sum(1 for finding in findings if finding.level == "error"), + } + + lines = [ + "LocalCastBridge pre-compaction persistence check", + f"Workspace: {ROOT}", + f"Findings: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error", + "", + "Git status:", + status, + "", + "Recent commits:", + log, + "", + f"Summary: {summary}", + f"Next action: {next_action}", + ] + if subgoals: + lines.append("Active subgoals:") + lines.extend(f"- {item}" for item in subgoals) + if evidence: + latest = evidence[-1] + lines.extend( + [ + "", + "Latest distilled evidence:", + f"- {latest.get('ts', '(missing ts)')} {latest.get('type', '(missing type)')}/{latest.get('status', '(missing status)')}: {latest.get('note', '(missing note)')}", + ] + ) + + lines.append("") + lines.append("Findings:") + for finding in findings: + lines.append(f"[{finding.level.upper()}] {finding.message}") + + lines.extend( + [ + "", + "Pre-compaction checklist:", + "- Update state/map.yaml only if current understanding changed.", + "- Refresh notes/fresh-workspace-handoff.md if re-entry instructions changed.", + "- Add distilled evidence only for a belief-changing lesson, verification, rejected path, or scar.", + "- Keep exact branch/HEAD out of handoff prose; git commands own volatile truth.", + "- Commit completed persistence changes, or state why the worktree must stay dirty.", + "- Re-run this helper after edits before yielding to compaction.", + ] + ) + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Audit LocalCastBridge persistent state before imminent compaction." + ) + parser.add_argument( + "--strict", + action="store_true", + help="Exit nonzero on warnings as well as errors.", + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + findings: list[Finding] = [] + evidence: list[dict[str, Any]] = [] + + add_required_file_checks(findings) + try: + evidence = add_content_checks(findings) + except (OSError, ValueError, json.JSONDecodeError) as exc: + findings.append(Finding("error", f"state content check failed: {exc}")) + try: + add_handoff_checks(findings) + except OSError as exc: + findings.append(Finding("error", f"handoff check failed: {exc}")) + try: + add_agents_checks(findings) + except OSError as exc: + findings.append(Finding("error", f"AGENTS check failed: {exc}")) + try: + status, log = add_git_checks(findings) + except (OSError, subprocess.CalledProcessError) as exc: + findings.append(Finding("error", f"git check failed: {exc}")) + status = "(git status unavailable)" + log = "(git log unavailable)" + + print(render_report(findings, status, log, evidence)) + + has_error = any(finding.level == "error" for finding in findings) + has_warning = any(finding.level == "warn" for finding in findings) + if has_error or (args.strict and has_warning): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/localcast_state.py b/tools/localcast_state.py new file mode 100644 index 0000000..c3acb35 --- /dev/null +++ b/tools/localcast_state.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +STATE_DIR = ROOT / "state" +MAP_PATH = STATE_DIR / "map.yaml" +BRANCHES_PATH = STATE_DIR / "branches.json" +EVIDENCE_PATH = STATE_DIR / "evidence.jsonl" + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def write_text(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + + +def load_branches() -> dict[str, Any]: + return json.loads(read_text(BRANCHES_PATH)) + + +def save_branches(data: dict[str, Any]) -> None: + write_text(BRANCHES_PATH, json.dumps(data, indent=2) + "\n") + + +def append_evidence(record: dict[str, Any]) -> None: + with EVIDENCE_PATH.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=True) + "\n") + + +def utc_stamp() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def extract_map_field(name: str) -> str | None: + prefix = f" {name}:" + lines = read_text(MAP_PATH).splitlines() + for index, line in enumerate(lines): + if line.startswith(prefix): + value = line.split(":", 1)[1].strip() + if value != ">": + return value + block: list[str] = [] + for candidate in lines[index + 1 :]: + if candidate.startswith(" "): + block.append(candidate.strip()) + continue + if candidate.strip() == "": + block.append("") + continue + break + return " ".join(part for part in block if part).strip() + return None + + +def extract_active_subgoals() -> list[str]: + lines = read_text(MAP_PATH).splitlines() + results: list[str] = [] + in_section = False + for line in lines: + if not in_section: + if line.strip() == "active_subgoals:": + in_section = True + continue + if line.startswith(" - "): + results.append(line[4:].strip()) + continue + if line and not line.startswith(" "): + break + return results + + +def cmd_status(_: argparse.Namespace) -> int: + branches = load_branches().get("branches", []) + active = [branch for branch in branches if branch.get("status") == "active"] + summary = extract_map_field("summary") or "(missing)" + next_action = extract_map_field("next_action") or "(missing)" + subgoals = extract_active_subgoals() + + print(f"Workspace: {ROOT}") + print(f"Summary: {summary}") + print(f"Next action: {next_action}") + print(f"Active branches: {len(active)} / {len(branches)}") + if subgoals: + print("Active subgoals:") + for item in subgoals: + print(f"- {item}") + return 0 + + +def cmd_add_evidence(args: argparse.Namespace) -> int: + record: dict[str, Any] = { + "ts": utc_stamp(), + "type": args.type, + "status": args.status, + "note": args.note, + } + if args.branch: + record["branch"] = args.branch + append_evidence(record) + print("Appended evidence record.") + return 0 + + +def cmd_add_branch(args: argparse.Namespace) -> int: + data = load_branches() + branches = data.setdefault("branches", []) + if any(branch.get("id") == args.id for branch in branches): + raise SystemExit(f"Branch '{args.id}' already exists.") + branches.append( + { + "id": args.id, + "hypothesis": args.hypothesis, + "status": "active", + "artifacts": args.artifact or [], + "notes": args.note or "", + } + ) + save_branches(data) + print(f"Added branch '{args.id}'.") + return 0 + + +def cmd_close_branch(args: argparse.Namespace) -> int: + data = load_branches() + for branch in data.get("branches", []): + if branch.get("id") != args.id: + continue + branch["status"] = args.status + if args.note: + branch["notes"] = args.note + save_branches(data) + print(f"Updated branch '{args.id}' to status '{args.status}'.") + return 0 + raise SystemExit(f"Branch '{args.id}' was not found.") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Inspect and update LocalCastBridge persistence state files." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + status_parser = subparsers.add_parser("status", help="Show a compact state summary.") + status_parser.set_defaults(func=cmd_status) + + evidence_parser = subparsers.add_parser( + "add-evidence", help="Append one distilled JSONL evidence record." + ) + evidence_parser.add_argument("--type", required=True) + evidence_parser.add_argument("--status", required=True) + evidence_parser.add_argument("--note", required=True) + evidence_parser.add_argument("--branch") + evidence_parser.set_defaults(func=cmd_add_evidence) + + add_branch_parser = subparsers.add_parser( + "add-branch", help="Create a new active branch entry." + ) + add_branch_parser.add_argument("--id", required=True) + add_branch_parser.add_argument("--hypothesis", required=True) + add_branch_parser.add_argument("--artifact", action="append") + add_branch_parser.add_argument("--note") + add_branch_parser.set_defaults(func=cmd_add_branch) + + close_branch_parser = subparsers.add_parser( + "close-branch", help="Close an existing branch." + ) + close_branch_parser.add_argument("--id", required=True) + close_branch_parser.add_argument( + "--status", required=True, choices=["accepted", "rejected", "archived"] + ) + close_branch_parser.add_argument("--note") + close_branch_parser.set_defaults(func=cmd_close_branch) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main())