Skip to content

fix(msteams): a name the resolver cannot select is recovered structurally — witnessed stale live (#853, #797)#896

Draft
DmitriyG228 wants to merge 59 commits into
mainfrom
fix/853-teams-bot-selectors
Draft

fix(msteams): a name the resolver cannot select is recovered structurally — witnessed stale live (#853, #797)#896
DmitriyG228 wants to merge 59 commits into
mainfrom
fix/853-teams-bot-selectors

Conversation

@DmitriyG228

Copy link
Copy Markdown
Contributor

What & why

Live #853 instrumentation (the RED witness, 2026-07-21) caught the #797 red on today's Teams web DOM. Unlike Zoom #852 (where the bot is served no names), Teams does paint a name pill in every tile — but every teamsNameSelector missed. The lead selector div[class*="___2u340f0"] is an extension-era minified hash that has since rotated; the live name div now carries only atomic hashes like ___12zni01 f1cmbuwj fv6wr3j fz5stix inside a container ___1504rl1 f1euv43f ftuwxu6 — no data-tid, no aria name, matching none of the explicit selectors. extractName returned '', so the no-name guard discarded every voice-outline transition: perfect on-screen names, an anonymous transcript.

Fix mechanism

Rather than chase a new hash that will drift again (the exact failure #797 predicted), extractTeamsSpeakerName gains a selector-agnostic structural fallback that runs only after every explicit selector misses: it scans the tile's text-bearing leaf nodes for a name-shaped string — has a letter, isn't a timer, isn't a UI-control/forbidden word. It reads the name the way a human reads the tile, so it survives the next hash roll. The explicit selectors stay as the preferred fast path.

extractName is hoisted out of the createTeamsSpeakers closure to an exported pure function so it is testable offline; behavior for the live code path is unchanged.

Observation bundle

# Observation Evidence
RED Explicit selectors only, reconstructed live-shaped tile → '' extractTeamsSpeakerName(tile, { structuralFallback: false }) === ''
GREEN (offline) Structural fallback → "Dmitry Grankin" extractTeamsSpeakerName(tile) === 'Dmitry Grankin'
Precedence A real selector hit still wins the fast path legacy ___2u340f0 tile → 'Legacy Name' with fallback on and off
Guard Timer / UI-control-only tile yields no false name 05:14 + Microphone tile → ''; lone 00:30''

src/name-drift.test.ts — 6 checks, wired into the module's npm test. Source-swap red→green proven: with the fallback body stubbed out the GREEN row fails (assertion failed: GREEN: the structural fallback recovers "Dmitry Grankin"); restored, all 6 pass. The fixture is RECONSTRUCTED to mirror the live-witnessed class structure (___1504rl1 container / ___12zni01 leaf, atomic hashes only, no name attrs) — it is not a captured byte-for-byte DOM dump; what it faithfully reproduces is the property that broke the resolver (a name reachable by traversal but by no selector). Zero-dependency module preserved: hand-rolled DOM shim, no jsdom; gate:isolation green.

  • Module suite: npm test (teams-capture) — teams-capture + blindness + name-drift all green.
  • Full gates: node scripts/gates.mjs all✅ gates green (all 33 gates).

Honest scope — RED witness + fix + offline green, NOT the live green

This PR carries the live RED witness, the fix, and the offline structural regression (green). The live green — a real Teams meeting where the dom-outline hints resolve into named transcript rows — is still pending a clean admit and is not claimed here. It complements PR #870 (Teams de-silence), whose instrumentation is what made this name-drift diagnosis visible in the first place.

Base = main, independent (teams-capture only) — not stacked on #797/#870.

Refs #853, #797.

triage added 30 commits July 20, 2026 00:03
…aker attribution

The gmeet replay harness proves segmentation off per-channel, glow-named
frames. The lane the attribution bugs actually live in (#797/#499/#539)
is the mixed one: ONE audio stream for everybody, named only from
out-of-band hints — and it had no offline oracle at all, so 'audio
survived, attribution collapsed to seg_N' could only ever be observed
live.

replay-mixed.test.ts drives the REAL @vexa/mixed-pipeline from a stored
session, replacing only the two non-deterministic dependencies (STT → a
clock-keyed mock; the cut → an injected BoundarySource, since production
downloads a pyannote model) and asserting who-spoke: deterministic
across two runs, every name traceable to a recorded hint, no segment
left anonymous, both ground-truth speakers present across the turn
change. Wired into the package's replay script so gate:replay covers it.

The golden is harvested from a real jitsi meeting (two scripted speakers
with ground-truth WAVs) and distilled to the ~18s Anna->Boris window: 58
frames + 9 hints, real ts/hint timing with a synthetic speech-level
waveform — replaying the real-audio cut and the synthetic one yields
identical output (Anna:8.5s, Boris:6.3s), which is what lets the fixture
ship at 27 KB gzipped instead of 1.2 MB. Two fixture properties are
load-bearing and documented in the fixture README, both found the hard
way: amplitude below the capture energy gate replays to ZERO segments,
and a too-tight leading cut makes the short-UI-switch guard correctly
reject the opening name as a tile flip (seg_0 — a fixture artifact, not
a defect). The attribution checks are guarded on a non-empty replay so a
silent fixture fails loud instead of greening three rows vacuously.
…ment that measures something real

Two things.

1) RE-LAND the mixed-lane attribution oracle. It was reviewed on #831 but
   the squash-merge (2662ce2) captured the PR BEFORE the commit landed,
   so replay-mixed.test.ts and its golden are absent from main. Cherry-
   picked verbatim from 2eda66b and re-verified green here. It now sits
   in both the package test chain and the replay script, so gate:replay
   covers the lane the attribution bugs actually live in.

2) A latency instrument, and an honest account of why the obvious one is
   worthless. Wiring a lag oracle into the BATCH replay produced
   p50=3900ms/max=7800ms - pure artifact: those are exactly
   (session end - segment end), because the harness feeds every frame in
   a tight loop and the pipeline's submit/confirm cadence is WALL-CLOCK
   (speaker-streams.ts setInterval/Date.now), so nothing can confirm
   until all the audio is already in. That number is not reported and not
   gated; what the batch harnesses now assert instead is the invariant
   that IS meaningful there - nothing may be confirmed before its own
   audio exists.

   The real measurement needs the session fed at the rate it was spoken,
   so replay-paced.test.ts does that (opt-in, ~10s for the golden,
   wall-clock and therefore machine-sensitive, gating nothing unless
   LATENCY_BUDGET_P95_MS is set). Its causality check earned its keep
   immediately: the first run read -145ms because frames were paced to
   their START timestamp, while a capture frame is delivered when its
   audio ENDS.

   The number it now reports (~55ms p50 on the golden) is deliberately
   NOT presented as our latency: it reads below the 250ms mock STT
   round-trip, which is not physically coherent for audio that must be
   transcribed before it is confirmed, so segment end evidently tracks
   the confirmed prefix's audio timeline rather than the last chunk sent
   to STT. That semantic needs pinning before any budget is declared -
   documented in the harness header rather than papered over.
…nd an honest upper bound for the mixed lane

Pins the semantic that blocked a latency budget. speaker-streams computes
a segment's end as windowStartMs + totalSamples/sampleRate — the buffer's
FULL extent at confirm time, so it tracks the newest audio fed rather than
the span the text covers. Latency measured against it collapses to a
constant (~55ms) however slow STT is, which is what the first version of
this harness reported. The metric is now taken against the segment START:
turn start -> confirmed text, the delay a user actually experiences.

Validated as an instrument, not asserted: mock STT 100ms -> p50 503ms,
250ms -> 856ms, 600ms -> 1509ms. That is about 2xSTT + 300ms, because
LocalAgreement confirmThreshold=2 needs TWO submissions (two round-trips)
before a prefix confirms — so the confirm threshold, not buffering, is the
dominant latency lever. A coherence check now fails the run if time-to-text
ever drops below the STT round-trip it must pay, so the metric cannot
silently degenerate into a moving target again.

gmeet is gated at p95 <= 1500ms (observed 856ms, headroom for a regression
guard rather than an aspiration).

The mixed lane (zoom/teams/jitsi) is measured but deliberately NOT gated.
Two things separate it from gmeet: it confirms per TURN, so confirmed-text
timing is bounded by how long a speaker holds the floor (6.8-11.3s here)
rather than by latency — hence first visible DRAFT is this lane's
comparable number; and this harness injects a cut source that fires only
on speaker change, whereas production's PyannoteSegmenter cuts
continuously on speech/silence, so the pipeline is starved of the cuts
that trigger early drafts (Anna's first turn drafts not at all until it
ends). The numbers are therefore an upper bound produced by an
unrepresentative double. Gating on that would be a green that proves
nothing; a real budget needs a boundary source with production cut
density, which recorded pyannote boundaries in captured-signal.v1 could
supply.
…can be measured instead of approximated

The mixed lane (zoom/teams/jitsi) could not be gated on anything, because
its chunking is decided entirely by PyannoteSegmenter and a replay cannot
download the model. Every offline harness therefore substituted its own
cut source, and a number measured against a substitute is a number about
the substitute: the previous run reported 2.9-11.3s time-to-text and
Anna's opening turn drafting not at all, which was the harness starving
the pipeline of cuts rather than the pipeline being slow.

captured-signal.v1 gains a type-tagged BoundaryEvent, and the bot tees
production's OWN cuts into the recorded session by wrapping the real
segmenter through the existing makeSegmenter seam (so production keeps
its own cut source and the recorder only observes it, kind + tMs +
confidence, on the same epoch clock as frames and hints). Additive:
recorders and replays that predate it still work, and both harnesses say
out loud which chunking they are using.

Live-harvested on the lab: 216 frames, 76 hints, 27 real pyannote cuts.
With production's chunking the mixed lane now measures rather than
approximates:

  attribution  - both speakers named across the turn change, deterministic
  latency      - time-to-first-text p50 2780ms / max 4406ms
                 (vs gmeet 856ms, same paced clock, same 250ms mock STT)

That ~3x gap is real and is a product question, not a test threshold: the
mixed lane waits for a pyannote cut, which needs a PAUSE, where gmeet
submits per speaker every 100ms. Budgets are therefore per-lane
regression guards with headroom (gmeet 1500ms, mixed 6000ms), and the
harness prints the comparison so the gap stays visible rather than
normalized.

Two fixture artifacts found and excluded along the way, both of which
mimic the #797 signature: a window starting mid-turn makes the pipeline
cold-start and emit seg_0 (the full session attributes fine), and a
too-short leading turn trips the short-UI-switch guard. The golden is
therefore cut from the session's natural start; both traps are in the
fixture README.
…of completing silently

The faults were never lost for lack of typing: @vexa/transcribe-whisper
classifies every one (payment_required/unauthorized/unavailable/timeout,
with the backend's own detail), and both pipelines hand them to onError.
What was missing was the last hop — the composition root logged them to
a console that dies with the container. So a meeting whose STT backend
refused every chunk reached 'completed' indistinguishable from a silent
room, which is why empty-transcript reports could not be diagnosed after
the fact (docs/test-scenarios/terminal-seams.md carried this as an
explicit GAP row).

The bot now accumulates STT faults per kind and reports once, on the
TERMINAL lifecycle event — the last thing anyone hears from a bot, and
the only emit that is never an idempotent no-op at the receiver. A dead
backend faults on every chunk (18 in a 2-minute live run), so counting
and reporting once is both the honest summary and the only shape that
cannot flood the control plane; the first fault of each kind still logs
loudly for anyone tailing the container. Rides lifecycle.v1 as an
additive field exactly as infra_fault does (additionalProperties: true
— no schema or seal change). meeting-api ingests it into
meeting.data.stt_fault, so it reaches the DB and the existing
meeting.status_change webhook without a new event type.

Deliberately NOT changed: the no_op gate on same-status re-emits (its
idempotency is load-bearing), and the terminal's live banner — #552's UX
half still names the transport layer, because the SSE the banner reads
is fed by the collector, not by lifecycle. That leg stays open.

Offline-proven: stt-faults.test.ts (storm→one summary per kind, counts,
backend detail preserved, terminal-only, healthy meeting unchanged, a
throwing reporter never perturbs the exit) + meeting-api
test_lifecycle_diagnostics (persisted to data + webhook, negative
control). Bot suite clean, gates green; meeting-api 770 pass (the 31
calendar-sync failures are pre-existing on main, 768 pass there).
…of publishing a stale draft

flushSpeaker(force) — the bot leaving, a meeting ending, a channel rotating — emitted
buffer.lastTranscript and reset, so audio newer than that draft was discarded. The emitted
segment still carried the buffer's FULL extent, so a turn whose draft covered only its opening
seconds went out as a full-length segment with most of its words missing and nothing downstream
saw a gap. The inFlight->pendingFinal guard that exists for exactly this case sat below the
draft branch and was unreachable whenever a draft existed, which is the common case.

Owed audio is now submitted (or deferred to the in-flight response) before the draft is
considered; the draft stays the fallback for an empty or hallucinated final response, so a turn
is still never lost outright.

Also adds the transcript QUALITY oracle this was found with: speech_fixture.py builds a
captured-signal.v1 session from real speech with KNOWN text, quality.test.ts replays it through
the real lane against real STT and scores recall/precision/fragmentation/attribution against
that truth. Every existing replay harness used a mock transcribe returning canned strings, so
none could see a transcript that was structurally perfect and substantively wrong.

Controlled measurement (q2 fixture, local whisper, STT pinned +3000ms, zero dead air, n=4 each):
  baseline  recall 0.777 0.777 0.489 0.628 | lastTurnRecall 0.976 0.976 0.146 0.146
  fixed     recall 0.989 x4                | lastTurnRecall 0.976 x4
…is stored once

The store upserts on (meeting_id, segment_id) — a segment_id IS the identity of a line of
transcript. The mixed lane published its forming tail under turn:N:p<i> (indexed off the
UNCONFIRMED slice, so it renumbered as the turn advanced) and the confirmation under
turn:N:<seq>. Two identities for the same words: the draft row was never replaced, so every
confirmed sentence left its draft behind as a permanent second row.

Observed in a real Jitsi meeting — 6 stored rows for 3 spoken sentences:
  turn:54:p0 | происходящего.        turn:54:0 | происходящего.
  turn:54:p1 | И, мало того, ...     turn:54:1 | И, мало того, ...

The forming tail now carries the ids it will confirm under (turn.seq is already past this pass's
confirmations), so draft -> confirm is an upsert on one row. This is what the gmeet lane already
does; the mixed lane is now the same. Nothing consumed the 'p' prefix.

Red test models the CONSUMER, not the wire: every published segment folded into a map keyed by
segment_id, last-write-wins — exactly upsert_segments — then asserts no sentence is stored twice.
Pre-fix that store held 7 rows for 4 sentences; post-fix 4.
…turned on

The recorder shipped in 0.12.15 and the bot honours invocation.v1 captureSignalEnabled, but
NOTHING set it: bot_spawn did not know the option existed and no deploy surface exposed it. The
feature was unreachable in every real deployment. The live runs that 'proved' it worked had the
env var set by hand on containers launched OUTSIDE the control plane — which is precisely what
hid the gap.

Adds a deployment-scoped CAPTURE_SIGNAL_ENABLED (compose + helm, config.v1 declared) that flows
knob -> invocation -> bot, and a test that pins the reachable path rather than the unit in
isolation.

OFF by default on purpose: it persists RAW meeting audio, a stronger retention claim than a
transcript, so turning it on is an explicit operator decision.

Caught while committing: the helm template read .Values.services.captureSignalEnabled while
values.yaml declared it under meetingApi — there is no 'services' section, so the knob would have
rendered 'false' no matter what an operator set. The same dead-config bug the change exists to
fix, one level up. gate:config-contract checks that a declared key appears on a deploy surface,
not that the .Values path it references resolves, so it stayed green. Verified by rendering:
--set meetingApi.captureSignalEnabled=true now yields "true", default yields "false".
It fed the lane minAudioDuration 0.15 / submitInterval 0.1 — 13x and 20x faster than the
production defaults (2s/2s) — inherited from the batch replay test. Every latency number it
produced therefore described a configuration no deployment runs. It now uses the production
defaults, overridable via BOT_SPEAKER_* to compare tunings deliberately.

The budget also moved from total time-to-text to PIPELINE OVERHEAD (total minus
submissions x STT). Total is dominated by the STT gateway, which this repo does not control:
measured against production 2026-07-19, a 4s chunk costs p50 ~2.1s server time with only ~370ms
of that network. An earlier version 'passed' a 1500ms total budget purely because the mock STT
was 250ms — a green that proved nothing about the product.

On the corrected config the harness is RED and should be: overhead p95 3954ms against a 900ms
budget, projecting ~8.15s time-to-text at production STT. That matches the user-reported 'time to
first transcript is very high' and is the baseline for the gmeet cadence work. The harness is
opt-in (npm run replay:paced) and gates nothing — gate:replay runs replay/replay-mixed.
…sion

Replays a recorded captured-signal.v1 session — its audio, its speaker hints, and production's
OWN segmenter cuts — through the REAL mixed lane and scores what survives: coverage, word
retention (words STT returned vs words published), segment size distribution, holes, duplicates.
MIN_TURN_MS sweeps boundary coalescing so the over-segmentation axis can be measured rather than
argued about.

First reading, on a live jitsi session sharing a YouTube tab (continuous speech):
  183 recorded cuts in 190s, p50 inter-cut gap 0.41s, 142/182 under 1s, every confidence 0.35-0.47
  STT windows p50 0.77s, 34/54 under a second -> Whisper answers 'Thank you.' to a 0.38s sliver
  word retention 1.000 — the pipeline publishes every word STT hands it

That retention number retires a hypothesis I had argued from code reading: that mixed
LocalAgreement-3 was dropping text because turns close before they can confirm. It is not. The
loss is upstream — continuous speech is diced into sub-second windows, so what STT returns is
filler rather than transcription.

MOCK_STT=1 is deliberate here and scoped: the question is structural (words in vs words out), so a
stand-in emitting a known word count per second measures retention exactly, deterministically, and
without a 10-minute CPU-whisper round. It says nothing about ASR quality and must not be used to
claim any.
…ic set, two loops, stage-attributed fixtures

FRAMEWORK.md is the governing doc: six stages (capture -> segmentation -> STT -> assembly ->
publish -> store), an invariant metric set for every lane, the external/internal loop contract
(no fix without a fixture from a real session; no ship without human re-witness), the
ground-truth ladder, and the anti-patterns of the calibration day encoded as rules.

Instruments landed with it:
- eval/src/tape-to-signal.mjs — desktop raw tape -> captured-signal.v1. THE bridge: a
  human-witnessed session becomes an offline, scoreable fixture instead of something only
  replayable into a running desktop. Prints capture duty cycle on conversion, so a fixture
  self-reports whether its audio was even delivered before anyone blames a pipeline.
- eval/src/single_pass_truth.py — the SAME audio the pipeline received, sent to the SAME STT in
  one pass, as the reference. Splits every miss into model ceiling (absent from the reference
  too — not ours) vs streaming loss (present there, missing live — ours, quantified).
- desktop: VEXA_STT_TAP=1 logs each submission window + its answer (a transcript hole is
  ambiguous without it), and the gmeet lane now runs the SHIPPED cadence via BOT_SPEAKER_*
  (production defaults 2/2/2) instead of a hardcoded desktop-only 1.5/3 — the hot loop must
  measure the configuration that ships.
- extension README: corrected 'Google-Meet-only by design' — src/meeting.ts detects and captures
  gmeet/youtube/zoom/teams; a YouTube tab is the cheapest repeatable mixed-lane source there is.

Calibration baselines (2026-07-20): jitsi bot capture duty cycle 65.0% (capture defect;
segmenter innocent — inter-cut p50 0.41s tracks the delivery-gap p50 0.37s), youtube-direct
control 97% duty / 0.671 coverage / 0% filler; hallucination is monotonic in window size
(31% filler <1s, 0% >=2s), which names #613's un-root-caused sibling half.
… and ten audited gaps

Attribution was the thinnest part of v1 and is now written down properly: ClusterNameBinder is
THE binding logic and the four platform speaker adapters are thin sources feeding it
(hintKindForPlatform: teams -> dom-outline, else dom-active), each adapter shared by BOTH the bot
and the extension. So validating the binder on ONE mixed platform validates it for jitsi, teams
and zoom; only hint quality stays platform-specific. Adds attribution's own stage chain, the four
signals that need no ground truth (provisional rate from transcript.v1 , hint
matched/missed from onHintOutcome, rename churn, speaker cardinality), and the two truth-bearing
oracles — including self-identifying speech, which puts the speaker label INSIDE the content so
the single-pass reference can score attribution without any labelling.

Also converts claims into a coverage matrix whose blanks are honest (zoom/teams entirely
unmeasured; gmeet content measured only on a TTS fixture), and records ten gaps this framework
does NOT yet close. The sharpest is G1: single_pass_truth.py is speaker-BLIND, so a transcript
with perfect words and scrambled speakers scores 1.000/1.000 — mock-blindness repeating on a
different axis. G2 admits the attribution metric row was aspirational. Others: an uncalibrated
reference (G3), no fixture corpus at all (G4 — today's fixtures live in a temp dir), nothing in
gates (G5), no store-fidelity metric where the false '64.9% dropped' came from (G6), latency
unmeasured live (G7), English-only while the originating complaint was Russian (G8), no overlap
metric (G9), no single session bundle (G10).

Also fixes the tape converter to discriminate recording chunks by their REC1 magic — a recording
chunk decoded as an audio frame yields plausible garbage (magic reads as speakerIndex, media bytes
as denormal timestamps), which silently corrupted the first conversion's duty cycle; and clips the
live transcript to the recorded session's own window before scoring, which moved measured
precision from 0.841 to 0.941.
… G11, not a pass

The coverage matrix let gmeet's content cell read as if a TTS fixture covered it. It does not: the
mixed lane's recall .905 was measured against a single-pass reference on a real session, while
gmeet has only ever been scored on synthetic known-text audio. The lanes do not transfer — LA-2 vs
LA-3 confirm economies, per-participant <audio> capture vs the bot's webrtc-hook chain, and
gmeet-only loss surfaces (glow gating #616, the silence gate with no env knob).

Also marks gmeet's capture cell as a path independent of #850's, so the bot mixed-capture fix is
not mistaken for covering it, and records that the 0.12.15 attribution behaviour the maintainer
attests to has never been written down as a number a regression could fail against.

#851 scope broadened to match: loss + latency + a no-regression sweep, with the #613 pause repro
walked on gmeet so post-pause loss is either fixed or attributed to #616 with evidence.
…t regression test (#848)

FRAMEWORK.md claimed "every witnessed session joins the regression corpus" and there was no
corpus: no location, no naming, no index, no per-fixture expected values. A session file alone
cannot be a regression test — replaying audio proves nothing unless the numbers it USED to
produce are stored beside it. The day's two calibration sessions were sitting in /tmp.

An entry is $VEXA_CORPUS/<platform>/<slug>/: the session, a baseline.json holding every metric at
promotion time, a manifest.json pinning provenance (source file, window, commit, STT service and
model, sha256), and optionally the single-pass reference and the live transcript it was scored
against. CORPUS.md is the tracked index, so a fresh checkout finds the corpus even though the
audio is deliberately not in the repo.

  promote-fixture.mjs   tape or captured-signal session -> entry, baselines computed
  score-fixture.mjs     re-measure every entry, diff against its baseline, exit 1 on drift
  signal-metrics.mjs    the truth-free delivery/shape metrics, one definition for all callers

Three metric blocks, and the difference between them is the point. Delivery/shape and content
recompute from stored bytes, so drift there means a scorer or the fixture changed. The lane block
re-runs the REAL mixed pipeline over the fixture with a mock STT — everything held fixed except
our code, so movement is the code moving. tape-to-signal grew --head-sec because a tape keeps
growing after the session someone measured ended; the youtube entry re-derives byte-identically
from a tape twice its length.

The lane harness now models the CONSUMER: publish AND publishPending upserted by segment_id, last
write wins, exactly upsert_segments. The first version of that model dropped the `pending`
argument production actually ships and so could not see a draft-identity defect at all — the
instrument was blind to the defect it was built to catch. Fixed, and reverting 4c030cd now takes
youtube/2026-07-20-continuous-speech from 11 store rows / 0 duplicates to 22 rows / 11 duplicates.
Fixtures with no recorded cuts (any desktop tape) run the real PyannoteSegmenter rather than
scoring one 20-minute turn and calling it the lane.

single_pass_truth.py reads gzipped sessions, emits its score as JSON for a baseline, no longer
demands STT credentials to re-score against a reference it already has, and accepts a service URL
with or without the route on it.

Recorded, both entries at 5b13eff:
  jitsi/2026-07-20-capture-gaps       duty 0.650 · recall 0.858 · precision 0.723
  youtube/2026-07-20-continuous-speech duty 0.949 · recall 0.905 · precision 0.941

Same source material, same lane, same STT — the gap is the bot's capture chain and nothing else.

Closes framework gap G4; G6 partly.
The corpus entry jitsi/2026-07-20-capture-gaps delivers 136.2s of audio over 209.5s of wall clock —
a 65.0% duty cycle against 94.9% for the extension on the same source material. Two mechanisms
produce that identically and have unrelated fixes:

  * the peak-amplitude silence gate (mixed-audio.ts) refuses a whole 256ms buffer below 0.005
  * createScriptProcessor runs its callback on the MAIN THREAD and drops buffers when the page is
    busy — documented in this repo by the module written to replace it (gmeet-capture/pcm-capture.ts),
    and the bot's renderer is the busiest we run: four AudioContexts, a software-rendered meeting
    client, and a concurrent Opus encode

Nothing distinguished them. The delivered frames' peak amplitudes are truncated exactly at 0.00503
with none below, which proves the gate is live — but a buffer the processor never hands over is
never peak-tested either, so that evidence is equally consistent with both. The gmeet lane logs
seen/emitted per stream; this lane logged nothing per frame.

stats() measures both against the AudioContext's own clock, which is the only local witness to how
much audio existed: processorDeficitSec = rendered - delivered, gatedSec = what the gate refused.
Counted in samples rather than buffers, so the figures stay true whatever buffer size the engine
picks. The bot now passes a log so a live run reports the split; the extension already had a quiet
default and keeps it.

Tested against the existing browser shim with a test-driven context clock — advancing it
independently of the buffer callback is exactly how a starved processor is modelled, and the test
asserts the gate is NOT blamed for a processor deficit and vice versa.

Diagnosis and its correction are on #850. No fix yet: which mechanism dominates is now a
measurement, and the next live run makes it.
…as holes (#850)

cut() concatenates only the frames that exist, so a span containing a hole yields audio SHORTER
than the span it claims. Whisper describes that compressed audio in its own timebase, and the
mapping read it as `spanStart + ws.start*1000` — treating compressed time as wall time. Every word
after a hole was stamped early by the accumulated hole, compounding across the turn. The cut's own
comment asserted frames are contiguous; on this lane they are not.

Holes are not exotic and not someone else's bug: capture always has some (a dropped buffer, a gated
silence, a renegotiation). On jitsi/2026-07-20-capture-gaps, 108 of 183 recorded segmenter cuts land
at a wall time where no audio was ever delivered. Downstream, turns open and close against the wrong
instants and the hint binder matches names against a clock that has drifted — so this decays
attribution, not only timing.

cut() now returns its span layout and wallTimeAt() maps compressed seconds back through the holes.
The audio stays compressed: zero-filling would restore clock equality at the price of inviting the
model to hallucinate over manufactured silence, and would cost STT time for nothing. Both edges
resolve a boundary correctly — a start opens the next run, an end closes the previous one — so a
sentence is neither stretched across the hole after it nor pulled back into it.

Also: a span may no longer reach behind the ring. cut() can only return what it holds, so a span
older than RING_MS returned RECENT audio under an OLD start, placing an entire turn at the wrong
instant. Clamping spanStart to the oldest surviving frame keeps the claim and the samples the same
age.

Red test (model-free): speech, a 3s hole no frame ever covered, speech again. Pre-fix the post-hole
sentence published at 2000ms — inside the hole, 3s early. Post-fix [0-2000] and [5000-7000], both
where the audio actually was.

Two instrument corrections this surfaced, both of which invalidated numbers I had already reported:

  * the replay harness fed frames in a tight synchronous loop, so the WHOLE session was fed before
    the lane's async pump ran once and audio was evicted from the 120s ring before anything read it.
    The run scored the leftovers. It now yields between frames: submissions go from p50 0.25s (an
    artifact) to p50 2.56s (production-like), 185 STT calls to ~435, 11 publishes to ~279.
  * `retention` under MOCK_STT is not a loss metric. The mock invents fresh tokens per call, so every
    LocalAgreement resubmission inflates the denominator with words that were never new. Published
    words are stable run to run (2746-2750) while sttWords is not (3935-4138). It is now recorded
    only against real STT.

Lane baselines re-recorded with tolerances measured from five consecutive runs rather than assumed
exact: structure (coverage 0.905-0.906, holes 6/6/6/6/6, words 2746-2750) is stable; call-count
figures are not. What the corpus exists to catch moves far further — reverting 4c030cd still takes
storeDupes 0 → 11.
…850)

Two numbers reported as measurements on 2026-07-20 were artifacts, and neither was visible in a
single run: a replay that fed a synchronous ring faster than the async pump could read it (scoring
the leftovers, with a submission p50 of 0.25s against production's ~2s), and a retention ratio whose
denominator the mock STT controlled (every LocalAgreement resubmission counted as words lost).

Both are now rules in FRAMEWORK.md rather than history: repeat a run before baselining it; a ratio
whose denominator the instrument controls is not a measurement; a replay must not outrun the code it
replays, and a cadence that does not resemble production's indicts the instrument first.

CORPUS.md carries the re-recorded lane baselines, the five-run spread the tolerances come from, and
the caveat that the single-pass comparison is built from DELIVERED audio and is therefore blind to
capture loss by construction.
)

The framework's attribution row was aspirational (gap G2): every signal was already emitted and none
was read. `onHintOutcome` is the clearest case — #797 reports hints "detected, silently discarded",
which is precisely this counter going unwatched since it was added.

Four signals, none of which needs a labelled meeting:

  provisionalRate  share of stored WORDS still under a seg_N cluster id — a turn no hint ever claimed
  hintMissRate     the binder's own per-hint verdict, finally counted
  renames/churn    turns whose name settled somewhere other than where it started (A→B→A is
                   defective whichever name is right, so distinctness is the measure)
  cardinality      published speakers vs distinct hinted names

Scored against the store, not the wire: `rename` repaints rows in place, so the attribution a reader
ends up with is the LAST name each row was given.

First measurement, jitsi/2026-07-20-capture-gaps: 16.7% of words provisional, and 79 of 86 hints
missed — a 91.9% miss rate.

That second number is mostly NOT the binder. Of 83 in-window hints, 55 (66%) point at a wall instant
where capture never delivered a frame, so no turn could exist to match them; 3 more fall outside the
audio window entirely. Capture loss is upstream of attribution loss, which reorders #847's chain:
#850 is not parallel to the attribution work, it gates it. What remains after subtracting the holes
is 21 hints that pointed at delivered audio and still missed — a real residue, and the honest size
of the binder-side question.

A fixture with no hint stream (a shared tab) reports attribution as n/a rather than 100%
provisional: nobody to name is not the same as failing to name.

Tolerances follow the lane block's measured jitter. What these exist to catch — a namer that stops
naming, a hint stream that goes dark — moves in tenths.
Coverage matrix carries the first attribution numbers (jitsi: 16.7% provisional, 91.9% hint miss)
and G2 closes for the mixed lane. What stays open is named rather than implied: the gmeet lane binds
at capture from the glow and needs its own reading, and these four signals score WHETHER a name was
assigned, never whether it was right — that needs the self-ID oracle.
… suspect (#850)

Two mechanisms can omit a capture frame and they leave identical evidence in a recording, because a
buffer the ScriptProcessor never delivered was never peak-tested either. The code map ranked the
main-thread ScriptProcessor first — this repo documents that node as dropping and duplicating
buffers under load, in the very module written to replace it, and the bot's mixed lane is the last
path still on it inside the busiest renderer we run.

Measurement says no. Driving the real capture module in real chromium, 15s per arm:

  continuous tone, idle page                 duty 99.0%   gated 0.0s   processor deficit 0.1s
  speech-like + digital silence, idle page   duty 99.3%   gated 2.6s   processor deficit 0.1s
  continuous tone, main thread BUSY          duty 99.1%   gated 0.0s   processor deficit 0.1s
  speech-like + silence, main thread BUSY    duty 100.7%  gated 2.8s   processor deficit 0.0s

The BUSY arms block the main thread for 180ms out of every 220ms — heavier than a meeting client
imposes — and the processor still delivered every buffer (58 seen, 58 emitted, 15.0s rendered). A
blocked main thread queues those callbacks; it does not discard them. The gate, meanwhile, removes
precisely the digital silence and nothing else.

So the ranked suspect is refuted and the silence gate stands alone as the mechanism, which is the
distinction that decides the fix: a gate is a policy to change, a starving processor would have been
a rewrite onto the AudioWorklet.

What this does NOT establish: that the bot's renderer behaves like the bench's. A synthetic block is
not four AudioContexts, a software-rendered meeting client and a live Opus encode. The counter now
ships in the bot, so any live run reports its own split and can contradict this.
…ency tail offline (#850)

A live desktop session against the hosted STT (transcription.vexa.ai, whisper-1) showed round trips
of p50 1419ms and max 9045ms — but the service is not the interesting half. 1217 submissions, grouped
into runs where a turn re-sends a longer span each pass:

  352 growth runs · p50 3 passes / 5.3s · max 11 passes / 35.0s
  5100 audio-seconds SENT for 2266 seconds of distinct span = 2.3x re-transcription

Three passes is LocalAgreement-3 working as designed; the span only advances when something
confirms, so the unconfirmed window is re-transcribed every pass. The tail is the problem: an 11-pass
turn reaches a 35s window, and a 35s window costs seconds of STT before a single word can appear.

resendRatio and maxSubmitSec now ride in the lane block. The youtube corpus entry reproduces the
tail with no meeting: maxSubmitSec 33.1s against the live session's 35.0s, resend 1.5x. So the
latency defect is now a fixture, not an anecdote — which is what makes the fix testable before it
is witnessed.

No fix here. Bounding the window is the obvious lever and it is not free: a cap that is not paired
with guaranteed forward progress stalls a turn that will not confirm, and forcing a confirm trades
latency for the accuracy LocalAgreement exists to protect. That tradeoff gets swept on the corpus
rather than guessed.
youtube/2026-07-20-witnessed-panel — the maintainer watched this session live in the extension panel
and judged time-to-text ~6s and quality good. It is the only entry carrying a human verdict, so it is
the point the instrument numbers are calibrated against rather than argued from.

It also anchors the far end of the delivery curve: capture duty cycle 100.0%, zero gaps over 100ms in
557 seconds, on the same capture code that delivers 65.0% through the jitsi bot. Whatever removes 35%
there leaves this source untouched — which is the argument for changing the silence gate rather than
the capture chain.

Content is not scorable for this entry and the manifest says so: the desktop store had restarted, so
no live transcript survived to compare against a reference. Recorded as a gap in the entry rather
than quietly omitted.
…d no labelling (#849, #852)

The four truth-free signals say whether a name was assigned. They cannot say whether it was correct,
and that gap was being carried as "zoom will start it" — a plan that needs a meeting, real people
and a human to judge.

It does not. speech_fixture.py grows a `--lane mixed` mode: the same known-text TTS clips, but mixed
to ONE stream with the speaker withheld from every frame (the gmeet lane names frames because
capture genuinely knows; every other platform does not), and the names delivered out-of-band as a
hint stream. Because the fixture knows who spoke when, the published transcript can be scored for
attribution ACCURACY — the truth-bearing oracle FRAMEWORK.md listed and nobody had built.

The hint dials are the point: --hint-lag-ms, --hint-drop, --hint-wrong reproduce the three ways a
DOM name source fails, seeded so a fixture is reproducible. 3 speakers, 9 turns, 355 known words:

  hint stream       accuracy   given a good hint   provisional   wrong rows
  clean (250ms)       91.3%         91.3%             1.2%           1
  lag 750ms           82.6%         82.6%             1.2%           3
  30% dropped         65.2%         93.8%            22.2%           3
  34% wrong           47.8%         84.6%             0.0%          12

Two things fall out. The binder is largely faithful — "given a good hint" holds 82-94%, so its
errors are dominated by the quality of what the DOM tells it, not by the binding. And the last row
is the one that matters: half the transcript under the wrong speaker while EVERY truth-free signal
reads healthy — 0.0% provisional, 3 published against 3 hinted, nothing unnamed. Recorded in
FRAMEWORK.md as G1a, demonstrated rather than asserted.

Accuracy carries the same run-to-run jitter as the rest of the lane block (the clean arm scored
87.0% and 91.3% on two runs), so it is a ±4pp instrument, not a ±0.1pp one. Enough to separate 91%
from 48%; not enough to chase a point.
…d on merit (#850, #854)

Reverting the segmenter-timeline change exposed this: the corpus could tell me a diff moved
coverage 0.905 -> 0.835 and slivers 78 -> 56, and could not tell me whether that was a regression or
a correction. A corpus entry's stored transcript came from the ORIGINAL live session, so it scores
that session and nothing else. Every content question about a diff was unanswerable, and the honest
response was to revert a change I still believe is right.

TRANSCRIPT_OUT writes what the CHANGED code produces from the same audio, in the shape
single_pass_truth.py --realtime already reads. score_replay.py scores it against either a corpus
entry's single-pass reference, or — on a synthetic fixture — the clip text itself, which is absolute
truth rather than another model's opinion.

Proven end to end on the 3-speaker mixed fixture with real STT (local faster-whisper-small):

  KNOWN TEXT: 355 words · REPLAY: 351 words
  recall 0.907 (322/355 kept, in order) · precision 0.917 (29 words never said)

And with real STT rather than the mock, attribution accuracy on that fixture reads 96.4% against
87-91% under MOCK_STT — the mock's uniform windows cut turns differently, which is one more reason
its numbers are structural only.

The metric set is now complete on a fixture that needs no meeting and no human: delivery, content
against absolute truth, attribution accuracy AND the four truth-free signals, integrity, shape, and
re-transcription cost.
, #851)

I claimed gmeet-lane attribution was unscored. It was not — quality.test.ts has always scored
"segments under the right speaker"; nobody had run it on a multi-speaker fixture and written the
number down. Corrected by measuring rather than by asserting again.

Same 3 speakers, same known text, same local STT:

  gmeet  (per-channel, named at capture)   attribution 1.000 (14/14) · recall .873 · precision .963
  mixed  (one stream + DOM hints, clean)   attribution 0.964          · recall .907 · precision .917
  mixed  (hints wrong 34% of the time)     attribution 0.478

This is the layer model's central claim, finally priced: naming at capture is free and cannot be got
wrong, while the mixed lane's attribution is only ever as good as the hint stream feeding it. It also
says where per-platform effort belongs — not in the binder, which holds 82-94% given a good hint, but
in whether each platform's DOM produces one.

Coverage matrix updated; the gmeet row's content and attribution columns are no longer blank.
…ign actually costs (#847, #854)

Every content number in the corpus rested on an untested assumption: that a single-pass reference is
closer to truth than the live transcript. If it were not, recall against it would measure agreement
between two flawed transcripts and mean nothing. G3 named that and nobody had settled it.

A synthetic fixture settles it, because the spoken text is known absolutely rather than inferred:

  SINGLE PASS vs known text : recall 0.977  precision 0.943   <- the model's ceiling on this audio
  OUR REPLAY  vs known text : recall 0.907  precision 0.917   <- model + streaming together

  streaming's OWN loss: 7.0 points of recall, 2.6 points of precision

So the reference is substantially better than the pipeline, recall against it is loss rather than
agreement, and the corpus's content axis means what it claims. G3 closes; what remains of it is
narrower and now stated as such — 60s chunking with 3s overlap can still duplicate or drop at the
seams, and this bounds the total error without isolating them.

It also answers "how far from perfect, and how much of that is ours": of the 9.3 points between the
pipeline and the known text, 7.0 belong to the streaming design and 2.3 to the model.
…al meeting

meeting-speaker.mjs joins a Jitsi room in headless chromium with a WAV as its microphone
(--use-file-for-fake-audio-capture), so a real WebRTC source with known ground truth exists without
a person, a TTS service, or a host. Two of them plus a real bot is the shape that reads the capture
delivery split from a production renderer, which is the one measurement the headless bench cannot
make: the bench proved the ScriptProcessor survives 82% main-thread blocking, but a bot's renderer
is four AudioContexts, a software-rendered meeting client and a live Opus encode.

Adapted from the lab script; playwright is imported by path because eval is deliberately not a
workspace member.

First run did not reach the measurement, and the reason is worth recording: the speakers join with
`#config.prejoinPageEnabled=false` and land in the conference, while the bot joins plain
https://meet.jit.si/<room> and sat in "waiting for admission" for 234s. meet.jit.si gates a joiner
arriving into a room that is already populated. The bot must therefore join FIRST and let the
speakers arrive around it — an ordering constraint on autonomous multi-party runs, not a bot defect.
….si nobody can

meeting-speaker.mjs grows ADMIT=1: poll for a lobby knock and let it in, so an autonomous
multi-party run does not need a person to click Admit for every bot.

It cannot work on meet.jit.si, and the evidence is worth keeping. Spawning the bot into an EMPTY
room still logs "[Jitsi] Bot is in the lobby — waiting for a moderator to admit", so the earlier
guess that this was a join-ordering problem was wrong. Joining a speaker to admit it fails the same
way, and the speaker's own page says why:

  "Asking to join meeting… The conference has not yet started because no moderators have yet
   arrived. If you'd like to become a moderator please log-in."

meet.jit.si requires an AUTHENTICATED moderator to start a conference, so every unauthenticated
participant queues — the would-be doorkeeper included. No amount of ordering or polling escapes it.

The dial stays because it is correct against a Jitsi we control, which is the actual fix: a
self-hosted host has no lobby, and the compose stack already parses declared jitsi hosts. Until
then, a live multi-party jitsi run needs one authenticated human to open the door — the one step in
this harness that is genuinely not automatable on a public deployment.
…s discarding time (#850)

Measured on a real bot renderer, in a live meeting, with the delivery counter:

  seen=500 emitted=168 · delivered 128.0s of 128.0s rendered · processor deficit 0.0s · gated 85.0s

Every buffer the graph rendered reached the callback — in the renderer that was the entire basis for
suspecting otherwise (live Jitsi client, software-rendered, four AudioContexts, concurrent Opus
encode). So the gate was the whole capture deficit, and the ScriptProcessor hypothesis is dead in
production as well as on the bench.

Dropping a frame does not drop audio, it drops TIME. The segmenter splices speech onto speech across
the hole and reads it as a speaker change; turn boundaries land where nothing was delivered (108 of
183 recorded cuts on jitsi/2026-07-20-capture-gaps); the hint binder matches names against a clock
that has drifted (55 of 86 hints point into holes). What the gate saved is already saved twice
downstream — the pipeline refuses to submit a span below DROP_RMS, and the post-Whisper acoustic
gates drop what comes back.

A non-positive threshold now disables gating outright. `maxVal > 0` would still have refused an
all-zero buffer, and an all-zero buffer is exactly what a codec's silence suppression emits — the
one case a disabled gate most needs to pass. Pinned by a test.

The extension's two MIC-path gates are deliberately untouched: the evidence here is from the meeting
path, and changing the mic on the strength of it would be the speculative fix this issue has spent
its whole life avoiding.

NOT YET VALIDATED, and it cannot be validated offline: every corpus fixture was recorded AFTER the
gate, so its holes are baked into the bytes and no replay can show them filled. This needs one live
capture on a platform we ship.
…uman (#850)

New corpus entry jitsi/2026-07-20-gate-off: the same bot capture chain as 2026-07-20-capture-gaps
with the silence gate off, two synthetic speakers on an open Jitsi host. Live counters held
seen=500 emitted=500 · processor deficit 0.0s · gated 0.0s for the whole run.

  duty cycle        0.650 -> 0.999
  gaps >100ms       199 / 115.1s -> 9 / 1.9s
  inter-cut p50     0.41s -> 1.47s
  cuts under 1s     142 -> 10
  provisional words 16.7% -> 0.0%

Both knock-ons predicted from the hole analysis are visible. The segmenter stops cutting on holes —
8.7x fewer cuts — which was the mechanism starving LocalAgreement and feeding Whisper the sub-second
windows it hallucinates over. And attribution improves with the binder untouched, because the 55 of
86 hints that pointed at instants capture never delivered now have turns to bind to.

Read the duty cycle, not the shape deltas: the red arm is continuous YouTube audio and this one is
sparse TTS clips, so the arms are not matched. What holds either way is that a CONTINUOUS source
scoring 65.0% was the defect.

Autonomy: jitsi.dorf-post.de logs "Bot immediately admitted (no lobby)" where meet.jit.si demands an
authenticated moderator, so this whole run needed nobody. Unmeasured and worth knowing before
production: frames now flow during silence over a hop that ships each one as a 4096-element JSON
array.
triage and others added 26 commits July 20, 2026 17:16
… the incoming one (#852)

Found in the first real-platform fixture. zoom/2026-07-20-live-zoom, a live 5-person call:

  142 hints · 13 speaker switches · ZERO isEnd events
  2 speakers published against 5 hinted

Zoom's adapter reports who is lit NOW (`name | null`), unlike Teams and Jitsi whose adapters emit
speaking start/end events themselves — so only this lane had to derive the end edge, and it derived
it wrong:

  if (name) hint(name, t, false);
  else if (lastActive) hint(lastActive, t, true);   // only when NOBODY is lit

A real meeting hands over directly, A -> B, never passing through "nobody lit". So A's window never
closed, and the binder names a turn by max-overlap against hint windows — an open window keeps
winning against every speaker who follows it. Three of five participants were attributed to someone
else, while every truth-free signal except cardinality read clean (0.0% provisional, nothing
unnamed): the exact blindness recorded as G1a.

The transition rule is now a pure function (makeActiveSpeakerBridge) so it is provable without a
page, and pinned by a test: a handover closes one window before opening the next, a heartbeat
re-assert of the same name does not close it, and the close lands before the open so two windows
never overlap.

Not yet validated live — it needs another Zoom call to confirm 5 hinted speakers become 5 published
ones. The mechanism and the fix are both offline-provable; the outcome is not.
…aker (#849, #852)

The signal compared distinct hint NAMES against distinct published speakers. A hint stream is a
poll, so anyone briefly lit counts as a full participant — and on zoom/2026-07-20-live-zoom that
produced "3 of 5 speakers never reach the transcript", which I reported as a defect and then had to
withdraw:

  Joyful Jen  226.0s lit    Anthony 46.0s    Rick 6.0s    Antee 4.0s    Tony 2.0s

Twelve seconds between the bottom three, of a 269s call where two people did 98% of the talking. A
two-speaker transcript is the correct outcome.

Consecutive polls on one name now merge into a lit window, and only names lit at least 8s count
toward the expected cardinality; the rest are reported separately rather than silently. The same
entry now reads "2 published vs 2 substantively hinted (+3 lit under 8s, not counted)".

This is the third over-claim of the day and the only one whose cause was the instrument rather than
the pairing of artifacts. The pattern behind all three: a red number was treated as a defect before
asking whether the EXPECTED value was right. The two guards now in the tree — a transcript must
match its session's meeting id, and cardinality must weight by lit time — both attack that.
…rned into a URL (#501)

Production, 2026-07-20: 8 of 34 visible bot pods in Error, 6 of them "failed to join", spread across
the day. Every one navigated to https://teams.microsoft.com/l/meetup-join/389141384648269 — a bare
numeric id — and ended up on login.microsoftonline.com/common/oauth2/v2.0/authorize, with the Teams
app reporting `[_getUserIdentifier] user missing`.

A joinable Teams deep link is …/l/meetup-join/19:meeting_<b64>@thread.v2/0?context={Tid,Oid} — the
thread id AND a context naming tenant and organizer. The repo's own parser says so
(collector/meeting_link.py:71). A numeric meeting id carries neither, so the constructed URL is
syntactically fine and semantically meaningless, and Teams redirects to authenticate.

Everything after that is noise the logs make look like a selector problem: each pre-join step
reports its control "not found" because it is probing a login form, and admission polls 31s and
finds no admitted/rejected/lobby indicator because there is no meeting page. I first mis-attributed
these failures to the gmeet locale issue (#856) on the strength of that final line alone.

The rule was already written, one comment above the template that broke it:

  # NO jitsi template: a jitsi room name is scoped to a DEPLOYMENT … constructing a URL from the
  # bare id would silently join the public room of that name — the wrong meeting

It applies harder to Teams: a jitsi room name at least resolves to SOME room, while a Teams id
without its thread resolves to nothing. construct_meeting_url now returns None for a teams id that
is not a thread id, which routes into the typed 422 that already existed — and the message names
the id as the problem rather than claiming the platform is unsupported, so the caller is not sent to
fix the wrong thing.

Cost this removes: a full container lifecycle, 31 seconds of polling, and a silent failure the
caller could not distinguish from a meeting with no speech.

Suite green in the project's own environment: `uv run pytest` gives 807 passed, 5 skipped, 0
failed. (An earlier run of mine reported 31 failures in test_calendar_sync.py; that was system
`python3 -m pytest` against mismatched deps, not a real result — the gate runs uv, and is green
because the tests actually pass.)
…g quiet (#852)

Live bot in a real Zoom meeting with two known-text speakers, 2026-07-20. Content transcribed
correctly; attribution 100% provisional — seg_0, seg_4, seg_7 — and the bot's own counters read

  hint-counters bridge-crossed=0 pipeline-received=0 binder-matched=0 binder-missed=0

Zero hints ever left the page. Probing app.zoom.us/wc/<id>/join the same way the bot joins, ~25s
after entering the meeting:

  .speaker-active-container__video-frame        0
  .speaker-bar-container__video-frame--active   0
  .video-avatar__avatar-footer                  0

Every selector the Zoom name path depends on matches nothing; the only class anywhere near the video
area is audio-voip-active-icon. The DOM this adapter was written against is gone.

What made it expensive is not the rot but the SILENCE. The watcher logs only on transitions, so one
that never sees a speaker emits nothing: no error, no warning, a clean bot log, a complete
transcript, and all-zero counters that are indistinguishable from a meeting where nobody spoke. The
extension path on the same platform produces 142 hints over 5 real names, so the module works — it
just cannot say when it has stopped working.

It now reports having seen no speaker at all, and names which cause the DOM supports: containers
present but unnamed ("the room may be silent") versus containers absent ("NONE of the containers
exist … selectors are stale, attribution WILL be empty"), listing the selectors that missed so the
fix does not need a source dive. Tested both ways against a shimmed DOM.

NOT fixed here: the selectors themselves. That needs a survey of the current client to find where
the active speaker is now expressed, and audio-voip-active-icon is the lead. This change is what
makes the next redesign visible in the first minute rather than after someone reads a bad
transcript, and the teams and jitsi watchers share the shape.
…854)

I read a replay's coverage of 0.378 on zoom/2026-07-20-live-zoom as a product defect — 145.8s of a
269.1s session apparently never reaching STT, with capture having delivered all of it at duty 1.001.
Checking the LIVE session the fixture came from, before writing it up:

  LIVE   68 segments · union coverage 267.7s over a 331.2s span = 0.808
  REPLAY coverage 0.378 (101.5s of 268.8s)

More than 2x apart, so the replay figure is the harness. There is no 54% loss.

quality-mixed.test.ts states this in its own header — "replay is unpaced … the submitted-window
sizes and every latency derived from them are the harness's, not production's" — and coverage sits
downstream of submission cadence, so it inherits the distortion. I wrote that warning and read past
it.

CORPUS.md now says so above the entries and marks the figure at each one; score-fixture prints it
with a `~`. The number stays in the lane block because it is a perfectly good regression detector
against the same fixture's own baseline — it just cannot be read as "the lane covers X% of a
meeting", which is exactly how I read it.

The live loss figure unaffected by any of this: recall 0.905 / precision 0.941 on the youtube
control, measured against a single-pass reference rather than derived from a replay.
…already does (#852)

A live bot in a real Zoom meeting produced a correct transcript attributed entirely to seg_N, with
hint-counters bridge-crossed=0 — no hint ever left the page. The maintainer's observation is what
made this findable: the extension and the bot use the SAME module (clients/extension/src/inpage.ts
imports createZoomSpeakers from @vexa/zoom-capture), so the adapter cannot be the difference.

Looking at how each side installs it, the bot has an internal asymmetry:

  browser-utils.global.js   context.addInitScript   → re-injected into EVERY document
  installRemoteAudioHook    context.addInitScript   → re-injected into EVERY document
  the speaker watchers      one-shot page.evaluate  → setInterval dies with ITS document

So audio and names have different lifetimes. Zoom's web client navigates around join (the URL picks
up _x_zm_rtaid= after the Join click, observed directly while driving speaking bots into the same
meeting), which would leave the audio graph alive and the name path silently dead — precisely the
observed shape.

Held as a hypothesis, not a proof: the mixed capture is created a few lines earlier in the same
evaluate and ran for the whole meeting, so either the navigation preceded the evaluate or something
else is at work. The fix is defensible either way — a watcher whose only job is to poll the DOM
should outlive a navigation exactly as the audio hook does, and nothing about the current shape
guarantees that.

Capture setup is factored into a callable and re-run on every main-frame navigation. Safe by
construction: each branch is guarded on its own w.__vexa* handle and a fresh document has a fresh
window, so this installs one watcher per document, never two.

Discrimination if it is NOT the cause: 84438cd makes a live watcher log "NO ACTIVE SPEAKER seen in
Ns" every 30s. The line appearing means the watcher is running and the DOM has no active-speaker
container; no line means it is not running at all. One live bot run separates them in 30 seconds.

Also eliminated while looking: viewport. Xvfb runs 1920x1080, so a compact responsive layout is not
the explanation.
… meeting (#852)

The re-arm in 45333ed rested on a claim about Playwright that I had not tested: that a setInterval
installed via page.evaluate dies at a navigation while an addInitScript one does not. Two intervals,
one installed each way, then a navigation:

  before navigation : page.evaluate 11 ticks · addInitScript 12 ticks
  after  navigation : page.evaluate  0 ticks · addInitScript 16 ticks

So the asymmetry inside capture-bridge.ts is real: the browser bundle and the WebRTC audio hook
outlive any navigation, the platform speaker watchers did not. A client that navigates after join
leaves audio flowing and names dead — a transcript that is correct and attributed entirely to seg_N.

This does NOT establish that Zoom navigates after the evaluate fires; the mixed capture is created a
few lines earlier in the same block and ran for the whole meeting. Two possibilities remain and the
blind-watcher line (84438cd) separates them in one log line on the next bot run.

The point of doing it this way: the MECHANISM is deterministic and provable with a double in
seconds, while only the PLATFORM's behaviour needs a live meeting. Waiting for a Zoom call to learn
how Playwright handles navigation would have been the wrong trade — and it is the same split the
milestone exists to make.
…ent (#854, G3)

single_pass_truth.py cuts 60s chunks with 3s of overlap so a word split by a cut still appears. That
shared audio is then transcribed in BOTH chunks, and the seam text lands in the reference twice. On
youtube/2026-07-20-continuous-speech:

  reference 3870 words · 31 repeated 8-grams within 400 words of each other
  122 words (3.2%) inside a duplicated region

A live transcript that correctly says a phrase once cannot match a reference that says it twice, so
every duplicated word scored as a miss. Recall 0.905 was understated — real streaming loss is at
most ~6.3%, not the 9.5% quoted in this issue's framing, in #850's observation bundle and in the
staging readiness assessment on #847.

dedupe_seams joins chunks by finding, at each seam, the longest suffix of the accumulated text that
also opens the next chunk, and dropping it once. Capped at 60 words — comfortably above 3s of speech
— so an ordinary repeated phrase mid-chunk is never mistaken for a seam.

Closes the duplication half of G3. The other half stays open and is harder: a word DROPPED at a seam
is invisible without a second reference built with different cut points.

Found by looking at WHICH words were missing rather than accepting how many. The largest "missing
run" was the reference repeating itself — "of the moment to describe so i think we've of the moment
to describe so i think we" — which is not a shape real speech takes.
…res (#854, G3)

Regenerated both references with dedupe_seams and re-baselined:

  youtube/2026-07-20-continuous-speech   recall 0.905 -> 0.920   loss 9.5% -> 8.0%
  jitsi/2026-07-20-capture-gaps          recall 0.858 -> 0.872   loss 14.2% -> 12.8%

The reference was counting its own chunk overlap as content, so a live transcript that correctly
said a phrase once scored a miss against a reference that said it twice. Every loss figure quoted
across #854, #850's observation bundle and the #847 staging assessment inherited that inflation.

G3's duplication half closes with a measured bound. The seam-DROP half is now stated separately and
stays open: a word lost at a cut is invisible without a second reference built with different cut
points, so the reference's recall is unbounded from below.
…where it cannot hold (#854, G3)

G3's remaining half was that a word DROPPED at a chunk cut is invisible in a single reference. It is
visible in a second reference cut elsewhere, so --chunk-offset-sec builds one.

  fixture                              duty    ref-vs-ref disagreement   measured loss
  youtube/2026-07-20-continuous-speech 0.949            4.4%                 8.0%
  jitsi/2026-07-20-capture-gaps        0.650           23.9%                12.8%

On the clean control the instrument is usable — 4.4% uncertainty against an 8.0% signal — though not
to the tenth of a point I have been quoting. On the gappy entry the instrument is NOISIER than the
thing it measures, so recall 0.872 there cannot support any weight and is marked not citable.

The cause is structural: load_session concatenates only DELIVERED frames, so every capture hole is a
splice in the audio the reference is built from, and shifting the cuts lands them on different
splices. A reference over holey audio is intrinsically less trustworthy — the holes are extra seams.

This gives the corpus a rule it did not have: content scoring is only meaningful on high-duty
entries. The threshold sits somewhere between 0.65 and 0.95; two points do not locate it.

Delivery figures are untouched — duty cycle comes from the bytes, not from a model — so #850's
65.0% -> 99.9% result is unaffected by any of this.
…t the pipeline (#850, #854)

Scored the same live transcript against two references over identical audio, cuts 25s apart:

  vs reference A (cuts  0s): matched 266 of 368 · precision 0.723 · "102 invented"
  vs reference B (cuts 25s): matched 314 of 368 · precision 0.853 ·  "54 invented"

Reference B recognises 48 of the supposedly-invented words as real speech, so precision against a
single reference understated by 13 points. The invention rate is at most ~14.7%, and with the two
references disagreeing by 23.9% on this entry, not a number to build on.

I had described that shortfall as "the sub-second-window hallucination measured as a number rather
than described" — in #850's A3, its observation bundle, and this entry's own description. It was
measuring the instrument. The entry's duty cycle is 0.650 and load_session concatenates only
delivered frames, so every capture hole is a splice the reference reads across.

CORPUS.md now says the entry's DELIVERY figure is what holds and its content columns cannot carry a
claim. A3 remains partial for a corrected reason: not that fragmentation persisted, but that the
evidence I cited for the invention half was my own reference's seams.

Nothing delivery-side moves: duty cycle is bytes over wall time with no model in the path.
…rom duty (#854)

I committed a rule from two data points — content scoring is trustworthy in proportion to duty
cycle. A third point breaks it:

  youtube/continuous-speech  duty 0.949  one speaker    4.4% ref-vs-ref
  youtube/witnessed-panel    duty 1.000  multi-voice    7.2%
  jitsi/capture-gaps         duty 0.650  gappy         23.9%

The cleanest audio in the corpus — duty 1.000, zero gaps in 557s — is LESS stable than a 0.949
entry, and the two are the same platform and capture path. Duty is one term at most; what the audio
contains looks like another, since a panel with turn-taking gives a chunk boundary more ways to go
wrong than one uninterrupted speaker.

CORPUS.md now says stability is measured per entry with --chunk-offset-sec and must appear beside
any content figure, rather than inferred from duty. The jitsi entry stays disqualified on its own
measured number, not on a rule.

Also scored the witnessed panel: replay precision 0.972 against BOTH references — what the lane
emits on the cleanest available audio is almost entirely real speech, and that holds across two
independently-cut references. Its recall 0.311 is the unpaced-replay cadence artifact, same as the
already-retracted 0.378 coverage, and is not a pipeline figure.
#854)

extension-loop.mjs claimed the external loop needs no human. It does, for exactly
one gesture: MIXED-lane tabs (youtube, zoom, teams) take their audio from the
offscreen tabCapture stream, and chrome.tabCapture.getMediaStreamId requires the
activeTab grant that ONLY a toolbar click, context menu or keyboard command
produces (clients/extension/src/background.ts:538). Playwright drives pages, not
browser chrome, so no driven run can produce it.

autoStart still opens the session and the ingest websocket, so the failure was
silent in the worst way: a tape appears, grows to 110 bytes of header, and the
desktop reports capture:no-signal for the whole run. Measured on a 90s youtube
run: 1 line in the tape, ch999=0f ch1000=0f, six ENGINE FAULT lines.

The loop now (a) waits for the media element and reports whether currentTime
advances, so a silent tab is never confused with an unminted stream, (b) nurses
playback every tick and says when the player pauses itself, (c) prints the one
human cue when audio is playing and no frames arrive, and (d) counts its --sec
budget from the FIRST FRAME, so a run that waited two minutes for a gesture does
not bank those minutes as captured data.

teams-speaker.mjs joins the Teams web client with a WAV as its microphone, the
sibling of zoom-speaker.mjs, reusing the selectors @vexa/join already ships so a
speaker never fails for a reason the bot would not.
…eakers both (#854)

Every content number in the corpus is scored against a single-pass STT reference,
and that reference needs its own error bars: it duplicated its chunk overlap
(122 words, 3.2%) and re-cutting it moves the answer 4.4-23.9% depending on the
entry. Two "findings" this week were the reference moving, not the pipeline.

A TTS clip has no such problem — the text exists before the audio does. So:

  build_truth_wav.py concatenates a speaker's cached clips into one microphone
  WAV plus a manifest of {startSec, endSec, text} per clip. Fed to a synthetic
  participant's fake mic, the meeting itself becomes the fixture.

  score_truth.py aligns the published transcript against that truth with
  word-level LCS (never containment — containment produced 24 false positives
  out of 39 here and reversed a conclusion), and because each clip owns a known
  wall-clock window it scores ATTRIBUTION on the same pass: of the words that
  survived, how many carry the name of the speaker who actually said them.

Validated against four synthetic transcripts built from the truth itself, all
four matching their predictions before the run:

  perfect            recall 1.0   precision 1.0   attribution 1.0
  20% words dropped  recall 0.78  precision 1.0   attribution 0.999
  alternate clips mislabelled     recall 1.0      attribution 0.565
  nothing ever named recall 1.0                   attribution NONE (1470 unnamed)

An unnamed segment is scored as unnamed, never as wrong: the mixed lane publishes
seg_N until a hint binds a turn, and punishing that would punish honesty.
…kers through the bot (#852, #854)

Two runs, two questions, and mixing them is how a green run hides a dead half:
leg 1 asks whether the WORDS are right through the extension (the path a customer
uses), leg 2 asks whether the NAMES are right through the bot, which is the only
thing the bot uniquely carries and the only place #852 lives. Leg 2 is 90s —
long enough to see hints cross the bridge, and it is not measuring content.

Both legs speak the same known-truth WAVs, so neither leans on an STT reference.

The speakers now print AUDIO_START=<epoch> at the instant the fake-capture file
starts feeding the track. That is clip offset zero for score_truth.py: guess it
and whole turns misattribute while the content numbers still look fine.

The runbook states the one gesture that cannot be automated, with the citation,
so no future session rediscovers it the way this one did — by watching a 90s run
produce a 110-byte tape.
The first cut built one reference by interleaving both speakers' clips in
absolute time. LCS is order-sensitive, so an error in the @epoch scrambled that
order and the mismatch came back as lost words. Measured on a WORD-PERFECT
transcript: +5s → recall 0.871, +30s → 0.607, +120s → 0.587. The speakers report
AUDIO_START when the join completes, not when the first sample leaves the fake
device, so a two-second slip is ordinary — this scorer would have invented
double-digit streaming loss out of a clock and I would have gone looking for it
in the pipeline.

Scoring each speaker's own WAV against the transcript removes the clock from the
content numbers entirely: order inside one speaker's truth is fixed by their own
file. The epoch survives only as a coarse window filter, where seconds cannot
matter. The sweep is now flat — 1.0 at +0s, +5s, +30s and +120s.

That alone let two speakers claim the SAME published word (both say "the"),
which cost 25 words of precision on a perfect transcript and misattributed the
same 25. Speakers are now assigned disjointly, every speaker order tried, best
total kept — so a perfect run scores perfectly, which is the least an instrument
must do before it is allowed to grade anything else.

  perfect            recall 1.0    precision 1.0    attribution 1.0
  20% words dropped  recall 0.769  precision 0.987  attribution 0.983
  alternate clips mislabelled      recall 1.0       attribution 0.565
  nothing ever named recall 1.0    precision 1.0    attribution NONE

The prior commit message claimed the epoch drives attribution and not content.
That was backwards: attribution is content-aligned and barely moved (0.98-1.0
across the whole sweep), while content was the half that collapsed.
…#854, #852)

speech_fixture.py already builds the mixed lane's whole input from the cached TTS
clips, whose text is known before any audio exists. Driving the REAL lane over it
with real STT and the real segmenter answers "is the quality good?" with nobody
in the room — and the mixed lane is the one Zoom and Teams both ride.

  content recall       0.924   291 of 315 known words kept, in order
  content precision    0.936   20 published words that were never said
  attribution accuracy 0.947   0 wrong, 1.0% of words provisional
  coverage · retention 0.825 · 0.869
  structure            19 segments · 0 duplicate texts · 1 hole >2s · 18 STT calls

Three consecutive runs returned these to three decimals, down to the STT call
count and the words returned — fixed audio through fixed cut points is
deterministic even across a remote model, so this entry's tolerances are zero
where the tape entries need ±15 calls.

What it does NOT cover is named, so nobody reads it as more than it is: it starts
at captured-signal.v1, so capture delivery is out of scope (the live legs) and so
is whether the platform's DOM lit the right names (#852, the bot leg).

Also: the harness printed "hints 0 matched / 8 missed (100.0% miss)" beside a
0.947 attribution accuracy with zero wrong names. That column is the hint-hop
instrument — did a hint find an open turn AT THE INSTANT IT ARRIVED — and a hint
250ms into a turn lands before any audio has been transcribed into it, reports
missed, and is then used correctly by the binder. Printed that way it reads
exactly like a dead hint stream, which is the #852 symptom I am hunting; it now
says what it measures, and score-fixture no longer calls a rise there WORSE.
Ran the bot from source against a live 35-person Zoom room (the compiled dist —
tsx injects esbuild's __name helper into page.evaluate closures and the page-side
capture dies with ReferenceError, which is a dev artifact, not production). Three
separate silences turned a diagnosable problem into "bridge-crossed=0":

1. The page-side bundle is injected with addInitScript({path}) whose .catch was
   EMPTY. window.VexaBrowserUtils is the entire page side — mixed capture, gmeet
   capture, every platform's speaker watcher — so a failed inject means a bot that
   joins, sits there, and emits nothing at all: no audio, no hints, no page logs.
   That is indistinguishable from a silent room. It now says so and names the path.

2. The page-console forwarder filtered on /perspeaker|capture|stream|vexabrowser|
   audiocontext|error|fail/. Attribution lives entirely in the page-side speaker
   watchers, and none of their diagnostics match those words — including the
   blindness reporter written for exactly this investigation, which could never
   have appeared in a bot log. `speaker` and `hint` now pass.

3. The blindness reporter drew a verdict ("selectors are stale") from one fact.
   A second browser joined to the SAME meeting at the SAME moment found both
   selectors present, so the verdict was wrong and it sent the investigation the
   wrong way for twenty minutes. It now reports counts — [class*=speaker],
   [class*=video-frame], name footers, iframes, viewport, visibility — which
   separate "this client renders no speaker chrome" from "the class names moved",
   and leaves the verdict to whoever reads them.

With all three fixed, from inside a genuine join (Bot admitted, 7 remote streams,
seen=1900 emitted=1900, 486.4s delivered, processor deficit 0.0s — #850's fix
holding live on Zoom): 0 [class*=speaker] nodes in the bot's DOM, and no other
WHO signal in that layout. The cause of that is still open.
)

Zoom does not serve one client. A browser with hardware acceleration and a camera
gets the gallery — speaker bar plus large active tile — and that is what the
EXTENSION runs in, an ordinary human Chrome, where the watcher has always worked.
A browser without them is served a SINGLE-TILE client, and the bot launches with
--disable-gpu, --in-process-gpu and no fake capture device by design (those args
cut per-bot CPU from ~4.4 cores to ~115%). Zoom even says so in a banner on the
bot's own screen: "For a better meeting experience, enable the option 'Use
graphics/hardware acceleration'".

So the module was right, the selectors were right, and the bot was watching for
chrome its client never renders. One selector was missing:
`.single-main-container__video-frame`, whose name lives in the same
`.video-avatar__avatar-footer` nameFromContainer already reads.

Live, 26-person room, bot from source:

  before   NO ACTIVE SPEAKER ×3   bridge-crossed=0     0 hints recorded
  after    NO ACTIVE SPEAKER ×0   bridge-crossed=105   105 hints recorded
           4 distinct speakers named, weighted as they spoke

What found it was a SCREENSHOT. Every DOM probe returned zero and read like a
dead page; the picture of that same instant showed the speaker's name on screen
beside a live level meter. querySelectorAll can only answer questions someone
thought to ask. The bot now drops a periodic PNG when VEXA_DEBUG_SHOT_DIR is set,
and the blindness reporter describes whatever nodes carry a name instead of
counting the ones it expected.
…reason, tallied by the replay (#868, #849)

explainMatch exposes the windowMatch scan with its working shown (which gate
rejected the best candidate, how many names contested, what the flicker
debounce cost); resolveName narrates every provisional hold as
[binder-reject]; quality-mixed tallies the reasons per run. Measured on
botsig9: 26/26 rejects at the confidence gate, every one a 2-candidate
contest. The 0.12.17 handoff rides along as the written ground truth.
…not silently dropped

The Teams voice-outline watcher's only output is a speaker transition, so a
signal it detects but cannot NAME — and a selector that has drifted to match
nothing at all — both produced perfect silence: no log, a clean transcript, and
attribution that reads all-provisional. That silence is what cost a live meeting
to diagnose (#797).

Two self-reports, no selector changes (modernizing them needs a live session):
- The no-name discard at `emit` now logs that a voice signal fired but the name
  resolver returned empty — which name-selector strategies were tried and a
  sanitized element description (tag/class prefix, no user text) — rate-limited
  so a drifted selector reports once per window instead of every frame.
- A blind-watcher interval emits `NO ACTIVE SPEAKER seen in Ns`, mirroring Zoom's
  #852 report word-for-word so one log grep catches both platforms, naming which
  of a silent room / a stale voice selector / absent tiles the DOM supports.

Refs #797 #853
… installs no browser (#852)

The node CI lane (gates.yml) installs no Playwright browsers, so chromium.launch()
was an environment dependency, not a test: it passed locally (cached chromium) and
failed in CI with 'Executable doesn't exist at .../headless_shell', exiting 1. This
was the only real-browser test in the whole node lane.

The test's own docstring already promised 'a DOUBLE rather than a meeting ... no
flake'; the implementation just failed to deliver it. Encode Playwright's injection
lifetime contract directly — addInitScript scripts re-run against every new document,
a page.evaluate runs once against the current document and its timers die when that
document is replaced — install one watcher each way, navigate, count which still
ticks. Deterministic, browser-free, no timers.
…#797, #852)

zoom-speakers.ts:233 read window.innerWidth/innerHeight unguarded inside the
setInterval tick, and the tick has no try/catch: on a DOM double (or any headless
context) without a window global, the blind-report branch threw
'ReferenceError: window is not defined' out of a timer callback and crashed the
process — @vexa/zoom-capture#test failed in CI (uncaught, exit 1) while passing
where a window happened to exist. Guard the viewport read (typeof window check,
visibilityState ?? 'n/a') so the diagnostic degrades to 'n/a' instead of killing
the watcher it exists to help. Align blindness.test.ts's assertion with the log
text the source actually emits ('none of … exist here'). These match the versions
already on the #852 stack; 797 had the pre-refinement copies.
… re-arm (#852)

45333ed added page.on('framenavigated') in the capture bridge; the
zoom-speaker-wiring fake page lacked .on/.mainFrame. Mechanical shim.
…en hashed name-class selectors drift (#853)

Live #853 instrumentation witnessed the #797 red on today's Teams web DOM: the
bot IS served the participant names (visible on-screen in each tile's name pill),
but every teamsNameSelector missed — the lead selector div[class*="___2u340f0"]
is an extension-era minified hash that has since rotated (live tile name div now
carries only atomic hashes like ___12zni01 f1cmbuwj …). extractName returned '',
so the no-name guard discarded every detected voice-outline transition.

Voice-outline detection itself is healthy (observing N with signal), so this is
pure name-selector drift, not the #852 different-DOM class. Rather than chase a
new hash that will drift again, add a selector-agnostic structural fallback that
runs only after all explicit selectors miss: scan the tile's text-bearing leaves
for a name-shaped string (has a letter, not a timer, not a UI-control word). The
old selectors stay as the preferred fast path.
…853, #797)

Hoist extractName to an exported pure fn extractTeamsSpeakerName(element, opts?)
so the structural fallback can be exercised without a live meeting, and add
name-drift.test.ts. The fixture is RECONSTRUCTED to mirror the live-witnessed
class structure (___1504rl1 container / ___12zni01 atomic-hash leaf, no name
attrs) — not a captured DOM dump. It reproduces the property that broke the
resolver: a name node reachable by traversal but by no explicit selector.

Asserts, offline: explicit selectors only → '' (the #797/#853 red); with the
structural fallback → "Dmitry Grankin" (green); the fast path still wins when a
real selector matches; and the timer/forbidden-word guard keeps a control-only
tile at '' (no false name). Wired into the module's npm test; isolation stays
zero-dep (hand-rolled DOM shim, no jsdom).
@DmitriyG228 DmitriyG228 added this to the v0.12.17 milestone Jul 21, 2026
#853)

The live Teams DOM carries mute controls as tile leaves; 'mic' does not
substring-match 'mute', so a lone Mute label would have passed the
name-shaped guard. Add 'mute' (covers unmute).
@DmitriyG228

Copy link
Copy Markdown
Contributor Author

Live GREEN witnessed (see #853 comment thread): the fixed build resolved SPEAKER_START: Dmitry Grankin ×4 with bridge-crossed 0→24, no-name reports → 0, on the exact DOM that returned empty pre-fix. This PR now carries RED (live) + fix + offline red→green regression + live GREEN. Remaining open: a captured tile-DOM fixture + a WAV guest leg for binder-matched>0 (needs one clean rejoin; not blocking).

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