Panelist loudness metering and readiness board (BS.1770-4) - #249
Merged
Conversation
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
…ntime sample rate
…16 interleaved PCM
…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.
… pinned layout arithmetic
…per-source readings
…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(); |
| // not cost a text-source rebuild. | ||
| std::string sig; | ||
| sig.reserve(model.rows.size() * 24 + 16); | ||
| char buf[64]; |
| std::string sig; | ||
| sig.reserve(model.rows.size() * 24 + 16); | ||
| char buf[64]; | ||
| std::snprintf(buf, sizeof(buf), "R%d:%s%.1f|", |
| 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), |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdPlan:
docs/superpowers/plans/2026-09-05-loudness-engine-and-meter.mdThe 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
Notes for review
try_lockand never blocks on the audio drain mutex; a skipped source keeps its row and simply reports no measurement.z > 0.1 * meanwith nolog10in the loop — algebraically identical, and all pinned figures still assert unchanged.kLoudnessBoardMinRowPxis slot pitch, not drawn height. The test assertslast.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
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