Skip to content

Panelist loudness metering and readiness board (BS.1770-4) - #249

Merged
iamfatness merged 15 commits into
mainfrom
feat/panelist-feedback
Sep 5, 2026
Merged

Panelist loudness metering and readiness board (BS.1770-4)#249
iamfatness merged 15 commits into
mainfrom
feat/panelist-feedback

Conversation

@iamfatness

Copy link
Copy Markdown
Owner

Subsystem 1 of the panelist feedback system — a preshow tool for Zoom panel shows. It measures every panelist's loudness to ITU-R BS.1770-4 and draws an operator-facing readiness board as an OBS source, so a producer can tell before air whether everyone is arriving at a comparable level.

Design spec: docs/superpowers/specs/2026-09-05-panelist-feedback-design.md
Plan: docs/superpowers/plans/2026-09-05-loudness-engine-and-meter.md

The detector and framing subsystems are planned but not in this branch.

Why the numbers are relative, not absolute

The operator doesn't primarily care that a panelist hits −23 LUFS; they care that panelist A isn't 6 LU louder than panelist B. So the headline figure per row is deviation from the panel median of gated integrated loudness. Median, not mean, so one hot mic can't drag the reference. R128 / A-85 / streaming targets are selectable presets; panel median is the default.

Three things that would otherwise ship silently wrong

  • Sample rate is derived, never assumed. BS.1770-4 publishes coefficients only for 48 kHz; Zoom commonly sends 32 kHz. Hardcoding the published table reads −18.66 LUFS where the truth is −19.98 — 1.3 LU off and entirely plausible-looking. Coefficients are derived at the runtime rate and the failure is pinned by a test.
  • The gate is load-bearing, not an optimisation. A panelist is silent ~80% of a preshow. Ungated, 4 s of speech inside 20 s reads −27.08 instead of −20.16. Integration is properly two-pass: absolute at −70 LUFS, then relative at −10 LU below the absolute-gated mean.
  • Only panelist sources vote. Active Speaker (a duplicate of whoever is talking) and Audience (the whole-room mix) are excluded from the median. Caught in final review; they would have shifted the reference by a full LU.

Notes for review

  • Metering runs on the audio lane, fed inside the drain loop — media events are coalescing prompts, so per-wakeup feeding would silently under-measure whenever events coalesce.
  • The graphics thread uses try_lock and never blocks on the audio drain mutex; a skipped source keeps its row and simply reports no measurement.
  • The relative gate is a factor of ten in linear mean square, so pass 2 is z > 0.1 * mean with no log10 in the loop — algebraically identical, and all pinned figures still assert unchanged.
  • kLoudnessBoardMinRowPx is slot pitch, not drawn height. The test asserts last.h + gap >= min; the naive form is unsatisfiable for any positive gap. Documented in CLAUDE.md so it doesn't get "corrected" back.

Known gaps

  • Live verification is outstanding. Row order stability, no-audio→measuring→verdict transitions, the Reset button, and single-log-line behaviour need a real meeting with the plugin installed. This repo has explicitly ruled against a headless harness, so nothing here substitutes.
  • Short-term LUFS is half-delivered by design. It drives the deviation bar (live movement) but the row text shows the integrated verdict. Putting short-term in the label signature would rebuild 15 text children ~10x/sec — the exact churn the refresh gate prevents. Recorded as a deliberate decision, open to reversal.
  • Seven minor findings triaged as non-blocking, listed in the branch's SDD ledger.

Verification

59/59 unit tests green; full plugin build clean (obs-zoom-plugin.dll). Built with VS 2022 x64. No existing test expectation values were modified anywhere in this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n

Grok and others added 15 commits September 5, 2026 13:15
Preshow tool for Zoom panels: per-panelist BS.1770 loudness metering,
a shared subject detector, Tiles auto-framing, and a framing-advice
return feed routed over the OBS Virtual Camera.

Records the research that unblocked it: the return path needs no Zoom
video-send entitlement, and libfacedetection (BSD-3, vendored as source,
YuNet weights compiled in) satisfies the landmark requirement without a
runtime dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
Tiles subscribes at P360, but subscriptions are shared and upgrade-only:
a participant already carried at 1080p for the active-speaker feed or an
ISO output keeps that quality and the tile reuses it. So the panelists
being checked already have real pixels for the crop math.

Auto-framing therefore adds no subscription pressure and cannot
reintroduce the 2026-08-17 meeting throttle. The existing ISO and
program-output controls are the resolution lever; no new control needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
Three plans, one per independently shippable subsystem:
  loudness engine + readiness board (7 tasks)
  subject detector (8 tasks, benchmark-gated)
  framing consumers + self-exclusion (11 tasks)

Cross-plan fix: SubjectFrame was defined by both the detector and the
consumers plan with different member defaults. src/subject-frame.h is now
the single definition and zoom-subject-source.h includes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
…ange

loudness_meter_configure() cleared filter state on a mid-source format
change (Zoom renegotiation, Mix/Isolated role flip) but not the gated
integration window Task 3 added. Blocks measured under the old biquad
coefficients/channel weighting could survive into an average with blocks
measured under the new ones, corrupting a panelist's in-progress check.
Factored the clearing shared by configure() and reset_window() into
loudness_meter_clear_window() so the two paths cannot drift apart; added
a covering test.
…ith the Solid technique

Adds the CoreVideo Loudness Meter OBS source: a custom-draw readiness
board with one row per live CoreVideo audio source, drawn with the
existing Tiles Solid technique (no new .effect file) and private
child text sources for panelist name/value labels. Rebuilds its model
from corevideo_loudness_readings() at 10 Hz on the graphics thread
(video_tick), never per-frame, to avoid contending with the audio
lane's g_sources_mtx + per-source mutex on the 60 Hz render path.

Registered alongside the Tiles/SuperSource sources in plugin-main.cpp,
wired into the obs-zoom-plugin source list in CMakeLists.txt, with
locale strings added to data/locale/en-US.ini.

Also bounds the row count a canvas can show: loudness_board_visible_rows()
and kLoudnessBoardMinRowPx=24 in loudness-board.h cap rows to what stays
legible rather than shrinking into an unreadable texture on a large panel.

Test correction: the pinned test for the row cap asserted
`last.h >= kLoudnessBoardMinRowPx`, but that can never pass for any
implementation that also satisfies the pinned
`loudness_board_visible_rows(360, 40) == (360 - kLoudnessBoardHeaderPx) /
kLoudnessBoardMinRowPx` equality. kLoudnessBoardMinRowPx is the row's
SLOT PITCH (its own doc comment says "plus the gap"), not the drawn
height loudness_board_row_rect returns after subtracting
kLoudnessBoardRowGapPx. Corrected the assertion to
`last.h + kLoudnessBoardRowGapPx >= kLoudnessBoardMinRowPx`, which
matches the constant's documented meaning and still fails correctly
against a broken/uncapped row count. Do not "fix" this back to comparing
last.h alone -- verified by hand and by running the original assertion,
which fails deterministically (21 < 24) given the pinned capacity of 13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
…te, visibility poll, thread-safe probe, true 10Hz cadence

Five review defects in the Task 6 meter source, all confirmed in the
brief's own verbatim code:

- set_text_child() (obs_source_update(), which takes libobs's own
  source lock and allocates) was called while holding ctx->mutex, in
  both the per-row label loop and the header update. Now collects
  {source, text} pairs under the lock and applies them after release.

- The label refresh was gated on model.signature alone, which encodes
  panel content but not `shown` (a function of canvas height). Growing
  the source's height could reveal rows still holding an empty applied
  string with no signature change to trigger a refresh -- worst case a
  silent preshow panel where every row sits at "no audio" forever.
  Added `applied_shown` and a new pure helper,
  loudness_board_needs_label_refresh() in src/loudness-board.h, pinned
  with four cases in tests/loudness-board-test.cpp (the load-bearing
  one: shown changes alone, signature unchanged, must still refresh).

- meter_video_tick polled corevideo_loudness_readings() at 10Hz even
  for a meter source not on any visible scene. Now gated on
  obs_source_showing(ctx->source), matching the fix already on record
  for the Talkback dock's mirror-image roster-poll defect.

- meter_text_source_id()'s cached/probed function-statics were a real
  data race across concurrently-created meter sources (UI thread,
  scene-load thread, control-API thread). Replaced with a function-
  local static initializer, which C++11 guarantees runs exactly once.

- rebuild_accum was zeroed instead of decremented after firing,
  discarding the remainder and landing every 7 frames at 60fps
  (~117ms, ~8.6Hz) instead of the documented 10Hz. Now subtracts the
  interval so the remainder carries forward.

Build-tests 59/59 green, full obs-zoom-plugin build clean, both
re-verified immediately before this commit. No other behaviour
changed -- the per-frame model copy, draw-loop magic numbers, and
unused header-slot value source are explicitly deferred to the final
whole-branch review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
…yout invariants

Task 5 already documented the audio-lane wiring (drain-loop feed, wire-format
tap, roster caching, lock order, window reset on resubscribe). This adds the
invariants it didn't cover: runtime-derived K-weighting coefficients (never
pinned 48 kHz), the shared clear_window() between configure()/reset_window(),
the two-pass gate as load-bearing (not an optimisation) plus the median
reference, the board's slot-pitch-vs-drawn-height row sizing, the
signature+shown label-refresh gate, and the 10 Hz poll's showing-gate and
subtracting accumulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
CRITICAL: only Participant-kind sources vote on the panel median or appear
as board rows. ActiveSpeaker (a duplicate of whoever is talking) and
Audience (the whole-meeting mix) used to be counted and rendered as
phantom "- unassigned -" rows. LoudnessReading now carries `kind`, and
the filter lives in the pure loudness-board.h logic (loudness_panel_median
and loudness_board_build), not the OBS glue, so it is unit-testable.
Chosen approach: exclude non-Participant readings entirely rather than
show them read-only -- a duplicate-speaker or room-mix row is the same
mystery-row defect wearing a label, not a fix for it.

IMPORTANT: the deviation bar is now driven by short-term deviation
(falling back to integrated deviation, never vanishing) via the new pure
selector loudness_board_bar_input(), while row TEXT stays on the
integrated verdict per the design ruling. Kept out of
LoudnessBoardModel::signature on purpose, since that would force a
~10x/sec text-child rebuild for a value the text never shows.

IMPORTANT: loudness_meter_integrated()'s relative gate no longer calls
log10() per gated block. L(z) > L(mean) - 10 <=> z > mean/10, so the
comparison moved to the linear domain, removing up to 6000 log10() calls
per source per 100ms poll from the graphics-thread-adjacent audio drain
and a float round-trip that could flip a boundary block. The pinned
-27.08/-20.16 and -22.96/-20.06 test figures are unchanged.
loudness_meter_configure() now reserves `gated` to kLoudnessMaxGatedBlocks
up front so the audio lane is provably allocation-free after configure.

IMPORTANT: corevideo_loudness_readings() now try_locks each source's
mutex instead of blocking, since output_audio_frame() holds the same
mutex across a whole drain (SHM open, obs_source_output_audio, rate-
limited disk-writing blog calls). A busy source is skipped for one 10Hz
poll rather than stalling the OBS graphics thread.

Documented (not previously stated) that the board shows SOURCES an
operator created, not the live roster -- a panelist with no source never
appears, and a rejoin drops an existing row to unassigned/no-audio until
re-pointed. Added to zoom-loudness-meter-source.h and CLAUDE.md.

MINOR: corrected two false comments claiming libobs caches/dedupes
effects created from a file (gs_effect_create_from_file allocates a
fresh effect every call) -- corrected in zoom-loudness-meter-source.cpp
and zoom-tiles-effect.h before a future "dedupe the two loads" cleanup
turns the harmless truth into a double-free at unload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
Regression A: corevideo_loudness_readings() continue'd past
out.push_back(r) on a failed ctx->mtx try_lock, dropping the whole
reading rather than just its numbers. That shrank the row count
loudness_board_row_rect() divides the canvas by (every other row
visibly resizes), dropped that source's vote from the panel median
for one poll (every other panelist's pass/fail could move for 100ms),
and changed model.signature (forcing the full child-text rebuild the
signature gate exists to prevent) -- against a mutex that is busy
often, not rarely, since output_audio_frame() holds it across a real
drain. The reading is now always pushed with only its measurement
fields left at their unavailable defaults, which loudness_board_build()
already renders correctly as NoAudio. display_name moved to its own
dedicated name_mtx (instead of ctx->mtx) so a row's identity survives
a poll where its numbers don't -- otherwise the name itself would have
flickered to "- unassigned -" on exactly the polls this was meant to
fix.

Regression B: has_short_term_deviation was populated whenever a
short-term reading and a reference existed, with no gate on the row
having a real verdict. loudness_meter_short_term() is ungated, so a
silent source's noise floor (~-90 LUFS) against a real reference
produced a full-length bar pegged hard left, in idle grey, on every
silent row -- the board's normal state. Now gated on has_deviation
itself (same Pass/Loud/Quiet condition), so NoAudio and Measuring rows
draw no bar at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqDL6gcoVvEMXv7Qwcni9n
#define PROP_HEIGHT "canvas_height"
#define PROP_RESET "btn_reset_windows"

static const char *kMeterSourceId = "corevideo_loudness_meter_source";
// handle between two owners that each call gs_effect_destroy() on it once is
// a double-free at unload.
static TilesEffect s_meter_effect;
static bool s_meter_pass_failed_logged = false;
return src;
}

static void set_text_child(obs_source_t *src, const char *text)
readings, kind, tol, kLoudnessBoardMinBlocks);

std::lock_guard<std::mutex> lk(ctx->mutex);
ctx->model = std::move(model);
// change in row count cannot silently steal the header's text child --
// hence the cap is kMeterMaxRows - 1 and not kMeterMaxRows.
static constexpr size_t kMeterHeaderSlot = kMeterMaxRows - 1;
const size_t total = model.rows.size();
Comment thread src/loudness-board.h
// not cost a text-source rebuild.
std::string sig;
sig.reserve(model.rows.size() * 24 + 16);
char buf[64];
Comment thread src/loudness-board.h
std::string sig;
sig.reserve(model.rows.size() * 24 + 16);
char buf[64];
std::snprintf(buf, sizeof(buf), "R%d:%s%.1f|",
Comment thread src/loudness-board.h
sig += buf;
for (const LoudnessBoardRow &row : model.rows) {
sig += row.name;
std::snprintf(buf, sizeof(buf), "|%d|%s%.1f;",

static std::string row_value_text(const LoudnessBoardRow &row)
{
char buf[96];
char buf[96];
if (row.has_deviation) {
if (row.has_integrated) {
std::snprintf(buf, sizeof(buf), "%+.1f LU %.1f LUFS %s",
"MIC CHECK reference: %s (waiting for a first check)",
kind);
} else if (shown < total) {
std::snprintf(buf, sizeof(buf),
kind, m.reference_lufs, static_cast<int>(shown),
static_cast<int>(total));
} else {
std::snprintf(buf, sizeof(buf),
ctx->source = source;
meter_apply_settings(ctx, settings);

char private_name[64];

char private_name[64];
for (size_t i = 0; i < kMeterMaxRows; ++i) {
std::snprintf(private_name, sizeof(private_name),
std::snprintf(private_name, sizeof(private_name),
"corevideo_meter_name_%d", static_cast<int>(i));
ctx->rows[i].name = make_text_child(private_name, 20, 0xFFF2F5F8u);
std::snprintf(private_name, sizeof(private_name),
@iamfatness
iamfatness merged commit da688ac into main Sep 5, 2026
34 checks passed
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.

2 participants