From 4e3cc946c39a94a559fdf98205ae3c18c27300fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 18 May 2026 19:29:21 +0100 Subject: [PATCH] Force neighbor loopback routing --- docs/implementation-plan.md | 1 + docs/neighbor-sender-setup.md | 9 ++ notes/current-system-map.md | 1 + scripts/configure-voicemeeter-routing.ps1 | 110 ++++++++++++++++++++++ scripts/setup_obs_synced_program.py | 11 +++ scripts/start-localcast-desktop.ps1 | 9 +- state/map.yaml | 10 +- 7 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 scripts/configure-voicemeeter-routing.ps1 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 3ce0f4f..8983913 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -32,6 +32,7 @@ - OBS synchronized program surface: - `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/configure-voicemeeter-routing.ps1` is called by the neighbor desktop launcher to route Voicemeeter virtual inputs to B3 before FFmpeg starts. - 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. diff --git a/docs/neighbor-sender-setup.md b/docs/neighbor-sender-setup.md index 101686d..3801875 100644 --- a/docs/neighbor-sender-setup.md +++ b/docs/neighbor-sender-setup.md @@ -81,7 +81,11 @@ Start launcher: winget alias is not reliably available in interactive `cmd.exe` - sets Windows default render to `Voicemeeter VAIO3 Input` - sets Windows default capture to `Voicemeeter Out B3` +- runs `scripts\configure-voicemeeter-routing.ps1` to route Voicemeeter virtual + inputs to B3 and unmute output buses before FFmpeg starts - writes FFmpeg logs under `C:\Meta\LocalCastBridge\logs` + - Voicemeeter routing details are written to + `C:\Meta\LocalCastBridge\logs\voicemeeter-routing.log` Stop launcher: @@ -106,6 +110,11 @@ Voicemeeter Out B3 (VB-Audio Voicemeeter VAIO) LocalCastBridge uses that device as `system-loopback`. +The routing script can prove that Voicemeeter accepted the B3 route, but B3 +will still be silent when no app audio is actually playing into the default +render device. In that case the co-streamer Focusrite bleed remains the +alignment witness until real loopback audio appears. + Do not rename the loopback in config unless the replacement appears in: ```powershell diff --git a/notes/current-system-map.md b/notes/current-system-map.md index b23c44e..aad08d2 100644 --- a/notes/current-system-map.md +++ b/notes/current-system-map.md @@ -122,6 +122,7 @@ Ownership: - `scripts/setup_obs_synced_program.py` owns packaging aligned program audio into OBS-controllable stems and muting/disabling raw unsynchronized OBS inputs. - `scripts/capture_co_streamer_surfaces.py` owns the late-arriving neighbor surface import: it captures neighbor Focusrite and neighbor loopback over SSH while recording local loopback, estimates the remote-family offset, and writes aligned co-streamer surfaces for the OBS stem packer. +- `scripts/configure-voicemeeter-routing.ps1` owns the neighbor launcher-side route from Voicemeeter virtual inputs to B3, with a sender-side routing log. - OBS owns final source volume, track assignment, filters, and stream/record output. - Aquarium/Spout owns synchronized program video, not raw remote desktop capture. diff --git a/scripts/configure-voicemeeter-routing.ps1 b/scripts/configure-voicemeeter-routing.ps1 new file mode 100644 index 0000000..cee1a66 --- /dev/null +++ b/scripts/configure-voicemeeter-routing.ps1 @@ -0,0 +1,110 @@ +param( + [string]$VoicemeeterExe = "C:\Program Files (x86)\VB\Voicemeeter\voicemeeter_x64.exe", + [string]$VoicemeeterRemoteDll = "C:\Program Files (x86)\VB\Voicemeeter\VoicemeeterRemote64.dll", + [int[]]$CandidateStrips = @(7, 5, 6, 3, 4), + [int[]]$CandidateBuses = @(7, 6, 5, 4, 3, 2, 1, 0), + [string]$LogPath = "C:\Meta\LocalCastBridge\logs\voicemeeter-routing.log", + [switch]$AlsoMonitorA1 +) + +$ErrorActionPreference = "Stop" + +New-Item -ItemType Directory -Force (Split-Path -Parent $LogPath) | Out-Null +"$(Get-Date -Format o) configuring Voicemeeter routing" | Set-Content -LiteralPath $LogPath -Encoding UTF8 + +function Log-Line { + param([string]$Line) + $Line | Tee-Object -FilePath $LogPath -Append +} + +if (-not (Test-Path -LiteralPath $VoicemeeterRemoteDll)) { + throw "Voicemeeter Remote DLL not found: $VoicemeeterRemoteDll" +} + +if (-not (Get-Process -Name "voicemeeter_x64" -ErrorAction SilentlyContinue)) { + if (-not (Test-Path -LiteralPath $VoicemeeterExe)) { + throw "Voicemeeter executable not found: $VoicemeeterExe" + } + Start-Process -FilePath $VoicemeeterExe + Start-Sleep -Seconds 3 +} + +$escapedDll = $VoicemeeterRemoteDll.Replace("\", "\\") +$source = @" +using System; +using System.Runtime.InteropServices; + +public static class LocalCastVoicemeeterRemote { + [DllImport("$escapedDll", CallingConvention = CallingConvention.StdCall)] + public static extern int VBVMR_Login(); + + [DllImport("$escapedDll", CallingConvention = CallingConvention.StdCall)] + public static extern int VBVMR_Logout(); + + [DllImport("$escapedDll", CallingConvention = CallingConvention.StdCall)] + public static extern int VBVMR_SetParameterFloat( + [MarshalAs(UnmanagedType.LPStr)] string name, + float value + ); + + [DllImport("$escapedDll", CallingConvention = CallingConvention.StdCall)] + public static extern int VBVMR_GetVoicemeeterType(ref int voicemeeterType); +} +"@ + +Add-Type -TypeDefinition $source + +function Set-VMFloat { + param( + [string]$Name, + [float]$Value + ) + return [LocalCastVoicemeeterRemote]::VBVMR_SetParameterFloat($Name, $Value) +} + +$login = [LocalCastVoicemeeterRemote]::VBVMR_Login() +try { + if ($login -lt 0) { + throw "Voicemeeter Remote login failed with code $login" + } + Start-Sleep -Milliseconds 250 + + $voicemeeterType = 0 + [void][LocalCastVoicemeeterRemote]::VBVMR_GetVoicemeeterType([ref]$voicemeeterType) + Log-Line "Voicemeeter type: $voicemeeterType" + + $routed = @() + foreach ($strip in $CandidateStrips) { + $b3 = Set-VMFloat -Name "Strip[$strip].B3" -Value 1.0 + $mute = Set-VMFloat -Name "Strip[$strip].Mute" -Value 0.0 + Log-Line "Strip[$strip].B3 -> $b3" + Log-Line "Strip[$strip].Mute -> $mute" + if ($b3 -ge 0) { + $routed += "Strip[$strip].B3" + } + if ($AlsoMonitorA1) { + $a1 = Set-VMFloat -Name "Strip[$strip].A1" -Value 1.0 + Log-Line "Strip[$strip].A1 -> $a1" + } + if ($mute -ge 0) { + $gain = Set-VMFloat -Name "Strip[$strip].Gain" -Value 0.0 + Log-Line "Strip[$strip].Gain -> $gain" + } + } + + foreach ($bus in $CandidateBuses) { + $mute = Set-VMFloat -Name "Bus[$bus].Mute" -Value 0.0 + $gain = Set-VMFloat -Name "Bus[$bus].Gain" -Value 0.0 + Log-Line "Bus[$bus].Mute -> $mute" + Log-Line "Bus[$bus].Gain -> $gain" + } + + if ($routed.Count -eq 0) { + throw "No candidate Voicemeeter strips accepted B3 routing" + } + + Log-Line "Voicemeeter B3 routing enabled: $($routed -join ', ')" +} +finally { + [void][LocalCastVoicemeeterRemote]::VBVMR_Logout() +} diff --git a/scripts/setup_obs_synced_program.py b/scripts/setup_obs_synced_program.py index 9284b77..c5fe010 100644 --- a/scripts/setup_obs_synced_program.py +++ b/scripts/setup_obs_synced_program.py @@ -93,6 +93,8 @@ def build_stems(run: Path, stem_dir: Path) -> dict[str, Any]: channel_fallback=np.zeros((frame_count, 2), dtype=np.float32), placeholder_status="placeholder-silence-until-neighbor-loopback-is-captured-and-aligned", synced_status="synced-from-neighbor-loopback-surface", + low_signal_status="captured-but-low-confidence-neighbor-loopback-is-near-silent", + min_rms=1e-4, ) stems = [ @@ -342,15 +344,24 @@ def load_co_streamer_surface( channel_fallback: np.ndarray, placeholder_status: str, synced_status: str, + low_signal_status: str | None = None, + min_rms: float = 0.0, ) -> tuple[np.ndarray, str]: if not path.exists(): return channel_fallback, placeholder_status loaded = load_optional_program_audio(path, sample_rate, frame_count) if np.asarray(channel_fallback).ndim == 1 and loaded.ndim == 2: loaded = np.mean(loaded, axis=1, dtype=np.float32) + if min_rms > 0.0 and rms(loaded) < float(min_rms): + return loaded, low_signal_status or placeholder_status return loaded, synced_status +def rms(samples: np.ndarray) -> float: + samples = np.asarray(samples, dtype=np.float32) + return float(np.sqrt(np.mean(samples * samples))) if samples.size else 0.0 + + def limit_peak(samples: np.ndarray, target: float = 0.98) -> np.ndarray: peak = float(np.max(np.abs(samples))) if samples.size else 0.0 if peak <= target or peak <= 1e-9: diff --git a/scripts/start-localcast-desktop.ps1 b/scripts/start-localcast-desktop.ps1 index f39eba1..50e9027 100644 --- a/scripts/start-localcast-desktop.ps1 +++ b/scripts/start-localcast-desktop.ps1 @@ -6,11 +6,13 @@ $soundVolumeView = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Lin $ffmpeg = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe" $config = Join-Path $root "config\localcast.json" $senderStart = Join-Path $root "scripts\sender-start.ps1" +$voicemeeterRouting = Join-Path $root "scripts\configure-voicemeeter-routing.ps1" Set-Location $root if (Test-Path -LiteralPath $voicemeeter) { Start-Process -FilePath $voicemeeter + Start-Sleep -Seconds 2 } else { Write-Warning "Voicemeeter was not found at $voicemeeter" } @@ -24,5 +26,10 @@ if (Test-Path -LiteralPath $soundVolumeView) { Write-Warning "SoundVolumeView was not found at $soundVolumeView" } -& $senderStart -Config $config -FfmpegPath $ffmpeg +if (Test-Path -LiteralPath $voicemeeterRouting) { + & $voicemeeterRouting +} else { + Write-Warning "Voicemeeter routing script was not found at $voicemeeterRouting" +} +& $senderStart -Config $config -FfmpegPath $ffmpeg diff --git a/state/map.yaml b/state/map.yaml index 1177dea..e4e7fd1 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -212,7 +212,9 @@ current_status: except synchronized LocalCastBridge 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. Missing synchronized neighbor feeds are explicit + for the stem packer. The neighbor desktop launcher now runs Voicemeeter + Remote routing so virtual inputs are sent to B3 before sender FFmpeg starts. + Missing synchronized neighbor feeds are explicit silent placeholders, not raw unsynchronized substitutes. The live producer currently uses synthetic calibrated observations through the real `SensorRig.fuse` path until camera detector observations replace them. Unit tests inject synthetic cameras and frames so @@ -234,6 +236,6 @@ current_status: shotgun capture/transport path, replace placeholder mic/speaker geometry with measured world coordinates, then build the delay/SRO alignment stage that feeds the bounded field cache and emits aligned six-channel blocks for - FOA encoding. Fix the neighbor loopback routing so the remote loopback is a - real timing witness, then let the co-streamer surface family set the shared - presentation buffer horizon for audio stems, AmbiX, and remote video. + FOA encoding. Verify remote loopback again when actual neighbor app audio is + playing; if B3 remains silent with real app audio, replace the loopback + capture endpoint instead of widening OBS to unsynchronized raw sources.