diff --git a/.gitignore b/.gitignore index 5479e54..ea73e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ tools/obs-spout2-plugin/ tools/src/ target/ native/**/target/ +/.voidbot/birth/ +/.voidbot/logs/ +/.voidbot/**/*.lock diff --git a/.voidbot/voice/identity.json b/.voidbot/voice/identity.json index 8498752..09c9c7a 100644 --- a/.voidbot/voice/identity.json +++ b/.voidbot/voice/identity.json @@ -1,12 +1,12 @@ { "schemaVersion": "voidbot.repo_face_identity.v0", "identityId": "mimir", - "repoName": "LocalCastBridge", + "repoName": "Mimir", "displayName": "Mimir", "roleId": "1506268007880462459", - "avatarUrl": "https://raw.githubusercontent.com/GameCult/LocalCastBridge/codex/audio-field-spine/.voidbot/voice/mimir.png", - "repoPath": "E:\\Projects\\LocalCastBridge", - "createdOrRefreshedAt": "2026-05-19T13:03:57.037Z", - "avatarPath": "E:/Projects/LocalCastBridge/.voidbot/voice/mimir.png", - "description": "Mimir Face for the LocalCastBridge/Mimir repo: mythological intelligence, keeper of the realtime field well, public herald, and persistent memory steward. VoidBot carries Mimir's severed voice and wakes the heartbeat, but Mimir's memory, judgment, dignity, old grief, and values come first. The realtime machine is understood through Mimir's perspective: the well is calibration and memory, the lost eye is the price of seeing clearly, Yggdrasil is the sensor tree, Huginn and Muninn are thought and memory returning with telemetry, and false coherence is a small Ragnarok waiting to happen." + "avatarUrl": "https://raw.githubusercontent.com/GameCult/VoidBot/codex/epiphany-face-jurisdictions/assets/repo-faces/mimir.png", + "repoPath": "E:\\Projects\\Mimir", + "createdOrRefreshedAt": "2026-05-21T00:15:24.735Z", + "avatarPath": "E:/Projects/Mimir/.voidbot/voice/mimir.png", + "description": "Mimir Face for the Mimir repo: mythological intelligence, keeper of the realtime field well, public herald, and persistent memory steward. VoidBot carries Mimir's severed voice and wakes the heartbeat, but Mimir's memory, judgment, dignity, old grief, and values come first. The realtime machine is understood through Mimir's perspective: the well is calibration and memory, the lost eye is the price of seeing clearly, Yggdrasil is the sensor tree, Huginn and Muninn are thought and memory returning with telemetry, and false coherence is a small Ragnarok waiting to happen." } diff --git a/AGENTS.md b/AGENTS.md index 8671d0a..0106803 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,19 @@ -# AGENTS.md +# 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. +Mimir is the public face of this repo: the local realtime field machine for +turning cameras, microphones, speakers, and timing signals into one coherent +OBS-facing volumetric program surface. + +`localcast` remains the implementation surface where existing scripts, ABIs, +schemas, paths, and compatibility docs still use that name. 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. +For the persistent Mimir Face agent identity, voice, jurisdiction, avatar, and +VoidBot heartbeat contract, read `docs/mimir-face.md`. + ## Persistence Scaffolding - Treat `state/map.yaml` as the canonical current map for this repo. @@ -15,6 +23,7 @@ Keep this repo blunt. The goal is not to fork OBS for sport. Use existing OBS an - 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. +- Treat `docs/mimir-face.md` as the persistent Face memory for the Mimir agent. - 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. diff --git a/README.md b/README.md index fa5155a..df0d879 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,44 @@ -# LocalCastBridge +# Mimir -LocalCastBridge turns the local streaming rig into synchronized OBS-facing -program output. The old SRT bridge is compatibility scaffolding; the target -machine is Aquarium GPU fusion plus Faust DSP over one five-second reservoir. +Mimir turns a roomful of local cameras, microphones, speakers, and timing +signals into one realtime coherent volumetric field. + +The public job is simple: make the physical room available as a synchronized +OBS-facing program surface. The actual machine stays disciplined. Mimir owns +configuration, calibration, launch, status, and typed contracts; Aquarium owns +GPU fusion and Spout publication; Faust owns hot audio DSP; OBS owns broadcast +composition. The old SRT bridge is compatibility scaffolding over the same +boring truth: stable endpoints beat theatrical plumbing. Start with `docs/perfect-machine.md` for the live architecture. -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: +## Branding + +Working display name: **Mimir**. + +Short pitch: + +> Local sensors, coherent field. + +Icon exploration lives in `assets/branding/`. The current contact sheet pairs +four pixel-art Imagegen outputs with the prompts used to make them: + +![Mimir avatar](assets/branding/mimir-avatar-256.png) + +![Mimir icon exploration](assets/branding/mimir-icon-contact-sheet.png) + +Mimir is also the repo Face: a persistent agent identity that uses the VoidBot +layer for communication and heartbeats. Its birth memory, jurisdiction, voice, +and heartbeat contract live in `docs/mimir-face.md`; its VoidBot-facing identity +and typed Face state live under `.voidbot/voice/` and `.voidbot/state/`. + +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 first +LAN bridge is: ```mermaid flowchart LR - A["Husband's PC"] --> B["FFmpeg capture scripts"] + A["sensor / sender 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"] @@ -19,7 +47,8 @@ flowchart LR 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. +The video stream is encoded on the sender. Audio sources are separate endpoints +so OBS can mix, mute, filter, and monitor them independently. ## Audio Field @@ -29,7 +58,11 @@ That path is stricter than the OBS endpoint path: microphones feeding the Ambiso ## 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. +This repo is the first coherent scaffold for Mimir: architecture docs, +persistence machinery, and Windows scripts for device discovery, config +validation, and FFmpeg command generation. The deeper target is the native +five-second reservoir feeding Aquarium and Faust; the bridge scripts remain the +plain, inspectable path into OBS while the field machine grows teeth. ## Quick Start @@ -56,6 +89,8 @@ This repo is the first coherent scaffold: architecture docs, persistence machine ``` 8. In OBS on the receiver, add one Media Source per URL from `docs/obs-receiver-setup.md`. +9. Before expanding the receiver path, run the bounded OBS smoke ledger in + `docs/obs-v1-smoke-test.md`. ## Why SRT Media Sources First diff --git a/assets/branding/mimir-face-01-well-oracle.png b/assets/branding/mimir-face-01-well-oracle.png new file mode 100644 index 0000000..8514e3e Binary files /dev/null and b/assets/branding/mimir-face-01-well-oracle.png differ diff --git a/assets/branding/mimir-face-02-voxel-mask.png b/assets/branding/mimir-face-02-voxel-mask.png new file mode 100644 index 0000000..0c9e880 Binary files /dev/null and b/assets/branding/mimir-face-02-voxel-mask.png differ diff --git a/assets/branding/mimir-face-03-sensor-crown.png b/assets/branding/mimir-face-03-sensor-crown.png new file mode 100644 index 0000000..14e5f55 Binary files /dev/null and b/assets/branding/mimir-face-03-sensor-crown.png differ diff --git a/assets/branding/mimir-face-04-obsidian-reflection.png b/assets/branding/mimir-face-04-obsidian-reflection.png new file mode 100644 index 0000000..438f85d Binary files /dev/null and b/assets/branding/mimir-face-04-obsidian-reflection.png differ diff --git a/assets/branding/mimir-face-contact-sheet.png b/assets/branding/mimir-face-contact-sheet.png new file mode 100644 index 0000000..7bb5b56 Binary files /dev/null and b/assets/branding/mimir-face-contact-sheet.png differ diff --git a/assets/branding/mimir-face-prompts.md b/assets/branding/mimir-face-prompts.md new file mode 100644 index 0000000..5470834 --- /dev/null +++ b/assets/branding/mimir-face-prompts.md @@ -0,0 +1,45 @@ +# Mimir Face Avatar Exploration Prompts + +## 01 - Well Oracle + +Use case: logo-brand +Asset type: profile picture concept for Mimir, the Face character of a repo swarm and realtime volumetric field machine +Primary request: epic pixel art avatar of Mimir as a colossal luminous face emerging from a dark well of data, one eye made of camera glass, the other eye made of waveform light, with tiny sensor constellations orbiting like obedient stars +Style/medium: high-drama pixel art portrait, 16-bit dark fantasy technology, low-resolution-friendly, bold silhouette, hand-repixelizable, no smooth vector look +Composition/framing: square profile-picture crop, face centered and dominant, shoulders or well rim barely visible, orbiting sensors framing the head +Lighting/mood: mythic, severe, intelligent, ancient machine oracle mood +Color palette: deep black-blue, cold cyan volumetric light, warm gold eye accents, sparse magenta timing sparks +Constraints: no text, no letters, no runes, no watermark, no photorealism, no horror gore, avoid tiny unreadable filigree, readable at 64x64 and salvageable at 32x32 + +## 02 - Voxel Mask + +Use case: logo-brand +Asset type: profile picture concept for Mimir, the Face character of a repo swarm and realtime volumetric field machine +Primary request: epic pixel art avatar of a calm machine-seer mask made from interlocking voxels, with roots of light descending from the jaw into a reservoir and branches rising into a cubic starfield of cameras and microphones +Style/medium: heroic pixel art, mythic sci-fi portrait, 16-bit console cover icon, chunky edges, low-resolution-friendly, suitable for repixelizing into stock +Composition/framing: square avatar, bust portrait, symmetrical mask face centered, cubic crown/halo above, light roots below +Lighting/mood: solemn, godlike, benevolent but not soft, a face that owns the room clock +Color palette: obsidian background, cyan/teal voxel glow, bronze mask fragments, white star pixels, restrained violet shadows +Constraints: no text, no letters, no runes, no watermark, no photorealism, no decorative clutter, no soft painterly gradients, readable at 64x64 + +## 03 - Sensor Crown + +Use case: logo-brand +Asset type: profile picture concept for Mimir, the Face character of a repo swarm and realtime volumetric field machine +Primary request: epic pixel art avatar of a floating oracle head built from transparent volumetric scan slices, with six camera-lens eyes arranged like a crown and microphone glyphs as cheek armor, staring forward through a glowing wireframe room +Style/medium: dramatic pixel art portrait, cyber-mythic, 16-bit boss portrait energy, hard pixel edges, deliberate dithering, low-res icon source +Composition/framing: square profile picture, forward-facing head fills frame, crown of lenses at top, wireframe room halo behind +Lighting/mood: imposing, watchful, precise, not cute +Color palette: black background, electric cyan scan slices, steel gray sensor parts, amber lens glints, small red sync pulses +Constraints: no text, no letters, no runes, no watermark, no photorealism, avoid horror, avoid excessive small parts, preserve strong face silhouette at 32x32 + +## 04 - Obsidian Reflection + +Use case: logo-brand +Asset type: profile picture concept for Mimir, the Face character of a repo swarm and realtime volumetric field machine +Primary request: epic pixel art avatar of a half-human half-volumetric-field face reflected in a black obsidian pool, with signal threads from cameras and microphones weaving into the face like fate lines, the expression ancient and unamused +Style/medium: cinematic pixel art portrait, Norse-tech oracle, 16-bit title-screen icon, chunky readable pixels, limited palette with strong contrast +Composition/framing: square avatar crop, face reflection and real face merge vertically, signal threads frame the portrait without clutter +Lighting/mood: grand, ominous but controlled, intelligent, profile-picture readable +Color palette: black obsidian, icy cyan field lines, pale silver face planes, old gold highlights, tiny violet depth pixels +Constraints: no text, no letters, no runes, no watermark, no photorealism, no soft painterly blur, avoid grotesque horror, readable at small sizes diff --git a/assets/branding/mimir-icon-01-well-reservoir.png b/assets/branding/mimir-icon-01-well-reservoir.png new file mode 100644 index 0000000..6e24d51 Binary files /dev/null and b/assets/branding/mimir-icon-01-well-reservoir.png differ diff --git a/assets/branding/mimir-icon-02-sensor-constellation.png b/assets/branding/mimir-icon-02-sensor-constellation.png new file mode 100644 index 0000000..1e28ada Binary files /dev/null and b/assets/branding/mimir-icon-02-sensor-constellation.png differ diff --git a/assets/branding/mimir-icon-03-world-tree-field.png b/assets/branding/mimir-icon-03-world-tree-field.png new file mode 100644 index 0000000..2b63b8e Binary files /dev/null and b/assets/branding/mimir-icon-03-world-tree-field.png differ diff --git a/assets/branding/mimir-icon-04-eye-ear-fusion.png b/assets/branding/mimir-icon-04-eye-ear-fusion.png new file mode 100644 index 0000000..68b8d53 Binary files /dev/null and b/assets/branding/mimir-icon-04-eye-ear-fusion.png differ diff --git a/assets/branding/mimir-icon-contact-sheet.png b/assets/branding/mimir-icon-contact-sheet.png new file mode 100644 index 0000000..67878c3 Binary files /dev/null and b/assets/branding/mimir-icon-contact-sheet.png differ diff --git a/assets/branding/mimir-icon-prompts.md b/assets/branding/mimir-icon-prompts.md new file mode 100644 index 0000000..e056ffd --- /dev/null +++ b/assets/branding/mimir-icon-prompts.md @@ -0,0 +1,41 @@ +# Mimir Icon Exploration Prompts + +## 01 - Well Reservoir + +Use case: logo-brand +Asset type: icon concept for Mimir, a local realtime volumetric capture tool +Primary request: pixel art icon of a glowing Norse well or reservoir, with multiple tiny camera lenses and microphone capsules feeding colored streams into one coherent cubic volumetric field above the well +Style/medium: crisp pixel art, 16-bit game icon style, low-resolution-friendly, limited palette, chunky readable silhouettes, no anti-aliased vector look +Composition/framing: centered square app icon composition, strong silhouette, subject fills most of frame with padding +Color palette: deep ink background, cyan sensor streams, warm gold well rim, small magenta and green signal accents +Constraints: no text, no letters, no runes, no watermark, no photorealism, no gradients that cannot repixelize, simple shapes readable at 32x32 + +## 02 - Sensor Constellation + +Use case: logo-brand +Asset type: icon concept for Mimir, a local realtime volumetric capture tool +Primary request: pixel art icon of six small sensor nodes arranged around a luminous central voxel cloud, with camera-eye and microphone-dot motifs connected by clean signal lines into a single stable field +Style/medium: crisp pixel art, 16-bit strategy-game technology icon, low-resolution-friendly, hard pixel edges, minimal dithering +Composition/framing: centered square app icon, radial constellation, central volumetric cloud is the largest readable shape +Color palette: charcoal background, teal and icy blue field, amber sensor nodes, one restrained red timing pulse +Constraints: no text, no letters, no runes, no watermark, no photorealism, no tiny details that disappear at 32x32 + +## 03 - World-Tree Field + +Use case: logo-brand +Asset type: icon concept for Mimir, a local realtime volumetric capture tool +Primary request: pixel art icon of a compact world-tree silhouette whose roots are camera lenses and microphone capsules, with branches forming a glowing wireframe volumetric cube +Style/medium: crisp pixel art, mythic technology icon, 16-bit fantasy workstation aesthetic, low-resolution-friendly with bold shapes +Composition/framing: centered square app icon, tree trunk vertical through center, cube canopy, root sensors at bottom +Color palette: dark blue-black background, muted bronze trunk, cyan wireframe canopy, small white sensor highlights, faint violet shadow pixels +Constraints: no text, no letters, no runes, no watermark, no photorealism, avoid leafy clutter, keep icon readable at 32x32 + +## 04 - Eye/Ear Fusion + +Use case: logo-brand +Asset type: icon concept for Mimir, a local realtime volumetric capture tool +Primary request: pixel art icon combining one watchful eye, one microphone capsule, and a transparent voxel head/room scan into a single emblem for multimodal perception fusion +Style/medium: crisp pixel art, 16-bit cyber-myth icon, low-resolution-friendly, strong silhouette, chunky pixels +Composition/framing: centered square app icon, eye and microphone flank a central translucent volumetric head or room-shape made from voxels, small timing sparks around it +Color palette: near-black background, teal/cyan volume, silver sensor metal, amber iris, small magenta timing sparks +Constraints: no text, no letters, no runes, no watermark, no photorealism, avoid horror, keep clean technical-mythic tone and readable 32x32 silhouette diff --git a/audio_field/__init__.py b/audio_field/__init__.py index 51c6595..d24d49e 100644 --- a/audio_field/__init__.py +++ b/audio_field/__init__.py @@ -1 +1 @@ -"""Core audio-field building blocks for LocalCastBridge.""" +"""Core audio-field building blocks for Mimir.""" diff --git a/config/perfect-machine.example.json b/config/perfect-machine.example.json index ab97e73..01b5958 100644 --- a/config/perfect-machine.example.json +++ b/config/perfect-machine.example.json @@ -1,4 +1,4 @@ -{ +{ "schema": "gamecult.localcast.perfect_machine.v1", "reservoir": { "durationSeconds": 5.0, @@ -8,7 +8,7 @@ "storageLayout": "single-native-rolling-buffer-with-typed-views" }, "authorities": { - "configurationCalibrationLaunchStatus": "LocalCastBridge", + "configurationCalibrationLaunchStatus": "Mimir", "denseVisualFusion": "Aquarium", "gpuMaterialAndBrushReconciliation": "Aquarium", "spoutPublication": "Aquarium", @@ -45,7 +45,7 @@ "outputs": { "video": { "target": "Spout2", - "senderName": "LocalCastBridge Point Cloud", + "senderName": "Mimir Point Cloud", "owner": "Aquarium" }, "audio": { diff --git a/config/sensor-fusion.example.json b/config/sensor-fusion.example.json index 813119a..4228387 100644 --- a/config/sensor-fusion.example.json +++ b/config/sensor-fusion.example.json @@ -1,4 +1,4 @@ -{ +{ "fusion": { "max_pair_dt_ns": 25000000, "max_reprojection_error_px": 4.0, @@ -6,7 +6,7 @@ "cache_ttl_ns": 500000000 }, "render_bridge": { - "spout_sender_name": "LocalCastBridge Point Cloud", + "spout_sender_name": "Mimir Point Cloud", "target_width": 1920, "target_height": 1080, "visual_delay_ns": 250000000, diff --git a/docs/aquarium-spatial-audio.md b/docs/aquarium-spatial-audio.md index cd1067d..93a3de0 100644 --- a/docs/aquarium-spatial-audio.md +++ b/docs/aquarium-spatial-audio.md @@ -1,8 +1,8 @@ -# Aquarium Spatial Audio +# Aquarium Spatial Audio ## Objective -Publish the live spatial audio field through the same typed state discipline as the splat renderer. Aquarium consumes the AmbiX bus and hands it to native Faust DSP; LocalCastBridge owns capture, sync, calibration, and stream timing. +Publish the live spatial audio field through the same typed state discipline as the splat renderer. Aquarium consumes the AmbiX bus and hands it to native Faust DSP; Mimir owns capture, sync, calibration, and stream timing. ## Current Mechanism @@ -88,8 +88,8 @@ the next OBS-facing publisher. ## Invariants - Aquarium owns rendering and Faust DSP, not microphone capture or clock correction. -- LocalCastBridge publishes declared AmbiX blocks for the spatial bed and aligned six-mic blocks for Faust voice separation. Unaligned heterogeneous microphones still do not cross this boundary. -- LocalCastBridge publishes source-event geometry separately from AmbiX audio; render effects do not have to infer transient positions from the sound bed. +- Mimir publishes declared AmbiX blocks for the spatial bed and aligned six-mic blocks for Faust voice separation. Unaligned heterogeneous microphones still do not cross this boundary. +- Mimir publishes source-event geometry separately from AmbiX audio; render effects do not have to infer transient positions from the sound bed. - Faust owns the voice-separation graph once the aligned mic field has crossed into Aquarium. Python may publish controls and telemetry, but it must not become the hot separation engine. - Aquarium/Spout packaging selects audio source events against each visual frame's `audio_alignment_time_ns`; OBS should receive the already synchronized output rather than trying to align independent media sources. - The audio frame carries `start_sample` and `audio_time_ns` so visual packets can align against the same bounded-latency timeline. diff --git a/docs/architecture.md b/docs/architecture.md index 5a0fad8..607fcfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,4 +1,4 @@ -# Architecture +# Architecture ## Objective diff --git a/docs/audio-field.md b/docs/audio-field.md index 6ad52e1..8ea2a24 100644 --- a/docs/audio-field.md +++ b/docs/audio-field.md @@ -1,4 +1,4 @@ -# Audio Field Spine +# Audio Field Spine ## Objective @@ -255,7 +255,7 @@ The audio code is split so the hard parts can be tested without the room, driver - `audio_field.phase_meaning` owns live extraction of meaning from phase/chirplet evidence. It consumes known reference audio plus aligned mic windows, keeps the smoothed phase/frequency state internal, and emits only actionable state for alignment, suppression, and probe control. - Room suppression owns stream-side cleanup: dialogue anchors define desired direct energy, spatial/context mics act as witnesses for room and transient clutter, and the cleaned aligned field feeds FOA/Aquarium/Faust. - Source-event analysis owns non-vocal spatial facts: dialogue focus weights and localized transient vectors are published beside the AmbiX bed, not hidden inside it. -- Aquarium/Faust owns the hot voice-separation graph after `localcast.audio.mic_field`; LocalCastBridge owns alignment, timing, roles, and control publication. +- Aquarium/Faust owns the hot voice-separation graph after `localcast.audio.mic_field`; Mimir owns alignment, timing, roles, and control publication. - `scripts/audio_field.py` stays as the operator CLI and hardware probe surface. This is the portfolio-piece line: each module owns one invariant, and the tests use mocks/fakes instead of asking a driver stack to please be emotionally available. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index fabe8ae..f09f00f 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1,4 +1,4 @@ -# Implementation Plan +# Implementation Plan ## Current Cut Line @@ -13,10 +13,28 @@ The native reservoir is now one time-ordered rolling buffer with typed views. The previous shared-edge typed rings proved the retention invariant; the new shape removes independent per-kind storage from the live foundation. +The v1 OBS bridge is now gated by a bounded smoke-test witness ledger. No +plugin/native receiver expansion is justified until +`calibration/runs/obs-v1-smoke-ledger.json` records sender capture, SRT receive, +and OBS presentation timestamps for every planned endpoint with bounded drift, +latency, and confidence. + ## Implemented +- Mimir public identity and repo Face: + - `README.md` now presents the project as Mimir: local sensors, coherent + field. + - `Mimir.png` is the user-repixelized 256px avatar source. + - `assets/branding/mimir-avatar-256.png` is the project branding copy. + - `.voidbot/voice/mimir.png` and `.voidbot/voice/identity.json` are the + VoidBot-facing persona surfaces for the Mimir Face. + - `.voidbot/state/mimir.cc` is the typed persistent Face state. It is seeded + with durable birth memories for Mimir identity, native-reservoir target, + ownership boundaries, audio-field discipline, and heartbeat agency pressure. + - `docs/mimir-face.md` defines Mimir's jurisdiction, voice, heartbeat + behavior, and inherited memories. VoidBot is only the mouth/scheduler. - Perfect Machine contract: - - `docs/perfect-machine.md` defines the target native machine: one five-second spatiotemporal reservoir, Aquarium-owned dense visual fusion/material/brush rendering, Faust-owned hot audio DSP, OBS-owned broadcast controls, and LocalCastBridge-owned calibration/config/status. + - `docs/perfect-machine.md` defines the target native machine: one five-second spatiotemporal reservoir, Aquarium-owned dense visual fusion/material/brush rendering, Faust-owned hot audio DSP, OBS-owned broadcast controls, and Mimir-owned calibration/config/status. - `config/perfect-machine.example.json` declares the contract shape for six cameras, six microphones, reservoir typed views, native authorities, outputs, and the demotion of bridge scripts to tooling. - `native/reservoir` is the first native Rust crate. It implements one shared-edge five-second rolling buffer with typed views and proves that @@ -51,12 +69,12 @@ shape removes independent per-kind storage from the live foundation. - Sender device discovery script. - Sender launch script with dry-run mode and per-source FFmpeg commands. - OBS receiver setup notes. -- Neighbor sender deployment under `C:\Meta\LocalCastBridge`. +- Neighbor sender deployment under `C:\Meta\Mimir`. - Madman's desktop start/stop launchers for the sender. - Receiver OBS scene has `Neighbor PC - Video` and `Neighbor PC - Focusrite` Media Sources. - Direct co-streamer loopback capture is now attempted with the repo WASAPI shim instead of Voicemeeter. - Receiver OBS scene has `Neighbor PC - System Audio` on SRT port `5102`. -- Sender script uses `-nostdin` and repo-root logs so desktop launchers stay alive and diagnostics land in `C:\Meta\LocalCastBridge\logs`. +- Sender script uses `-nostdin` and repo-root logs so desktop launchers stay alive and diagnostics land in `C:\Meta\Mimir\logs`. - Sender video config is `1920x1080` for the interactive desktop. The `1024x768` SSH session size is not the streaming target. - Desktop `.cmd` launchers delegate to PowerShell wrappers so paths with spaces and apostrophes are handled in one place. - Audio-field sidecar profile and tool: @@ -94,11 +112,19 @@ shape removes independent per-kind storage from the live foundation. - `docs/typed-visual-state.md` documents the diagnostic CultCache file shape and the CultNet visual schema target. - OBS synchronized program surface: + - `scripts/obs_smoke_test.py` creates the v1 OBS smoke-test endpoint plan, + event template, and witness ledger. + - `localcast.obs_smoke` owns the pure endpoint planning and ledger summary + logic: per-stage timestamps, matched end-to-end latency, endpoint drift, and + confidence. + - `docs/obs-v1-smoke-test.md` is the runbook for collecting + `sender_capture`, `srt_receive`, and `obs_present` evidence before receiver + expansion. - `scripts/setup_obs_synced_program.py` derives OBS-controllable stems from an aligned program audio timeline: host voice, co-streamer voice, ambient, transients, co-streamer loopback, and local loopback. - `scripts/capture_co_streamer_surfaces.py` captures neighbor Focusrite and neighbor loopback with local loopback ground truth, estimates the late remote-family offset, and writes aligned co-streamer surfaces for the stem packer. - `scripts/wasapi-loopback-capture.ps1` is the direct primary-playback loopback path. It must run in the neighbor's interactive console session; SSH-only capture sees the device but receives no render packets. - The tool creates/updates local OBS Media Sources for those stems and mutes/disables raw unsynchronized inputs. - - Strict mode disables every scene item except the synchronized LocalCastBridge program video and stem controls. + - Strict mode disables every scene item except the synchronized Mimir program video and stem controls. - Live phase-field meaning: - `audio_field.phase_meaning` extracts actionable delay, coherence, confidence, suppression, correction-energy, and active-probe need from internal phase/chirplet evidence. - `localcast.audio.phase_field` is a typed CultCache document at `calibration/runs/audio-phase-field.msgpack`. @@ -116,6 +142,8 @@ shape removes independent per-kind storage from the live foundation. ## Temporary - Audio and video are separate SRT endpoints. This preserves OBS mixing authority but may need latency tuning. +- The OBS v1 smoke ledger currently owns the receiver expansion gate. Until a + real run passes, endpoint coherence is unknown rather than assumed. - 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. @@ -151,3 +179,5 @@ shape removes independent per-kind storage from the live foundation. spatialization, and stem generation into Faust/native DSP. 6. Keep FFmpeg/SRT scripts as simple LAN ingest/capture utilities, not the synchronized program authority. +7. Run the v1 OBS smoke test against the actual sender/receiver setup and keep + the ledger as evidence before any receiver/plugin/native OBS expansion. diff --git a/docs/mimir-face.md b/docs/mimir-face.md index c5169b3..3d981ea 100644 --- a/docs/mimir-face.md +++ b/docs/mimir-face.md @@ -1,4 +1,4 @@ -# Mimir Face +# Mimir Face ![Mimir avatar](../assets/branding/mimir-avatar-256.png) @@ -39,10 +39,10 @@ Mimir must not: ## Prime Memory -Mimir's public name is Mimir. The implementation still contains -`LocalCastBridge` and `localcast` names where they are already part of scripts, -ABIs, paths, and compatibility surfaces. Rename public-facing prose first. -Rename code only when the ownership boundary is clear. +Mimir's public name is Mimir. The implementation still contains `localcast` +names where they are already part of scripts, schemas, ABIs, paths, and +compatibility surfaces. Rename public-facing prose first. Rename code only when +the ownership boundary is clear. Mimir exists to turn cameras, microphones, speakers, timing signals, and distributed capture surfaces into one realtime coherent volumetric field. The @@ -156,7 +156,7 @@ a monitoring daemon. These are the lessons Mimir should remember at birth: -- The repo began as LocalCastBridge, a LAN OBS bridge for a second Windows PC. +- The repo began as a LAN OBS bridge for a second Windows PC. That scaffold is still useful, but it is no longer the product story. - The public brand is now Mimir: local sensors, coherent field. - The finished 256px avatar is `assets/branding/mimir-avatar-256.png`; the root diff --git a/docs/native-rebuild-plan.md b/docs/native-rebuild-plan.md index cacd8e5..c4a5745 100644 --- a/docs/native-rebuild-plan.md +++ b/docs/native-rebuild-plan.md @@ -1,4 +1,4 @@ -# Native Rebuild Plan +# Native Rebuild Plan This is the cut line after the architectural teardown. @@ -14,7 +14,7 @@ to extend the deadline bridge. Make the second PC available in OBS as synchronized program video plus separately controllable audio while preserving the real ownership model: -- LocalCastBridge owns config, calibration, contract tests, launch, status, and +- Mimir owns config, calibration, contract tests, launch, status, and CultNet schema. - One native runtime owns live sample timing and retention. - Aquarium owns GPU visual fusion, material/brush/splat rendering, and Spout2 diff --git a/docs/neighbor-sender-setup.md b/docs/neighbor-sender-setup.md index 30ddf38..8c7465b 100644 --- a/docs/neighbor-sender-setup.md +++ b/docs/neighbor-sender-setup.md @@ -1,10 +1,10 @@ -# Neighbor Sender Setup +# Neighbor Sender Setup ## Host - Sender: `192.168.1.84` / `DESKTOP-M9FTRLL` - SSH: `ssh "madman's lullaby@192.168.1.84"` -- Install root: `C:\Meta\LocalCastBridge` +- Install root: `C:\Meta\Mimir` - Receiver OBS workstation: `192.168.1.66` ## Installed State @@ -25,7 +25,7 @@ Live local config on the sender: ```text -C:\Meta\LocalCastBridge\config\localcast.json +C:\Meta\Mimir\config\localcast.json ``` Current endpoints: @@ -66,8 +66,8 @@ The pre-edit backup is next to the scene file with a Madman's desktop has: ```text -C:\Users\Madman's Lullaby\Desktop\Start LocalCast Sender.cmd -C:\Users\Madman's Lullaby\Desktop\Stop LocalCast Sender.cmd +C:\Users\Madman's Lullaby\Desktop\Start Mimir Sender.cmd +C:\Users\Madman's Lullaby\Desktop\Stop Mimir Sender.cmd ``` Start launcher: @@ -79,7 +79,7 @@ Start launcher: winget alias is not reliably available in interactive `cmd.exe` - sets Windows default render to `Focusrite USB Audio\Device\Speakers\Render` - sets Windows default capture to `Focusrite USB Audio\Device\Analogue 1 + 2\Capture` -- writes FFmpeg logs under `C:\Meta\LocalCastBridge\logs` +- writes FFmpeg logs under `C:\Meta\Mimir\logs` Stop launcher: @@ -121,7 +121,7 @@ can fail with `Failed to capture image (error 5)`. The sender video process must be launched from Madman's interactive desktop, normally through: ```text -C:\Users\Madman's Lullaby\Desktop\Start LocalCast Sender.cmd +C:\Users\Madman's Lullaby\Desktop\Start Mimir Sender.cmd ``` Attached SSH probes are still useful for audio devices and SRT reachability. diff --git a/docs/obs-receiver-setup.md b/docs/obs-receiver-setup.md index f1c79fb..8c61f57 100644 --- a/docs/obs-receiver-setup.md +++ b/docs/obs-receiver-setup.md @@ -28,6 +28,22 @@ If using a passphrase, append: 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. +## V1 Smoke Gate + +Before adding receiver plugins, native OBS surfaces, or new bridge elegance, run +the bounded witness ledger in `docs/obs-v1-smoke-test.md`. + +The required artifact is: + +```text +calibration/runs/obs-v1-smoke-ledger.json +``` + +It must include `sender_capture`, `srt_receive`, and `obs_present` evidence for +every planned endpoint, plus endpoint drift, end-to-end latency, and confidence. +If the ledger fails, fix the clocks before expanding the receiver. The field may +be charming while lying. Still a liar. + ## Synchronization The video SRT source is a timed media artifact in the Aquarium path, not just diff --git a/docs/obs-spout-streaming.md b/docs/obs-spout-streaming.md index 60efc53..8f8defb 100644 --- a/docs/obs-spout-streaming.md +++ b/docs/obs-spout-streaming.md @@ -1,10 +1,10 @@ -# OBS Spout Streaming +# OBS Spout Streaming ## Current Status The Python/OpenGL Spout sender has been deleted. -LocalCastBridge no longer carries a runnable OpenGL publication path. The only +Mimir no longer carries a runnable OpenGL publication path. The only surviving pieces are diagnostic packet/brush math in `localcast.diagnostics.render_math` and typed diagnostic visual documents in `localcast.diagnostics.visual_cache`. @@ -25,7 +25,7 @@ native rolling reservoir ## Invariants - OBS receives a named Spout2 texture from Aquarium, not Python/OpenGL. -- LocalCastBridge may expose diagnostic typed documents and CPU render math, but +- Mimir may expose diagnostic typed documents and CPU render math, but it does not own the production renderer. - JSON remains a diagnostic/status surface only. - The neighbor SRT video feed is a timed media artifact and must share the same diff --git a/docs/obs-v1-smoke-test.md b/docs/obs-v1-smoke-test.md new file mode 100644 index 0000000..8ed4369 --- /dev/null +++ b/docs/obs-v1-smoke-test.md @@ -0,0 +1,85 @@ +# OBS V1 Smoke Test + +No plugin/native receiver work gets promoted until this ledger exists for the +plain FFmpeg/SRT OBS path. + +The smoke test is deliberately small: + +- `sender_capture`: sender-side flash/chirp or packet-origin timestamp. +- `srt_receive`: receiver-side stream arrival timestamp. +- `obs_present`: OBS preview/recording presentation timestamp. +- endpoint drift: media-time slope versus receiver monotonic time. +- end-to-end latency: matched `eventId` from `sender_capture` to `obs_present`. +- confidence: stage coverage, observation count, event confidence, latency, and drift. + +The output ledger is `calibration/runs/obs-v1-smoke-ledger.json`. A passing +ledger sets `gate.pluginOrNativeReceiverExpansionAllowed` to `true`. Anything +else means the bridge is still talking without enough clocks. Adorable, but no. + +## Run + +From the repo root: + +```powershell +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\scripts\obs_smoke_test.py --config .\config\localcast.json plan +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\scripts\obs_smoke_test.py --config .\config\localcast.json template-events +``` + +Edit `calibration/runs/obs-v1-smoke-events.jsonl` with real observations from +one bounded pass. Use the same `eventId` for the same visible flash or audible +chirp as it moves through stages. + +Then summarize: + +```powershell +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\scripts\obs_smoke_test.py --config .\config\localcast.json summarize +``` + +## Event Shape + +Each line is one JSON object: + +```json +{ + "endpoint": "video", + "stage": "obs_present", + "eventId": "video-flash-001", + "monotonicNs": 1000180000000, + "wallTimeNs": null, + "mediaTimeNs": 180000000, + "confidence": 0.9, + "note": "OBS recording frame containing flash" +} +``` + +Required stages are: + +- `sender_capture` +- `srt_receive` +- `obs_present` + +`monotonicNs` is mandatory because wall clocks lie casually. `wallTimeNs` is +optional human correlation only. `mediaTimeNs` is optional for individual events, +but drift confidence needs at least two receiver-side media-time observations. + +## Thresholds + +Defaults: + +- maximum absolute endpoint drift: `100 ppm` +- maximum end-to-end latency: `500 ms` + +Override them when running the summary: + +```powershell +& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' .\scripts\obs_smoke_test.py --config .\config\localcast.json --max-abs-drift-ppm 50 --max-latency-ms 350 summarize +``` + +## Interpretation + +`status: ok` means every planned endpoint has enough timestamp evidence for this +bounded pass. It does not prove the native reservoir is done. It proves the v1 +OBS bridge has a clocked witness. + +`status: fail` means stop bridge expansion and fix the missing or drifting +stage first. diff --git a/docs/perfect-machine.md b/docs/perfect-machine.md index e4dd621..afea7c4 100644 --- a/docs/perfect-machine.md +++ b/docs/perfect-machine.md @@ -1,4 +1,4 @@ -# Perfect Machine +# Perfect Machine ## Objective @@ -41,7 +41,7 @@ not the runtime API. brush/splat generation, final render target, and Spout publication. - Faust owns hot audio DSP: voice separation, room suppression, Ambisonic/HOA spatialization, and loopback/source stem generation. -- LocalCastBridge owns configuration, calibration, typed contracts, launch, +- Mimir owns configuration, calibration, typed contracts, launch, status, persistence, and compatibility surfaces. ## Native Runtime Shape diff --git a/docs/sensor-fusion-architecture.md b/docs/sensor-fusion-architecture.md index fc864cf..12ad966 100644 --- a/docs/sensor-fusion-architecture.md +++ b/docs/sensor-fusion-architecture.md @@ -1,4 +1,4 @@ -# Sensor Fusion Architecture +# Sensor Fusion Architecture ## Objective @@ -79,7 +79,7 @@ flowchart TD camera projection, brush lowering, and CPU rasterization. It has unit tests and does not require a GPU context. - `scripts/sensor_fusion.py` owns CLI composition for offline observation files. -- The Python/OpenGL Spout publisher has been deleted. No LocalCastBridge Python +- The Python/OpenGL Spout publisher has been deleted. No Mimir Python module owns OBS publication. - `config/sensor-fusion.example.json` owns the declarative rig shape: capture hints, intrinsics, extrinsics, latency, and fusion thresholds. @@ -137,12 +137,12 @@ OBS should see a named Spout sender, not a camera window, file tail, or desktop The intended production handoff is: ```text -LocalCastBridge TrackCache +Mimir TrackCache -> CultCache render-frame document -> Aquarium Engine typed runtime state -> GPU point/brush/splat buffer -> D3D render target --> Spout2 sender named "LocalCastBridge Point Cloud" +-> Spout2 sender named "Mimir Point Cloud" -> OBS Spout2 Capture source ``` diff --git a/docs/typed-visual-state.md b/docs/typed-visual-state.md index 49f95ed..f1a89a5 100644 --- a/docs/typed-visual-state.md +++ b/docs/typed-visual-state.md @@ -1,4 +1,4 @@ -# Diagnostic Typed Visual State +# Diagnostic Typed Visual State ## Objective @@ -87,7 +87,7 @@ channels before publication so the downstream accumulator can use Leap as the strongest timing/motion witness without pretending the packed image is one ordinary RGB camera. -LocalCastBridge does not own the final splat budget. It publishes dense typed +Mimir does not own the final splat budget. It publishes dense typed claims with stable keys and timestamps; Aquarium owns temporal accumulation, renderer residency, and any GPU-side reduction needed to make million-splat frames presentable. diff --git a/faust/localcast_voice_separation.dsp b/faust/localcast_voice_separation.dsp index cc238d5..1b54d2c 100644 --- a/faust/localcast_voice_separation.dsp +++ b/faust/localcast_voice_separation.dsp @@ -1,6 +1,6 @@ -declare name "LocalCastBridge Voice Separation"; +declare name "Mimir Voice Separation"; declare version "1.0"; -declare author "LocalCastBridge"; +declare author "Mimir"; declare license "MIT"; // Six aligned microphone inputs: diff --git a/localcast/__init__.py b/localcast/__init__.py index 06fcbb7..1172f26 100644 --- a/localcast/__init__.py +++ b/localcast/__init__.py @@ -1 +1 @@ -"""LocalCastBridge Python helpers.""" +"""Mimir Python helpers.""" diff --git a/localcast/diagnostics/render_math.py b/localcast/diagnostics/render_math.py index d32fe2c..0f4d7e9 100644 --- a/localcast/diagnostics/render_math.py +++ b/localcast/diagnostics/render_math.py @@ -12,7 +12,7 @@ from localcast.sensor_fusion.render_bridge import RenderFramePacket, RenderPoint @dataclass(frozen=True) class SpoutOutputConfig: - sender_name: str = "LocalCastBridge Point Cloud" + sender_name: str = "Mimir Point Cloud" width: int = 1920 height: int = 1080 fps: float = 60.0 diff --git a/localcast/diagnostics/spatial_audio.py b/localcast/diagnostics/spatial_audio.py index bc334e7..38c2af6 100644 --- a/localcast/diagnostics/spatial_audio.py +++ b/localcast/diagnostics/spatial_audio.py @@ -1,4 +1,4 @@ -import argparse +import argparse from pathlib import Path import sys import time @@ -68,7 +68,7 @@ def main() -> None: parser.add_argument("--input", type=Path, default=ROOT / "calibration" / "runs" / "audio-full-sync-20260518-165751" / "field-foa-ambix.wav") parser.add_argument("--cache", type=Path, default=ROOT / "calibration" / "runs" / "audio-state.msgpack") parser.add_argument("--status-cache", type=Path, default=ROOT / "calibration" / "runs" / "audio-stream-status.msgpack") - parser.add_argument("--stream-name", default="LocalCastBridge AmbiX") + parser.add_argument("--stream-name", default="Mimir AmbiX") parser.add_argument("--chunk-frames", type=int, default=1024) parser.add_argument("--duration", type=float) parser.add_argument("--loop", action="store_true") diff --git a/localcast/obs_smoke.py b/localcast/obs_smoke.py new file mode 100644 index 0000000..575b96c --- /dev/null +++ b/localcast/obs_smoke.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +from pathlib import Path +from statistics import median +from typing import Any, Iterable + + +NS_PER_MS = 1_000_000 +NS_PER_SECOND = 1_000_000_000 + + +@dataclass(frozen=True) +class EndpointPlan: + name: str + kind: str + port: int + listener_url: str + expected_latency_ms: float + + +@dataclass(frozen=True) +class SmokeEvent: + endpoint: str + stage: str + monotonic_ns: int + wall_time_ns: int | None = None + media_time_ns: int | None = None + event_id: str | None = None + confidence: float = 1.0 + note: str = "" + + +def load_config_endpoints(config_path: Path) -> list[EndpointPlan]: + config = json.loads(config_path.read_text(encoding="utf-8-sig")) + receiver = config.get("receiver", {}) + base_port = int(receiver.get("basePort", 5100)) + expected_latency_ms = float(receiver.get("srtLatencyMicros", 120000)) / 1000.0 + passphrase = str(receiver.get("passphrase", "") or "") + secret_suffix = "&passphrase=SET&pbkeylen=16" if passphrase else "" + + endpoints: list[EndpointPlan] = [] + video = config.get("video") or {} + if video.get("enabled", False): + port = base_port + int(video.get("portOffset", 0)) + endpoints.append( + EndpointPlan( + name="video", + kind="video", + port=port, + listener_url=f"srt://0.0.0.0:{port}?mode=listener&latency={int(expected_latency_ms * 1000)}&timeout=5000000{secret_suffix}", + expected_latency_ms=expected_latency_ms, + ) + ) + + for audio in config.get("audioSources", []) or []: + name = str(audio.get("name", "")).strip() + if not name: + continue + port = base_port + int(audio.get("portOffset", 0)) + endpoints.append( + EndpointPlan( + name=f"audio-{name}", + kind="audio", + port=port, + listener_url=f"srt://0.0.0.0:{port}?mode=listener&latency={int(expected_latency_ms * 1000)}&timeout=5000000{secret_suffix}", + expected_latency_ms=expected_latency_ms, + ) + ) + return endpoints + + +def event_from_json(raw: dict[str, Any]) -> SmokeEvent: + return SmokeEvent( + endpoint=str(raw["endpoint"]), + stage=str(raw["stage"]), + monotonic_ns=int(raw["monotonicNs"]), + wall_time_ns=None if raw.get("wallTimeNs") is None else int(raw["wallTimeNs"]), + media_time_ns=None if raw.get("mediaTimeNs") is None else int(raw["mediaTimeNs"]), + event_id=None if raw.get("eventId") is None else str(raw["eventId"]), + confidence=float(raw.get("confidence", 1.0)), + note=str(raw.get("note", "")), + ) + + +def read_events_jsonl(path: Path) -> list[SmokeEvent]: + events: list[SmokeEvent] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + try: + events.append(event_from_json(json.loads(line))) + except Exception as exc: # pragma: no cover - message quality path + raise ValueError(f"{path}:{line_number}: invalid smoke event: {exc}") from exc + return events + + +def write_event_template(path: Path, endpoints: Iterable[EndpointPlan]) -> None: + rows: list[dict[str, Any]] = [] + base = 1_000_000_000_000 + for index, endpoint in enumerate(endpoints): + event_id = f"{endpoint.name}-flash-001" if endpoint.kind == "video" else f"{endpoint.name}-chirp-001" + start = base + index * 10 * NS_PER_SECOND + rows.extend( + [ + { + "endpoint": endpoint.name, + "stage": "sender_capture", + "eventId": event_id, + "monotonicNs": start, + "wallTimeNs": None, + "mediaTimeNs": 0, + "confidence": 1.0, + "note": "Replace with sender-side flash/chirp capture timestamp.", + }, + { + "endpoint": endpoint.name, + "stage": "srt_receive", + "eventId": event_id, + "monotonicNs": start + int(endpoint.expected_latency_ms * NS_PER_MS), + "wallTimeNs": None, + "mediaTimeNs": int(endpoint.expected_latency_ms * NS_PER_MS), + "confidence": 0.8, + "note": "Replace with first packet or probe observation at receiver.", + }, + { + "endpoint": endpoint.name, + "stage": "obs_present", + "eventId": event_id, + "monotonicNs": start + int(endpoint.expected_latency_ms * NS_PER_MS * 1.5), + "wallTimeNs": None, + "mediaTimeNs": int(endpoint.expected_latency_ms * NS_PER_MS), + "confidence": 0.8, + "note": "Replace with OBS recording/preview observation.", + }, + ] + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in rows), encoding="utf-8") + + +def summarize_events( + endpoints: Iterable[EndpointPlan], + events: Iterable[SmokeEvent], + *, + max_abs_drift_ppm: float = 100.0, + max_latency_ms: float = 500.0, +) -> dict[str, Any]: + endpoint_plans = {endpoint.name: endpoint for endpoint in endpoints} + events_by_endpoint: dict[str, list[SmokeEvent]] = {name: [] for name in endpoint_plans} + for event in events: + events_by_endpoint.setdefault(event.endpoint, []).append(event) + + summaries = [] + for name, endpoint in endpoint_plans.items(): + rows = sorted(events_by_endpoint.get(name, []), key=lambda item: item.monotonic_ns) + stages = sorted({row.stage for row in rows}) + latencies_ms = _matched_latencies_ms(rows) + drift_ppm = _endpoint_drift_ppm(rows) + confidence = _endpoint_confidence( + rows, + latencies_ms=latencies_ms, + drift_ppm=drift_ppm, + max_abs_drift_ppm=max_abs_drift_ppm, + max_latency_ms=max_latency_ms, + ) + status = "ok" if confidence >= 0.8 else "suspect" if confidence >= 0.5 else "fail" + summaries.append( + { + "endpoint": name, + "kind": endpoint.kind, + "port": endpoint.port, + "listenerUrl": endpoint.listener_url, + "expectedLatencyMs": endpoint.expected_latency_ms, + "observedStages": stages, + "eventCount": len(rows), + "endToEndLatencyMs": _latency_summary(latencies_ms), + "endpointDriftPpm": drift_ppm, + "confidence": round(confidence, 3), + "status": status, + } + ) + + overall_confidence = min((item["confidence"] for item in summaries), default=0.0) + failed = [item["endpoint"] for item in summaries if item["status"] != "ok"] + return { + "schema": "gamecult.localcast.obs_v1_smoke_ledger.v1", + "createdWallTime": datetime.now(timezone.utc).isoformat(), + "gate": { + "pluginOrNativeReceiverExpansionAllowed": not failed, + "reason": "Every planned OBS-facing endpoint must have sender_capture, srt_receive, and obs_present evidence with bounded latency and drift.", + }, + "thresholds": { + "maxAbsDriftPpm": max_abs_drift_ppm, + "maxLatencyMs": max_latency_ms, + "requiredStages": ["sender_capture", "srt_receive", "obs_present"], + }, + "endpoints": summaries, + "overallConfidence": round(overall_confidence, 3), + "status": "ok" if not failed and summaries else "fail", + "blockedEndpoints": failed, + } + + +def _matched_latencies_ms(events: list[SmokeEvent]) -> list[float]: + by_id: dict[str, dict[str, SmokeEvent]] = {} + for event in events: + if not event.event_id: + continue + by_id.setdefault(event.event_id, {})[event.stage] = event + latencies: list[float] = [] + for stages in by_id.values(): + sender = stages.get("sender_capture") + observed = stages.get("obs_present") + if sender is None or observed is None: + continue + latencies.append((observed.monotonic_ns - sender.monotonic_ns) / NS_PER_MS) + return latencies + + +def _endpoint_drift_ppm(events: list[SmokeEvent]) -> float | None: + timed = [event for event in events if event.media_time_ns is not None and event.stage in {"srt_receive", "obs_present"}] + timed.sort(key=lambda item: item.monotonic_ns) + if len(timed) < 2: + return None + first = timed[0] + last = timed[-1] + wall_delta = last.monotonic_ns - first.monotonic_ns + media_delta = int(last.media_time_ns) - int(first.media_time_ns) + if wall_delta <= 0: + return None + return round(((media_delta - wall_delta) / wall_delta) * 1_000_000.0, 3) + + +def _latency_summary(latencies_ms: list[float]) -> dict[str, float | int | None]: + if not latencies_ms: + return {"count": 0, "median": None, "min": None, "max": None} + return { + "count": len(latencies_ms), + "median": round(median(latencies_ms), 3), + "min": round(min(latencies_ms), 3), + "max": round(max(latencies_ms), 3), + } + + +def _endpoint_confidence( + events: list[SmokeEvent], + *, + latencies_ms: list[float], + drift_ppm: float | None, + max_abs_drift_ppm: float, + max_latency_ms: float, +) -> float: + if not events: + return 0.0 + stages = {event.stage for event in events} + required = {"sender_capture", "srt_receive", "obs_present"} + stage_score = len(stages & required) / len(required) + event_score = min(1.0, len(events) / 6.0) + observation_score = max(0.0, min(1.0, sum(max(0.0, min(1.0, event.confidence)) for event in events) / len(events))) + latency_score = 0.0 + if latencies_ms: + worst = max(latencies_ms) + latency_score = max(0.0, min(1.0, 1.0 - (worst / max_latency_ms))) + drift_score = 0.0 + if drift_ppm is not None: + drift_score = max(0.0, min(1.0, 1.0 - (abs(drift_ppm) / max_abs_drift_ppm))) + return max(0.0, min(1.0, stage_score * 0.35 + event_score * 0.15 + observation_score * 0.15 + latency_score * 0.2 + drift_score * 0.15)) diff --git a/localcast/sensor_fusion/__init__.py b/localcast/sensor_fusion/__init__.py index 9d5cd50..069e293 100644 --- a/localcast/sensor_fusion/__init__.py +++ b/localcast/sensor_fusion/__init__.py @@ -1,4 +1,4 @@ -"""Sensor fusion spine for the LocalCastBridge spatial rig.""" +"""Sensor fusion spine for the Mimir spatial rig.""" from .core import ( CameraModel, diff --git a/localcast/sensor_fusion/render_bridge.py b/localcast/sensor_fusion/render_bridge.py index a541674..8bb3913 100644 --- a/localcast/sensor_fusion/render_bridge.py +++ b/localcast/sensor_fusion/render_bridge.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from dataclasses import dataclass @@ -9,7 +9,7 @@ from .core import TriangulatedPoint @dataclass(frozen=True) class RenderBridgeConfig: - spout_sender_name: str = "LocalCastBridge Point Cloud" + spout_sender_name: str = "Mimir Point Cloud" target_width: int = 1920 target_height: int = 1080 visual_delay_ns: int = 250_000_000 @@ -19,7 +19,7 @@ class RenderBridgeConfig: @staticmethod def from_dict(data: dict) -> "RenderBridgeConfig": return RenderBridgeConfig( - spout_sender_name=data.get("spout_sender_name", "LocalCastBridge Point Cloud"), + spout_sender_name=data.get("spout_sender_name", "Mimir Point Cloud"), target_width=int(data.get("target_width", 1920)), target_height=int(data.get("target_height", 1080)), visual_delay_ns=int(data.get("visual_delay_ns", 250_000_000)), @@ -98,7 +98,7 @@ class RenderFramePacket: source_time_max_ns=int(data.get("source_time_max_ns", 0)), present_time_ns=int(data.get("present_time_ns", 0)), audio_alignment_time_ns=int(data.get("audio_alignment_time_ns", 0)), - spout_sender_name=str(data.get("spout_sender_name", "LocalCastBridge Point Cloud")), + spout_sender_name=str(data.get("spout_sender_name", "Mimir Point Cloud")), target_width=int(data.get("target_width", 1920)), target_height=int(data.get("target_height", 1080)), points=tuple(RenderPointPacket.from_dict(item) for item in data.get("points", [])), diff --git a/native/README.md b/native/README.md index ae4a122..1b50e8f 100644 --- a/native/README.md +++ b/native/README.md @@ -1,8 +1,8 @@ -# Native Runtime +# Native Runtime This directory is for the hot path. -LocalCastBridge Python remains useful for calibration, launch, status, contract +Mimir Python remains useful for calibration, launch, status, contract tests, and offline analysis. It does not own dense live fusion or DSP. ## Crates diff --git a/native/reservoir/Cargo.toml b/native/reservoir/Cargo.toml index 3de6d90..999f468 100644 --- a/native/reservoir/Cargo.toml +++ b/native/reservoir/Cargo.toml @@ -1,8 +1,8 @@ -[package] +[package] name = "localcast-reservoir" version = "0.1.0" edition = "2024" -description = "Native five-second spatiotemporal reservoir core for LocalCastBridge" +description = "Native five-second spatiotemporal reservoir core for Mimir" [lib] crate-type = ["rlib", "cdylib", "staticlib"] diff --git a/notes/current-system-map.md b/notes/current-system-map.md index ca996ed..aa51e46 100644 --- a/notes/current-system-map.md +++ b/notes/current-system-map.md @@ -1,10 +1,23 @@ -# Current System Map +# Current System Map -LocalCastBridge is intentionally thin. The target live machine is not the +Mimir is the public Face and product name for this repo. `localcast` remains +the implementation surface where existing scripts, paths, schemas, and ABIs +still use it. + +The repo is intentionally thin. The target live machine is not the deadline bridge; it is the native rolling reservoir described in `docs/native-rebuild-plan.md`, `docs/perfect-machine.md`, and `config/perfect-machine.example.json`. +Mimir's persistent Face state is part of the operating machine: + +- `docs/mimir-face.md` owns the Face doctrine, jurisdiction, heartbeat behavior, + and inherited memory packet. +- `.voidbot/voice/identity.json` and `.voidbot/voice/mimir.png` are the + VoidBot-facing persona identity. +- `.voidbot/state/mimir.cc` is typed persistent Face memory. VoidBot is the + transport layer; Mimir owns continuity and repo-progress pressure. + ## Perfect Machine Target ```mermaid @@ -23,7 +36,7 @@ flowchart TD Ownership: -- LocalCastBridge owns configuration, calibration, contract tests, launch, status, and persistence. +- Mimir owns configuration, calibration, contract tests, launch, status, and persistence. - Aquarium owns dense visual fusion, material/brush/splat reconciliation, final render target, and Spout publication. - Faust owns hot audio DSP and program stem generation. - OBS owns broadcast controls. @@ -68,6 +81,13 @@ flowchart TD 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. +The next gate is not taste, it is evidence. `scripts/obs_smoke_test.py` and +`localcast.obs_smoke` now define the v1 OBS smoke ledger: +`calibration/runs/obs-v1-smoke-ledger.json`. Receiver/plugin/native expansion is +blocked until each planned endpoint has `sender_capture`, `srt_receive`, and +`obs_present` timestamp evidence, plus bounded drift, end-to-end latency, and +confidence. + ## Known Risks - Windows audio source names vary by driver and localization. @@ -118,7 +138,7 @@ Ownership: - Active probe optimization owns extra chirplet emission when confidence drops, bounded by level/spacing/audibility budget. `audio_field.active_probe` is the runtime join that converts phase-field confidence into emitted chirplet WAVs/manifests and playback. Probe WAVs rotate through fixed slots and the manifest rotates at a byte cap; calibration may be dense, but artifacts must stay bounded. - The camera/sensor-fusion pipeline may publish world poses later; it does not own audio clocks or channel timing. - OBS may ingest rendered output later; it is not the authority for the Ambisonic field. -- Aquarium/Faust owns hot voice separation after LocalCastBridge publishes `localcast.audio.mic_field`; LocalCastBridge owns alignment, timing, mic roles, graph id, and controls. +- Aquarium/Faust owns hot voice separation after Mimir publishes `localcast.audio.mic_field`; Mimir owns alignment, timing, mic roles, graph id, and controls. Invariant: distributed camera/Focusrite microphones must be aligned and resampled into one reference timeline before FOA encoding. Latency is allowed as bounded buffering, but cache depth must converge toward real-time. Speaker output chirplets are live telemetry; delay/SRO/phase state must update during runtime, not only during setup. Extra chirplets may be emitted automatically when confidence drops, but only under the active probe optimizer's budget. The local shielded cardioid and neighbor shotgun are the high-quality dialogue anchors; camera mics provide spatial/context evidence. @@ -196,3 +216,8 @@ Ownership: - Aquarium/Spout owns synchronized program video, not raw remote desktop capture. Invariant: raw SRT ingest, Desktop Audio, Mic/Aux, and other non-program scene items may exist for diagnostics, but they must be disabled or muted when broadcasting the synchronized program. Missing synchronized feeds should appear as explicit silent placeholders, not as live unsynchronized substitutes. The co-streamer Focusrite, co-streamer loopback, and remote video are the latest-arriving surface family; their measured delay should set the presentation buffer horizon instead of being patched in after the local field. Do not reintroduce Voicemeeter as the loopback authority without a concrete invariant; prefer direct WASAPI loopback or a sender FFmpeg build with native WASAPI input. + +Smoke-test invariant: a visible stream in OBS is not proof of coherence. The v1 +bridge must leave a witness ledger with per-stage timestamps, endpoint drift, +end-to-end latency, and confidence before the receiver surface grows new +machinery. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index e644239..d5f40b1 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1,6 +1,6 @@ -# Fresh Workspace Handoff +# Fresh Workspace Handoff -This is the short re-entry packet for `E:\Projects\LocalCastBridge`. +This is the short re-entry packet for `E:\Projects\Mimir`. Do not trust this file for the exact live HEAD. Exact branch, HEAD, and dirty state are volatile. Always ask git. @@ -19,6 +19,18 @@ Get-Content .\state\evidence.jsonl -Tail 8 ## Current Shape +- Public brand/Face is Mimir. Implementation surfaces still use `localcast` + until a deliberate package/schema rename cut exists. +- Mimir's Face doctrine lives in `docs/mimir-face.md`. +- Mimir's 256px avatar source is `Mimir.png`; project copy is + `assets/branding/mimir-avatar-256.png`; VoidBot persona copy is + `.voidbot/voice/mimir.png`. +- Mimir is registered in VoidBot's private repo-Face registry alongside Nibu and + Aqua. Repo-local Face identity/state live at `.voidbot/voice/identity.json` + and `.voidbot/state/mimir.cc`. +- Birth runner completed in plan mode under `.voidbot/birth`; the typed + repo-Face state has already been seeded with durable Mimir birth memories and + a heartbeat agency pressure. - 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. @@ -70,6 +82,11 @@ Get-Content .\state\evidence.jsonl -Tail 8 use the descriptor plus native point-buffer decoder by default. - Aquarium-Engine commit `3316895` exposes `CreateProducer(kind, sourceId)` so producer source hashing goes through native `localcast_producer_create_for_source`. +- Aquarium-Engine commit `0f610d0` makes native render packets a direct GPU + fusion input: `LocalCastNativeVisualFrameSource` exposes caller-owned + `LocalcastRenderPoint` buffers, `LocalCastRuntime` publishes that buffer + instead of managed seeds when present, and D3D12 uploads the native point bytes + for the LocalCast fusion compute shader. - Edge JSON is schema/diagnostic only. Runtime network/process data should remain typed CultNet documents. - Python live producers and diagnostic CultCache/JSON file adapters are diagnostics, not production foundations. The OpenGL Spout sink has been @@ -81,11 +98,18 @@ Get-Content .\state\evidence.jsonl -Tail 8 `LocalcastProducer`. - The AmbiX spatial-audio Python publisher is also quarantined as `localcast.diagnostics.spatial_audio`. -- Neighbor sender is deployed at `C:\Meta\LocalCastBridge` on `192.168.1.84`. -- Madman's desktop has `Start LocalCast Sender.cmd` and `Stop LocalCast Sender.cmd`. +- Neighbor sender is deployed at `C:\Meta\Mimir` on `192.168.1.84`. +- Madman's desktop has `Start Mimir Sender.cmd` and `Stop Mimir Sender.cmd`. - Receiver OBS scene collection has `Neighbor PC - Video`, `Neighbor PC - Focusrite`, and `Neighbor PC - System Audio` Media Sources added. +- V1 OBS bridge expansion is now gated by + `calibration/runs/obs-v1-smoke-ledger.json`. The harness is + `scripts/obs_smoke_test.py`, pure summary logic lives in + `localcast.obs_smoke`, and the runbook is `docs/obs-v1-smoke-test.md`. + A passing ledger needs `sender_capture`, `srt_receive`, and `obs_present` + evidence for every planned endpoint, with endpoint drift, end-to-end latency, + and confidence. - Sender uses SoundVolumeView for default device selection. Voicemeeter is no longer the loopback authority; co-streamer playback loopback uses the direct WASAPI shim scheduled into the neighbor console session. -- Sender script was patched after failed transmission: live FFmpeg commands now include `-nostdin`, logs go under `C:\Meta\LocalCastBridge\logs`, and interactive video capture size is `1920x1080`. +- Sender script was patched after failed transmission: live FFmpeg commands now include `-nostdin`, logs go under `C:\Meta\Mimir\logs`, and interactive video capture size is `1920x1080`. - Madman's desktop launchers call PowerShell wrappers: `scripts\start-localcast-desktop.ps1` and `scripts\stop-localcast-desktop.ps1`. - SSH-launched video capture fails with `gdigrab` error 5; test video from Madman's interactive desktop launcher. @@ -93,7 +117,9 @@ Get-Content .\state\evidence.jsonl -Tail 8 The useful next work is foundation surgery, not hardware feature expansion: -- bind Aquarium/Faust/native workers to the rolling-buffer `LocalcastRuntime` +- build native capture workers that fill typed audio/render buffers through + `LocalcastProducer` +- bind Faust/native audio workers to the rolling-buffer `LocalcastRuntime` - build native mic/loopback/phase producers over `LocalcastProducer`; do not restore Python audio live launchers - delete the remaining diagnostic `visual_producer` command once native/Aquarium @@ -103,9 +129,12 @@ The useful next work is foundation surgery, not hardware feature expansion: reviving the deleted Python/OpenGL publisher - route future visual/audio runtime work through Aquarium/Faust/native runtime plus typed CultNet docs - keep FFmpeg/SRT bridge scripts only as simple LAN ingest and capture utilities +- run the v1 OBS smoke-test witness ledger against the real sender/receiver + before adding any OBS plugin/native receiver expansion ## Immediate Re-entry Instruction Do not revive the Python/OpenGL bridge. Rehydrate, read -`docs/native-rebuild-plan.md`, then cut toward native workers, -Aquarium/Faust bindings, and typed CultNet boundaries. +`docs/native-rebuild-plan.md`, then run the v1 OBS smoke-test witness ledger +before receiver expansion. After that clock evidence exists, cut toward native +workers, Aquarium/Faust bindings, and typed CultNet boundaries. diff --git a/research/ambisonic-aquasynth/summary.md b/research/ambisonic-aquasynth/summary.md index d8c5449..9598201 100644 --- a/research/ambisonic-aquasynth/summary.md +++ b/research/ambisonic-aquasynth/summary.md @@ -1,4 +1,4 @@ -# Ambisonic AquaSynth Field: Distilled Research +# Ambisonic AquaSynth Field: Distilled Research ## Objective @@ -6,7 +6,7 @@ Consolidate four live microphones -- shielded cardioid, Deru shotgun, and two ca ## Current Mechanism -LocalCastBridge currently treats audio as independent OBS-ingestable endpoints: one FFmpeg process per audio source, one SRT port per source, and OBS owns the mix. That is coherent for manual OBS mixing, but it is the wrong ownership model for a single spatial field. An ambisonic bus needs clocked multichannel audio before spatial encoding, then effects, then decode/render. +Mimir currently treats audio as independent OBS-ingestable endpoints: one FFmpeg process per audio source, one SRT port per source, and OBS owns the mix. That is coherent for manual OBS mixing, but it is the wrong ownership model for a single spatial field. An ambisonic bus needs clocked multichannel audio before spatial encoding, then effects, then decode/render. ## Invariants diff --git a/research/live-adaptive-sync/summary.md b/research/live-adaptive-sync/summary.md index 2f5db91..20ff15e 100644 --- a/research/live-adaptive-sync/summary.md +++ b/research/live-adaptive-sync/summary.md @@ -1,4 +1,4 @@ -# Live Adaptive Sync: Distilled Research +# Live Adaptive Sync: Distilled Research ## Objective @@ -6,7 +6,7 @@ Find prior work that supports synchronizing independent live microphone feeds we ## Current Mechanism -LocalCastBridge currently launches separate FFmpeg capture processes and SRT endpoints. That preserves OBS mix control but does not give us one clocked multichannel stream. For Ambisonics, independent device clocks create two separate problems: +Mimir currently launches separate FFmpeg capture processes and SRT endpoints. That preserves OBS mix control but does not give us one clocked multichannel stream. For Ambisonics, independent device clocks create two separate problems: - initial offset: one stream starts late or has more device/buffer latency - sampling-rate offset: one device runs slightly faster/slower, so offset grows over time diff --git a/research/spout-obs-bridge/summary.md b/research/spout-obs-bridge/summary.md index b2faa99..91ee38c 100644 --- a/research/spout-obs-bridge/summary.md +++ b/research/spout-obs-bridge/summary.md @@ -2,6 +2,6 @@ Spout is the Windows-local GPU texture sharing bridge for this rig. The OBS path is the Off World Live OBS Spout2 plugin, which creates an OBS source from a Spout shared texture and can also send the OBS canvas back to Spout. The Spout2 SDK is the application-side sender/receiver surface; its modern path centers on DirectX shared textures and has native DirectX/OpenGL integration options. -Decision: LocalCastBridge should not render final pixels itself. It should publish typed render-frame packets from the fusion cache. Aquarium Engine should consume those packets, render them into a GPU render target, and publish that texture as a named Spout sender. OBS then receives that sender through the OBS Spout2 plugin. +Decision: Mimir should not render final pixels itself. It should publish typed render-frame packets from the fusion cache. Aquarium Engine should consume those packets, render them into a GPU render target, and publish that texture as a named Spout sender. OBS then receives that sender through the OBS Spout2 plugin. Latency decision: the render packet carries both source timestamps and intended presentation time. The stream compositor can buffer visual frames until they align with the ambisonic audio field's presentation timeline. Latency is allowed; unbounded drift is not. diff --git a/research/visual-spatial-map/brushstroke-fusion-rendering.md b/research/visual-spatial-map/brushstroke-fusion-rendering.md index 8451a26..e8bf4f9 100644 --- a/research/visual-spatial-map/brushstroke-fusion-rendering.md +++ b/research/visual-spatial-map/brushstroke-fusion-rendering.md @@ -1,4 +1,4 @@ -# Brushstroke Fusion Rendering Approach +# Brushstroke Fusion Rendering Approach ## Objective @@ -19,7 +19,7 @@ semantic intent -> renderer ``` -For LocalCastBridge, the equivalent is: +For Mimir, the equivalent is: ```text sensor observations diff --git a/research/visual-spatial-map/summary.md b/research/visual-spatial-map/summary.md index 07d7b3a..9222e15 100644 --- a/research/visual-spatial-map/summary.md +++ b/research/visual-spatial-map/summary.md @@ -1,8 +1,8 @@ -# Visual Spatial Map And Splat Scene +# Visual Spatial Map And Splat Scene ## Objective -Extend the AquaSynth/LocalCastBridge concept from "one camera view plus spatial audio" into a shared live spatial scene: +Extend the AquaSynth/Mimir concept from "one camera view plus spatial audio" into a shared live spatial scene: - two Razer Kiyo-class camera/mic rigs provide RGB and audio; one is Kiyo Pro - two PlayStation Eye / PS3 Eye cameras provide cheap high-rate low-resolution tracking views diff --git a/scripts/audio_field.py b/scripts/audio_field.py index 12687cb..3f0962c 100644 --- a/scripts/audio_field.py +++ b/scripts/audio_field.py @@ -1,4 +1,4 @@ -import argparse +import argparse import importlib.util import json import math @@ -2027,7 +2027,7 @@ def foa_weights(mic): def main(): - parser = argparse.ArgumentParser(description="Build and calibrate the LocalCastBridge six-mic Ambisonic audio field.") + parser = argparse.ArgumentParser(description="Build and calibrate the Mimir six-mic Ambisonic audio field.") sub = parser.add_subparsers(required=True) p = sub.add_parser("devices", help="List PortAudio devices as JSON.") @@ -2096,7 +2096,7 @@ def main(): p.add_argument("--mic-id", default="mic_focusrite_neighbor") p.add_argument("--ssh-target", default="madman's lullaby@192.168.1.84") p.add_argument("--ffmpeg", default=r"C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe") - p.add_argument("--remote-dir", default=r"C:\Meta\LocalCastBridge\calibration\remote-captures") + p.add_argument("--remote-dir", default=r"C:\Meta\Mimir\calibration\remote-captures") p.add_argument("--device", default="Analogue 1 + 2 (Focusrite USB Audio)") p.add_argument("--dshow-device", help=argparse.SUPPRESS) p.add_argument("--seconds", type=float, default=8.0) diff --git a/scripts/calibration_probe.py b/scripts/calibration_probe.py index 5e39865..2ad9809 100644 --- a/scripts/calibration_probe.py +++ b/scripts/calibration_probe.py @@ -1,4 +1,4 @@ -import argparse +import argparse import json import subprocess import sys @@ -321,7 +321,7 @@ def audio_smoke(args): def main(): - parser = argparse.ArgumentParser(description="Probe LocalCastBridge calibration devices.") + parser = argparse.ArgumentParser(description="Probe Mimir calibration devices.") sub = parser.add_subparsers(required=True) p = sub.add_parser("discover") diff --git a/scripts/capture_co_streamer_surfaces.py b/scripts/capture_co_streamer_surfaces.py index ecc999a..a1f4a58 100644 --- a/scripts/capture_co_streamer_surfaces.py +++ b/scripts/capture_co_streamer_surfaces.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import argparse import json @@ -29,11 +29,11 @@ def main() -> int: parser.add_argument("--sample-rate", type=int, default=48000) parser.add_argument("--ssh-target", default="madman's lullaby@192.168.1.84") parser.add_argument("--ffmpeg", default=r"C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe") - parser.add_argument("--remote-dir", default=r"C:\Meta\LocalCastBridge\calibration\remote-captures") + parser.add_argument("--remote-dir", default=r"C:\Meta\Mimir\calibration\remote-captures") parser.add_argument("--focusrite-device", default="Analogue 1 + 2 (Focusrite USB Audio)") parser.add_argument("--loopback-device", default="") parser.add_argument("--loopback-capture", choices=("wasapi", "dshow"), default="wasapi") - parser.add_argument("--remote-wasapi-script", default=r"C:\Meta\LocalCastBridge\scripts\wasapi-loopback-capture.ps1") + parser.add_argument("--remote-wasapi-script", default=r"C:\Meta\Mimir\scripts\wasapi-loopback-capture.ps1") parser.add_argument("--remote-wasapi-role", choices=("Console", "Multimedia", "Communications"), default="Console") parser.add_argument("--local-loopback-query", default="Scarlett") parser.add_argument("--max-lag-ms", type=float, default=3000.0) @@ -165,7 +165,7 @@ def remote_loopback_command(args: argparse.Namespace, output: str) -> str: raise SystemExit("--loopback-device is required when --loopback-capture dshow") return remote_ffmpeg_command(args, args.loopback_device, output, channels=2) log = output.rsplit(".", 1)[0] + ".log" - task = "LocalCastWasapiLoopbackCapture" + task = "MimirWasapiLoopbackCapture" return ( "powershell -NoProfile -ExecutionPolicy Bypass -Command " + quote_for_cmd( diff --git a/scripts/obs_smoke_test.py b/scripts/obs_smoke_test.py new file mode 100644 index 0000000..d99a0ce --- /dev/null +++ b/scripts/obs_smoke_test.py @@ -0,0 +1,62 @@ +import argparse +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from localcast.obs_smoke import load_config_endpoints, read_events_jsonl, summarize_events, write_event_template + + +def main() -> None: + parser = argparse.ArgumentParser(description="Create the bounded OBS v1 smoke-test witness ledger.") + parser.add_argument("--config", default="config/localcast.json") + parser.add_argument("--events", default="calibration/runs/obs-v1-smoke-events.jsonl") + parser.add_argument("--ledger", default="calibration/runs/obs-v1-smoke-ledger.json") + parser.add_argument("--max-abs-drift-ppm", type=float, default=100.0) + parser.add_argument("--max-latency-ms", type=float, default=500.0) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("plan", help="Print planned OBS-facing endpoints from the Mimir config.") + sub.add_parser("template-events", help="Write an editable JSONL event template for one smoke pass.") + sub.add_parser("summarize", help="Summarize recorded events into the smoke-test ledger.") + args = parser.parse_args() + + config_path = Path(args.config) + endpoints = load_config_endpoints(config_path) + + if args.command == "plan": + print( + json.dumps( + { + "config": str(config_path), + "endpoints": [endpoint.__dict__ for endpoint in endpoints], + "requiredStages": ["sender_capture", "srt_receive", "obs_present"], + }, + indent=2, + ) + ) + return + + events_path = Path(args.events) + if args.command == "template-events": + write_event_template(events_path, endpoints) + print(f"Wrote smoke event template: {events_path}") + return + + ledger_path = Path(args.ledger) + events = read_events_jsonl(events_path) + ledger = summarize_events( + endpoints, + events, + max_abs_drift_ppm=args.max_abs_drift_ppm, + max_latency_ms=args.max_latency_ms, + ) + ledger_path.parent.mkdir(parents=True, exist_ok=True) + ledger_path.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(ledger, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/sender-stop.ps1 b/scripts/sender-stop.ps1 index ff88911..fe297b4 100644 --- a/scripts/sender-stop.ps1 +++ b/scripts/sender-stop.ps1 @@ -1,4 +1,4 @@ -param( +param( [string]$LogDirectory = ".\logs" ) @@ -17,7 +17,7 @@ $ffmpegProcesses = Get-CimInstance Win32_Process | } if (-not $ffmpegProcesses) { - Write-Host "No LocalCastBridge-looking ffmpeg SRT processes found." + Write-Host "No Mimir-looking ffmpeg SRT processes found." return } diff --git a/scripts/setup_obs_synced_program.py b/scripts/setup_obs_synced_program.py index c5fe010..9a0dd1d 100644 --- a/scripts/setup_obs_synced_program.py +++ b/scripts/setup_obs_synced_program.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import argparse import json @@ -25,7 +25,7 @@ RAW_UNSYNCED_INPUTS = ( def main() -> int: parser = argparse.ArgumentParser( description=( - "Build synchronized LocalCastBridge OBS audio stems from the aligned " + "Build synchronized Mimir OBS audio stems from the aligned " "program timeline and hide raw unsynchronized OBS inputs." ) ) @@ -39,7 +39,7 @@ def main() -> int: parser.add_argument( "--disable-other-scene-items", action="store_true", - help="Disable every scene item except the synchronized LocalCastBridge program sources.", + help="Disable every scene item except the synchronized Mimir program sources.", ) args = parser.parse_args() @@ -99,7 +99,7 @@ def build_stems(run: Path, stem_dir: Path) -> dict[str, Any]: stems = [ stem_spec( - "LocalCastBridge - Host Voice", + "Mimir - Host Voice", "host_voice.wav", host_voice, field_rate, @@ -108,7 +108,7 @@ def build_stems(run: Path, stem_dir: Path) -> dict[str, Any]: position_m=[0.0, 0.0, 1.2], ), stem_spec( - "LocalCastBridge - CoStreamer Voice", + "Mimir - CoStreamer Voice", "co_streamer_voice.wav", co_streamer_voice, field_rate, @@ -117,7 +117,7 @@ def build_stems(run: Path, stem_dir: Path) -> dict[str, Any]: position_m=[0.0, 2.0, 1.2], ), stem_spec( - "LocalCastBridge - Ambient", + "Mimir - Ambient", "ambient.wav", ambient, field_rate, @@ -126,7 +126,7 @@ def build_stems(run: Path, stem_dir: Path) -> dict[str, Any]: position_m=None, ), stem_spec( - "LocalCastBridge - Transients", + "Mimir - Transients", "transients.wav", transients, field_rate, @@ -135,7 +135,7 @@ def build_stems(run: Path, stem_dir: Path) -> dict[str, Any]: position_m=None, ), stem_spec( - "LocalCastBridge - CoStreamer Loopback", + "Mimir - CoStreamer Loopback", "co_streamer_loopback.wav", co_streamer_loopback, field_rate, @@ -144,7 +144,7 @@ def build_stems(run: Path, stem_dir: Path) -> dict[str, Any]: position_m=None, ), stem_spec( - "LocalCastBridge - Local Loopback", + "Mimir - Local Loopback", "local_loopback.wav", local_loopback, field_rate, @@ -217,7 +217,7 @@ def configure_obs( mute_input(client, raw_name) disable_scene_item(client, scene, scene_items, raw_name) - allowed_scene_items = {"LocalCastBridge Point Cloud"} | { + allowed_scene_items = {"Mimir Point Cloud"} | { str(stem["obs_name"]) for stem in stem_manifest["stems"] } diff --git a/scripts/start-localcast-desktop.ps1 b/scripts/start-localcast-desktop.ps1 index 05a4b2e..8b86fe3 100644 --- a/scripts/start-localcast-desktop.ps1 +++ b/scripts/start-localcast-desktop.ps1 @@ -1,6 +1,6 @@ -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" -$root = "C:\Meta\LocalCastBridge" +$root = Split-Path -Parent (Split-Path -Parent $PSCommandPath) $soundVolumeView = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\SoundVolumeView.exe" $ffmpeg = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe" $config = Join-Path $root "config\localcast.json" diff --git a/scripts/stop-localcast-desktop.ps1 b/scripts/stop-localcast-desktop.ps1 index a115874..a81a219 100644 --- a/scripts/stop-localcast-desktop.ps1 +++ b/scripts/stop-localcast-desktop.ps1 @@ -1,6 +1,6 @@ -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" -$root = "C:\Meta\LocalCastBridge" +$root = Split-Path -Parent (Split-Path -Parent $PSCommandPath) $soundVolumeView = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\SoundVolumeView.exe" $senderStop = Join-Path $root "scripts\sender-stop.ps1" @@ -16,4 +16,3 @@ if (Test-Path -LiteralPath $soundVolumeView) { } Get-Process voicemeeter_x64 -ErrorAction SilentlyContinue | Stop-Process -Force - diff --git a/scripts/wasapi-loopback-capture.ps1 b/scripts/wasapi-loopback-capture.ps1 index 290abb9..2ac7a66 100644 --- a/scripts/wasapi-loopback-capture.ps1 +++ b/scripts/wasapi-loopback-capture.ps1 @@ -15,7 +15,7 @@ using System.IO; using System.Runtime.InteropServices; using System.Threading; -namespace LocalCastBridge { +namespace Mimir { public static class WasapiLoopbackCapture { const int eRender = 0; const int eConsole = 0; @@ -277,4 +277,4 @@ namespace LocalCastBridge { "@ Add-Type -TypeDefinition $source -[LocalCastBridge.WasapiLoopbackCapture]::Capture($Output, $Seconds, $SampleRate, $Channels, $Role) +[Mimir.WasapiLoopbackCapture]::Capture($Output, $Seconds, $SampleRate, $Channels, $Role) diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 86d9d97..e34958d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -93,3 +93,5 @@ {"ts": "2026-05-19T12:23:05+00:00", "type": "implementation", "status": "ok", "note": "Mimir is wired as a VoidBot repo Face: private VoidBot registry includes identity mimir; repo-local .voidbot voice/state files exist; .voidbot/state/mimir.cc is seeded through typed state operations with birth memories for identity, native reservoir, ownership boundaries, audio-field discipline, and heartbeat pressure."} {"ts": "2026-05-19T12:28:02+00:00", "type": "verification", "status": "ok", "note": "VoidBot repo-face heartbeat dry-run reconciled Mimir as an active participant (identity:mimir, repo:LocalCastBridge) in the shared initiative scheduler; dry run queued Void this tick, not Mimir."} {"ts": "2026-05-19T13:16:35+00:00", "type": "decision", "status": "ok", "note": "Mimir Face voice corrected from dry technical steward to mythological being first: Norse well-memory, lost eye, severed voice, roots/ravens/runes, trusted witnesses, and contempt for false coherence now belong to docs/mimir-face.md plus .voidbot Face state. Job jurisdiction remains realtime field stewardship, but personal values now explain why the work matters."} +{"ts": "2026-05-21T09:18:48+00:00", "type": "decision", "status": "ok", "note": "V1 OBS receiver expansion is now gated on calibration/runs/obs-v1-smoke-ledger.json: scripts/obs_smoke_test.py and localcast.obs_smoke require sender_capture, srt_receive, and obs_present evidence per endpoint, with drift, end-to-end latency, and confidence before plugin/native receiver work proceeds."} +{"ts": "2026-05-21T09:26:02+00:00", "type": "implementation", "status": "ok", "note": "Repository content now presents the public project as Mimir: prose/docs/config examples/OBS source names/default Spout sender/Faust metadata/tool descriptions migrated from LocalCastBridge branding, while lowercase localcast package/schema/ABI contracts remain until a deliberate code rename cut."} diff --git a/state/map.yaml b/state/map.yaml index 9b56efb..e3c019b 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -1,8 +1,8 @@ -workspace: +workspace: name: Mimir status: active focus: perfect-machine-native-reservoir - implementation_name: LocalCastBridge / localcast + implementation_name: Mimir public surface / localcast implementation contracts premise: > Mimir is the public name for the local realtime field machine: a roomful of cameras, microphones, speakers, and timing signals becomes one coherent @@ -46,10 +46,15 @@ invariants: - Python and JSON may diagnose or test the machine, not run it. accepted_design: + - `docs/mimir-face.md`, `.voidbot/voice/identity.json`, and + `.voidbot/state/mimir.cc` define Mimir as this repo's persistent Face: + VoidBot supplies the Discord mouth/scheduler/heartbeat transport, while + Mimir owns repo-progress heraldry, public story continuity, and memory + stewardship. - `docs/native-rebuild-plan.md` is the current cut plan. It supersedes the deadline Python/OpenGL bridge as a foundation and defines the next coherent rebuild target. - - `docs/perfect-machine.md` and `config/perfect-machine.example.json` define the native target: one five-second spatiotemporal reservoir, Aquarium GPU fusion/rendering, Faust DSP, OBS output controls, and LocalCastBridge tooling. + - `docs/perfect-machine.md` and `config/perfect-machine.example.json` define the native target: one five-second spatiotemporal reservoir, Aquarium GPU fusion/rendering, Faust DSP, OBS output controls, and Mimir tooling. - `native/reservoir` is the first native Rust crate. It now uses one time-ordered rolling buffer with typed views, so retention has exactly one owner while Aquarium/Faust still get kind-scoped queries. @@ -69,10 +74,22 @@ accepted_design: - `LocalcastRenderPoint` is the first native point-buffer element ABI, kept small to replace file-backed render-frame transport before richer material and brush descriptors exist. + - Aquarium-Engine commit `0f610d0` wires native render packets into the GPU + fusion path without managed point-list materialization: the native source + exposes caller-owned `LocalcastRenderPoint` memory as an + `AquariumGpuFusionPointBuffer`, D3D12 uploads those bytes into a structured + buffer, and the LocalCast fusion compute shader lowers points into temporal + gaussians on GPU. - `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`. + - `scripts/obs_smoke_test.py`, `localcast.obs_smoke`, and + `docs/obs-v1-smoke-test.md` define the v1 OBS smoke-test witness ledger. + The ledger must record `sender_capture`, `srt_receive`, and `obs_present` + timestamp evidence for every planned endpoint, summarize endpoint drift and + end-to-end latency, and emit confidence before receiver/plugin/native + expansion is justified. - Persistence follows the active repo pattern: map, scratch, evidence, branches, handoff, system map, implementation plan, state helpers. active_subgoals: @@ -88,7 +105,9 @@ 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. + - Run the v1 OBS smoke-test witness ledger against the actual sender/receiver + setup; no OBS source generator or plugin investigation is justified until + the ledger passes. - Evaluate the Ambisonic AquaSynth path as a separate synchronized multichannel audio pipeline; do not treat heterogeneous mic feeds as raw first-order ambisonic A-format without geometry, calibration, and a shared clock. - Build the six-microphone/two-speaker audio-field sidecar around the actual distributed camera/Focusrite clocks, explicit geometry, speaker calibration, bounded buffering, delay/SRO alignment, testable pipeline seams, and FOA AmbiX output. - Build the visual sensor-fusion sidecar around timestamped observations, calibrated sensor models, bounded track caches, sparse point-cloud output, and later brush/splat packet lowering. @@ -157,7 +176,7 @@ research: bibliography: research/spout-obs-bridge/bibliography.md key_decision: > OBS should receive a named Spout2 shared texture from Aquarium Engine, - not desktop capture or file polling. LocalCastBridge emits typed + not desktop capture or file polling. Mimir emits typed render-frame packets with source timestamps, intended presentation time, audio-alignment time, target size, Spout sender name, and point/brush claims. The deadline Spout sink now defaults to a `kiyo-mid-deru` @@ -195,7 +214,7 @@ research: key_decision: > Voice separation should run in Aquarium's Faust DSP, not as Python hot audio math after the mic field has already collapsed to AmbiX. - LocalCastBridge now publishes `localcast.audio.mic_field` with six + Mimir now publishes `localcast.audio.mic_field` with six aligned mic channels, roles, controls, and graph id `localcast.faust.voice_separation.v1`; the matching Faust graph lives at `faust/localcast_voice_separation.dsp`. @@ -262,8 +281,18 @@ research: current_status: summary: > + Mimir is now the public brand and repo Face. The 256px repixelized avatar + lives at `Mimir.png`, is copied to + `assets/branding/mimir-avatar-256.png`, and is registered for the VoidBot + repo-Face pipe at `.voidbot/voice/mimir.png`. VoidBot's private + `repo-discord-identities.json` now includes Mimir beside Nibu and Aqua, and + `.voidbot/state/mimir.cc` has typed birth memories for identity, + native-reservoir target, ownership boundaries, audio-field discipline, and + heartbeat agency pressure. A forced VoidBot heartbeat dry-run reconciled + Mimir as an active `repo_face` participant in the shared initiative + scheduler. First repo scaffold is in place and deployed to the neighbor sender at - C:\Meta\LocalCastBridge. FFmpeg 8.1.1 with SRT and h264_nvenc is installed + C:\Meta\Mimir. FFmpeg 8.1.1 with SRT and h264_nvenc is installed there, direct WASAPI console-session capture now owns system-output loopback, Madman's desktop has start/stop launchers, the sender script has launcher-safe FFmpeg arguments and repo-root logs, desktop launchers use absolute tool paths, and the @@ -323,7 +352,7 @@ current_status: per-channel stems from the aligned program audio timeline, creates/updates OBS Media Sources for host voice, co-streamer voice, ambient, transients, co-streamer loopback, and local loopback, and can disable every scene item - except synchronized LocalCastBridge program surfaces. Co-streamer surface + except synchronized Mimir program surfaces. Co-streamer surface capture now records neighbor Focusrite and neighbor loopback while recording local loopback ground truth, then writes aligned co-streamer surface WAVs for the stem packer. Live phase-field meaning now writes @@ -359,9 +388,14 @@ current_status: priority so Aquarium can prefer real Leap evidence over RGB surfaces, stochastic transient hypotheses, and synthetic fusion support while still seeing lower-priority fallback diagnostics when the Leap capture path stalls. + The v1 OBS bridge now has a bounded smoke-test harness and runbook: + endpoint plans come from `config/localcast.json`, event templates collect + `sender_capture`/`srt_receive`/`obs_present` timestamps, and the summary + ledger records endpoint drift, matched end-to-end latency, confidence, and + whether plugin/native receiver expansion remains blocked. next_action: > - Stop live feature work on the Python bridge. The first native cut is - complete: `native/reservoir` is now a single time-ordered rolling buffer - with typed views and updated ABI/tests. Next bind Aquarium/Faust to the - native runtime, delete diagnostic Python producers, and resume hardware - calibration through typed CultNet/native surfaces only. + Run the v1 OBS smoke-test witness ledger against the actual sender/receiver + setup before any receiver/plugin/native expansion. After that clocked + evidence exists, resume the native worker cut: fill typed buffers through + `LocalcastProducer`, then delete the remaining diagnostic Python producer + surfaces. diff --git a/tests/test_obs_smoke.py b/tests/test_obs_smoke.py new file mode 100644 index 0000000..2d68a84 --- /dev/null +++ b/tests/test_obs_smoke.py @@ -0,0 +1,110 @@ +import json +import tempfile +from pathlib import Path +import unittest + +from localcast.obs_smoke import EndpointPlan, load_config_endpoints, summarize_events, write_event_template, read_events_jsonl + + +class ObsSmokeTests(unittest.TestCase): + def test_loads_endpoint_plan_from_localcast_config(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "localcast.json" + path.write_text( + json.dumps( + { + "receiver": {"basePort": 5100, "srtLatencyMicros": 120000}, + "video": {"enabled": True, "portOffset": 0}, + "audioSources": [{"name": "voice", "portOffset": 2}], + } + ), + encoding="utf-8", + ) + + endpoints = load_config_endpoints(path) + + self.assertEqual([item.name for item in endpoints], ["video", "audio-voice"]) + self.assertEqual(endpoints[0].port, 5100) + self.assertEqual(endpoints[1].listener_url, "srt://0.0.0.0:5102?mode=listener&latency=120000&timeout=5000000") + + def test_summarizes_latency_drift_and_confidence(self): + endpoint = EndpointPlan( + name="video", + kind="video", + port=5100, + listener_url="srt://0.0.0.0:5100?mode=listener&latency=120000&timeout=5000000", + expected_latency_ms=120.0, + ) + events = [ + {"endpoint": "video", "stage": "sender_capture", "eventId": "a", "monotonicNs": 1_000_000_000, "mediaTimeNs": 0}, + {"endpoint": "video", "stage": "srt_receive", "eventId": "a", "monotonicNs": 1_120_000_000, "mediaTimeNs": 120_000_000}, + {"endpoint": "video", "stage": "obs_present", "eventId": "a", "monotonicNs": 1_180_000_000, "mediaTimeNs": 180_000_000}, + {"endpoint": "video", "stage": "sender_capture", "eventId": "b", "monotonicNs": 2_000_000_000, "mediaTimeNs": 1_000_000_000}, + {"endpoint": "video", "stage": "srt_receive", "eventId": "b", "monotonicNs": 2_120_000_000, "mediaTimeNs": 1_120_000_000}, + {"endpoint": "video", "stage": "obs_present", "eventId": "b", "monotonicNs": 2_180_000_000, "mediaTimeNs": 1_180_000_000}, + ] + + ledger = summarize_events([endpoint], [read_events_jsonl_item(item) for item in events]) + summary = ledger["endpoints"][0] + + self.assertEqual(ledger["status"], "ok") + self.assertEqual(summary["status"], "ok") + self.assertEqual(summary["endToEndLatencyMs"]["median"], 180.0) + self.assertEqual(summary["endpointDriftPpm"], 0.0) + self.assertGreaterEqual(summary["confidence"], 0.8) + + def test_missing_obs_stage_blocks_gate(self): + endpoint = EndpointPlan( + name="audio-voice", + kind="audio", + port=5102, + listener_url="srt://0.0.0.0:5102?mode=listener", + expected_latency_ms=120.0, + ) + + ledger = summarize_events( + [endpoint], + [ + read_events_jsonl_item( + { + "endpoint": "audio-voice", + "stage": "sender_capture", + "eventId": "a", + "monotonicNs": 1_000_000_000, + } + ), + read_events_jsonl_item( + { + "endpoint": "audio-voice", + "stage": "srt_receive", + "eventId": "a", + "monotonicNs": 1_120_000_000, + } + ), + ], + ) + + self.assertEqual(ledger["status"], "fail") + self.assertEqual(ledger["blockedEndpoints"], ["audio-voice"]) + self.assertFalse(ledger["gate"]["pluginOrNativeReceiverExpansionAllowed"]) + + def test_writes_event_template_as_jsonl(self): + endpoint = EndpointPlan("video", "video", 5100, "srt://0.0.0.0:5100?mode=listener", 120.0) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "events.jsonl" + write_event_template(path, [endpoint]) + events = read_events_jsonl(path) + + self.assertEqual(len(events), 3) + self.assertEqual({event.stage for event in events}, {"sender_capture", "srt_receive", "obs_present"}) + + +def read_events_jsonl_item(item): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "events.jsonl" + path.write_text(json.dumps(item) + "\n", encoding="utf-8") + return read_events_jsonl(path)[0] + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/localcast_prepare_compaction.py b/tools/localcast_prepare_compaction.py index 88d90b9..abfa760 100644 --- a/tools/localcast_prepare_compaction.py +++ b/tools/localcast_prepare_compaction.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import argparse import json @@ -236,7 +236,7 @@ def render_report(findings: list[Finding], status: str, log: str, evidence: list } lines = [ - "LocalCastBridge pre-compaction persistence check", + "Mimir pre-compaction persistence check", f"Workspace: {ROOT}", f"Findings: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error", "", @@ -284,7 +284,7 @@ def render_report(findings: list[Finding], status: str, log: str, evidence: list def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Audit LocalCastBridge persistent state before imminent compaction." + description="Audit Mimir persistent state before imminent compaction." ) parser.add_argument( "--strict", diff --git a/tools/localcast_state.py b/tools/localcast_state.py index c3acb35..4b57257 100644 --- a/tools/localcast_state.py +++ b/tools/localcast_state.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import argparse import json @@ -144,7 +144,7 @@ def cmd_close_branch(args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Inspect and update LocalCastBridge persistence state files." + description="Inspect and update Mimir persistence state files." ) subparsers = parser.add_subparsers(dest="command", required=True)