Skip to content

test(probe): 30s Twitch scene-switch probe, zero PC audio#43

Merged
ClodoCapeo merged 12 commits into
mainfrom
forge/pulsar-twitch-scene-switch
Jun 8, 2026
Merged

test(probe): 30s Twitch scene-switch probe, zero PC audio#43
ClodoCapeo merged 12 commits into
mainfrom
forge/pulsar-twitch-scene-switch

Conversation

@ClodoCapeo
Copy link
Copy Markdown
Contributor

What

A new live probe scripts/probe-twitch-scene-switch.py: a 30s Twitch broadcast that performs a real mid-course scene change at ~t=15s (duration/2) and carries zero PC sound. Plus two silent, visually-distinct scene pages served locally (scripts/live-test/scene-a.html cobalt, scene-b.html crimson).

Reuses the obs-websocket v5 plumbing from probe-m6-live.py (PulsarProcess, Inbox.pump, request, vendor_call, redact).

Scene-switch implementation: real scene (a) with auto-fallback (b)

Investigated the headless scene model:

  • pulsar-frontend-stub creates one program scene at boot (Default); its scenes vector is hard-coded single-entry.
  • BUT obs-websocket CreateScene uses obs_canvas_scene_create on the main canvas → registers in libobs's global source table; SetCurrentProgramSceneAcquireSceneobs_get_source_by_name (global lookup, NOT the stub vector) finds it; the stub's obs_frontend_set_current_scene rebinds obs_set_output_source(0, scene) + emits SCENE_CHANGED → the new scene composites.

So the probe attempts a true two-scene switch first (path a): CreateScene(pulsar-scene-b) → put a distinct browser_source on each scene → go live on scene-a → SetCurrentProgramScene(scene-b) at duration/2, asserted via GetCurrentProgramScene before/after. If the build does not honour the program-scene flip, it auto-falls-back to a SetCaptureSource URL swap (path b) on a single scene — still an eye-visible content change, clearly labelled as a fallback in logs + run summary. --force-fallback forces (b).

Zero PC sound (impératif)

Enforced three ways:

  1. Spawn env: PULSAR_MIC_DEVICE_ID + PULSAR_PROCESS_AUDIO_NAME popped → mic/process sources never created; PULSAR_CAPTURE_WINDOW popped.
  2. browser_source created with reroute_audio=False (both scene pages are silent anyway — no Web Audio).
  3. The stub always creates PulsarDesktopAudio (channel 1, the literal "sound of the PC"): the probe SetInputMutes it, then enumerates GetInputList + GetInputMute and asserts no unmuted audio input remains before going live (fails closed otherwise).

Validation done

  • python -m py_compile clean on Python 3.11.9 (the CI interpreter).
  • Full module import OK (incl. websockets); config-error guards return exit 2 for missing key / missing exe / --duration < 4.
  • AST pass: no unused imports.
  • Not run live — no built pulsar.exe and no key in this environment, per the task. The live run is for Probe/Vigil.

What Vigil / Probe must verify at the real run

  • Path (a) actually fires headless (program scene flips and composites scene-b's browser_source) vs. silent fallback to (b). The log line [scene-switch] implementation = ... reports which ran.
  • The switch is visible on the Twitch VOD (cobalt → crimson) at ~t=15s.
  • Audio: confirm the encoded MP4/stream has a silent (or absent) audio track — ensure_silence asserts mute over the wire, but a run-time ffprobe of the recorded MP4 is the ground truth.
  • No orphan pulsar.exe; clean StopDestination/StopRecord/teardown.

Surface sensible

Touches secrets handling (Twitch stream key from env, redacted) — flagging for Bastion clearance per the merge gate. No new deps, no network-contract change, no src/** change (probe + static HTML only).

🤖 Generated with Claude Code

ClodoCapeo and others added 12 commits June 7, 2026 23:04
Add scripts/probe-twitch-scene-switch.py: a 30s live Twitch broadcast that
performs a real mid-course scene change at duration/2 (~t=15s) and carries a
silent AAC track (no desktop/mic/process audio).

Switch implementation prefers a true two-scene OBS switch (path a): CreateScene
+ SetCurrentProgramScene, viable headless because obs-websocket resolves scenes
via libobs-global name lookup and the frontend-stub rebinds output channel 0 on
set_current_scene. Auto-falls-back to a SetCaptureSource URL swap (path b) when
the headless build does not honour the program-scene flip; the chosen path is
asserted and reported.

Zero PC sound is enforced on three fronts: mic/process-audio env popped at spawn
(sources never created), browser_source created with reroute_audio=False, and
the frontend-stub's unconditional PulsarDesktopAudio loopback muted over the
wire with GetInputList/GetInputMute asserting no unmuted audio input remains
before going live.

Two dependency-free, silent scene pages (scene-a.html cobalt / scene-b.html
crimson) make the switch unmistakable on the VOD. Stream key read from
TWITCH_STREAM_KEY only, redacted in all logs.

Refs: probe-twitch-live.py, probe-m6-live.py (v5 plumbing reused)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
obs-qsv11, win-dshow, and win-dshow's virtualcam-module compile sources
that require the MSVC ATL component (atlbase.h / atlcomcli.h / atlstr.h).
ATL is present on the CI windows-2022 runner but absent on a dev box
without the "C++ ATL" workload, where it broke the local build.

The previous local unblock disabled those three plugins unconditionally,
which would have regressed CI build coverage if committed. Replace it
with a conditional gate:

- patches/0002 now wraps the three plugin registrations in
  if(PULSAR_HAVE_ATL) ... else (disabled stub). The option defaults ON,
  so CI -- which never passes the flag -- builds all three plugins
  exactly as upstream does. No coverage regression, no asymmetry.
- scripts/build-win.ps1 probes the toolchain via vswhere for the ATL
  headers under VC/Tools/MSVC/<ver>/atlmfc/include and passes
  -DPULSAR_HAVE_ATL=OFF only when they are missing, logging an explicit
  warning. The skip is driven by detection, not by a static patch that
  amputates upstream in all builds.

The upstream submodule stays at its pinned SHA; the gate lives entirely
in the replayed patch + build script. None of the three plugins are on
the headless browser_source live path (x264/nvenc encode + CEF browser
source), so the OFF branch has zero functional impact on the Pulsar
probe. Verified: local build (ATL absent) -> BUILD_SCRIPT_EXIT=0,
pulsar.exe + pulsar-browser.dll staged, qsv11/win-dshow absent from
rundir.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents the C1083 / missing ATL headers failure mode introduced by the
PULSAR_HAVE_ATL gate (commit d634d35, PR #43): symptom, diagnostic,
root cause, gate mechanics (Test-AtlAvailable + patch 0002), verification,
rollback, and optional ATL install for full local parity.

Adds a pointer in DEVELOPMENT.md Toolchain table and Troubleshooting
section so the next developer hitting the error is one click from the
runbook.

Refs #43

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Pulsar CI proved the build (pipeline.yml) but carried none of the
étage-0 merge-gate conformance checks (docs/rules/git.md §1,
docs/rules/security.md §Détection). Add a dedicated compliance.yml so a
PR cannot be merged while leaking a secret, shipping a high/critical
dependency CVE, drifting its lockfile, or breaking CODEOWNERS.

- secret-scan: trufflehog over git history + working tree (verified-only)
  plus detect-secrets against a new .secrets.baseline.
- deps-audit: npm audit --omit=dev --audit-level=high (deps scope = npm;
  the Python probe glue is dev-only, no lock to pip-audit).
- lockfile-check: npm ci --dry-run + post-resolution clean-tree assert,
  and a guard rejecting a stray yarn.lock.
- codeowners-check: structural validation of .github/CODEOWNERS.

Separate workflow from pipeline.yml on purpose: governance vs build,
all-ubuntu/cheap, independent red surface, one check per gate in
gh pr checks. No error-suppression toggle anywhere -- each job blocks.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A missing-ATL detection result on a CI runner is not the benign "dev box
without the C++ ATL component" case the warning was written for: the
windows-2022 runner ships ATL, so a negative there is a detection
false-negative or a broken runner image. The old Write-Warning let the
build silently drop to PULSAR_HAVE_ATL=OFF, amputating qsv11/virtualcam/
win-dshow from the binary while CI stayed green -- a thinner artefact
shipped under a green check.

Throw instead when GITHUB_ACTIONS/CI is set, turning the build red so the
gap is visible rather than masked. Local builds (no CI env) keep the
warning + skip -- the intended dev convenience documented in the ATL
runbook.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two real environment-specific failures surfaced by the gates' own first
run on the ubuntu runners (not reproducible on the win dev box):

- lockfile-check: @clodocapeo/pulsar-bundle pins os:win32/cpu:x64, so
  `npm ci --dry-run` aborts with EBADPLATFORM on ubuntu before checking
  lockfile sync. Add --force (same bypass npm-publish already uses); a
  genuine drift still fails distinctly (EUSAGE), so the sync guarantee
  is intact.

- secret-scan: detect-secrets flagged 12 heuristic matches absent from
  the empty baseline. All reviewed and confirmed NON-secrets: UI locale
  labels ("Server Password"), doc/test placeholder passwords
  ("dev-only-do-not-ship-this", fake-${Date.now()}), the password= token
  in this workflow's own grep, and a viewer-scoped localhost JWT test
  fixture in probe-m6-live.py. Record them in .secrets.baseline as the
  audited allowlist; any NEW finding beyond these still turns the gate
  red. TruffleHog --only-verified found zero live credentials.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`detect-secrets scan --baseline` rewrites the baseline file in place and
always refreshes the volatile `generated_at` timestamp, so the previous
`git diff --quiet -- .secrets.baseline` check failed on the timestamp
alone with zero new secrets -- a guaranteed false red on the secret-scan
gate. Compare only the `results` map (filename + hashed_secret) so the
gate fails strictly when a genuinely new secret-shaped finding appears,
never on timestamp/metadata churn. A real new finding still turns the PR
red; a removed baseline finding is surfaced as a warning to prune.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous commit reworded the comment block in compliance.yml that
documents the secret-scan mechanism. The detect-secrets "Secret Keyword"
plugin flags that meta-text (it talks about `hashed_secret` / secrets),
a known self-referential false positive already present in the audited
baseline. Editing the block shifted its line (104 -> 110) and changed
its content hash, so the committed baseline went stale and the
secret-scan gate correctly went red on a "new" finding.

This is NOT a leaked credential: the finding is the workflow file
describing how the gate hashes findings. Re-baseline so the audited
false positive matches the current file; no other finding changes, and
no finding is audited as a real secret. Re-scanning against the new
baseline yields zero new/removed findings (gate green).

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Résout le risque résiduel R1 flaggé par Bastion : ws@8.20.0 est
vulnérable (GHSA-58qx-3vcg-4xpx, modéré, CVSS 4.4), résolu en 8.20.1.
Bump de la résolution lockfile uniquement — les ranges ^8.18.0 des
packages workspace satisfont déjà 8.20.1, package.json inchangé.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the three structural build/CI decisions landed in PR #43:
ATL-conditional build-gate (PULSAR_HAVE_ATL, default ON, CI-hard throw),
the compliance.yml merge gate (secret-scan/deps-audit/lockfile/codeowners),
and the URL-swap scene-switch fallback. Carries the Bastion residual-risk
ledger (R1 resolved via ws@8.20.1, R2 npm-only deps-audit, R3 baseline
re-audit) and traces the headless multi-scene (CreateScene 204) limitation
as a deferred item. First ADR of the repo; sets the house ADR format.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vigil review of PR #43 delta (ws@8.20.1 bump + ADR-001) passed: ws bump is lockfile-only (3 lines, 8.20.0->8.20.1), package.json ranges ^8.18.0 unchanged, no leftover 8.20.0 in lock (R1 closed). ADR-001 format sound (sets house format), zero drift vs artefacts: 3.1 matches build-win.ps1 (PULSAR_HAVE_ATL default ON, CI throw), 3.2 matches compliance.yml four jobs + CODEOWNERS, 3.3 matches scene-a/scene-b.html URL-swap. R2/R3 carry guards, headless multi-scene deferred. Flip Status proposed->accepted, set Decided 2026-06-08.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live-broadcast probe called StartDestination ~3-4s after spawning
pulsar.exe and gave up on the first attempt. The frontend streaming
output is wired asynchronously after boot, so a fast probe can hit a
transient 'frontend streaming output unavailable' before the output
exists — a boot-ordering race, not a broadcast failure (the same
binary/key passes on rerun, proven by re-running run 27107906022).

Poll StartDestination for a bounded 20s budget, but ONLY while the
error is exactly that transient string. Any other error (bad key,
RTMP reject) still fails on the first attempt, and exhausting the
budget is a hard failure — no masking of a genuine defect.

Refs #43

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ClodoCapeo ClodoCapeo merged commit 7ec00b4 into main Jun 8, 2026
13 checks passed
@ClodoCapeo ClodoCapeo deleted the forge/pulsar-twitch-scene-switch branch June 8, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant