Repository navigation
Conversation
With its fader and soft clipper off, the output bus leaves the sample format open and takes it from its first input. When the bus negotiates before any input has caps, which happens whenever an input joins after the flow started and, on GStreamer 1.24, whenever the mixer's first timeout fires before PLAYING, it queries downstream instead. That query passes through capssetter, which answers with its filter or ANY and never with what is downstream of it. The mixer then picks S32LE blind, and a consumer that takes a single format without converting refuses it: not-negotiated from the source. Answer caps queries on the capssetter's sink pad with what downstream of it accepts, with the fields the setter stamps opened again. A consumer that converts still sees every format and the blind pick is unchanged (S32LE); one that pins a format now gets it. This is the intermittent not-negotiated failure of the liveaudiorouter tests on Linux CI. The new tests connect the input only after the bus has negotiated, which fails without this change on every run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A live audiointerleave whose first deadline passes before its inputs' caps arrive aggregates with no output format and posts "not negotiated". Starting its output at the first input buffer removes that window. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
9570417 to
021c3b0
Compare
The caps answer from the output bus's capssetter now lists downstream's caps twice: first with downstream's rates, so a bus that picks its output blind picks a rate a non-resampling consumer takes, then with any rate, so that a rate locked by a mixer further downstream does not restrict the bus's inputs. The mixer passes the answer on to its own input pads; carrying the rate alone refused a 48 kHz input joining a router that feeds another mixer. Once an input has caps, the mixer still pins its other inputs to that input's rate, as before. Float formats come first within each, since the mixer fixes its format from the first entry and a converting consumer lists its own output format first: the bus would otherwise sum in 16-bit and saturate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
021c3b0 to
7d726d0
Compare
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — the cause is in the block, the probe is per-query with no captured reference, and the six new tests run in CI through the real builder.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The probe fires per query, not per buffer, and holds no reference to the element | CONFIRMED |
see Diagnosis |
| The tests go through the block, not a rebuilt topology | CONFIRMED |
backend/tests/liveaudiorouter_test.rs:264 — let result = liveaudiorouter::LiveAudioRouterBuilder |
| The new tests executed in CI | CONFIRMED |
Check (Linux) job 108551289114 at 7d726d0: all six new tests ok, liveaudiorouter_test 34 passed |
capssetter answers a caps query with the filter or ANY, and audiomixer then picks S32LE/44.1 kHz |
EXTERNAL |
upstream behaviour; assumed, and consistent with the reverted arms failing as the body's table says |
Diagnosis — Right layer. The probe is a query probe that captures nothing, and it takes the setter from pad.parent_element() on each call:
backend/src/blocks/builtin/liveaudiorouter.rs:736 — sink.add_probe(gst::PadProbeType::QUERY_DOWNSTREAM, |pad, info| {
The answer is built from what the consumer really takes: backend/src/blocks/builtin/liveaudiorouter.rs:754 — let mut accepted = src. The stamped channel-mask is removed again, so caps_out_O's unpositioned mask still intersects. Coverage is BOUNDED to the output bus. A second input at another rate, when neither input resamples, is still refused. That is not this PR's cause: #886 tracks it, found while reviewing this one.
The ordering is the non-obvious part. Downstream rates come first so a blind pick lands on one the consumer takes. The same caps at any rate come second, so a rate a downstream mixer has locked does not refuse a later input.
Behaviour change worth knowing before merge. With fader and clipper off, a consumer that accepts float now gets an F32LE bus even when every input is integer. It used to get the first input's format. It matches the F32LE pin #795 (f63d5e9) added on the fader path, and it removes fixed-point saturation on fan-in. A consumer that takes only an integer format still gets it (...an_integer_consumer).
Radius — LOCAL: one element's probe in one block. Every builtin.liveaudiorouter output bus gets it, and no other block calls make_capssetter.
Overlaps — none owns this decision. #886 is the follow-up on the input side. #785 (Option D, decided) will edit the same test file: textual only.
Tests & CI — every check green at 7d726d0. macOS/Windows skipped; nothing platform-specific. The flake-rate numbers (15/30 → 0/30) come from the author's rig and cannot be re-measured here. They do not affect the verdict: the late-input tests prove the mechanism deterministically. The same flake stopped Check (Linux) on #879 today (job 108488781339, a_routing_change_moves_audio_between_output_channels_while_playing, not-negotiated). Merging this should let those reruns go green.
Confidence: HIGH
* fix(whip): null a session bin whipserversrc removes mid-teardown
whipserversrc ends a WHIP session by taking the session's internal bin to
NULL and removing it from itself, on its own thread. Nothing waits for
that: the guard its unprepare() blocks on is cleared before the task
touches the bin. When Strom tears the session pipeline down at the same
moment -- both are woken by the same ice-connection-state emission -- the
pipeline's PLAYING->PAUSED step raises the already-NULL bin back up (a
GstBin is never skipped for being below the target state), and the
removal lands while it is PAUSED:
GStreamer-CRITICAL: Trying to dispose element bin31, but it is in
PAUSED instead of the NULL state.
The bin and everything under it -- webrtcbin, rtpbin, its UDP sockets and
threads -- is then dropped without ever running READY->NULL.
Catch the element on the way out instead: element-removed fires with no
bin lock held and the child already unparented. Lock its state, then set
it to NULL, so whichever thread reaches the child's state lock second
sees the other's effect.
Also wait out an ASYNC return in teardown_session_pipeline. Nothing in a
session pipeline goes async on the way down today, and it was not the
cause here, but dropping a pipeline mid-transition would leave the same
kind of orphan.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(whip): drop the debug trace from the orphan_guard module doc
The two paragraphs below it already state the rule the trace illustrates:
a plain child at NULL is skipped by a downward transition, a GstBin is
recursed into unconditionally, and the bin leaves the pipeline before the
descent can reach it again. The interleaving of one debug run is the
argument for that rule, which belongs in review, not in the module.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(release): bump version to 0.6.9 and document 0.6.6-0.6.9 (#828)
Bump the workspace version to 0.6.9 (Cargo.toml, Cargo.lock, the OpenAPI
info.version in openapi.json) and write the changelog entries that were
never added for 0.6.6, 0.6.7 and 0.6.8, plus the new 0.6.9 entry.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: catch up with 0.6.9 — RTMP output, and the MCP server that is gone (#830)
The standalone stdio MCP server was removed in #765, which updated MCP.md,
INTEGRATION.md, FAQ.md and DOCKER.md's "MCP in Docker" section but left two
references behind: DOCKER.md still described the backend builder stage as
building "the backend and the MCP server", and FEATURE_SUGGESTIONS.md still
built an idea on top of "the existing MCP server". The Dockerfile has no such
stage. The README now says outright that MCP is served by the backend itself,
so a reader does not go looking for a binary that no longer ships.
The RTMP Output block landed in 0.6.9 (#827), so the README's protocol and
block lists name RTMP, and the "publish to the big live platforms" idea no
longer describes the transport side as still open — what is left there is the
operator write-up and the encoder profile question. TAMS Output shipped back
in 0.6.6 and was never listed either.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(config): map every STROM_* env var explicitly (#831)
The figment env provider split variable names on every underscore, so it
could only ever express a single-word field name. `server.port` worked;
`STROM_SERVER_ICE_TRANSPORT_POLICY` became `server.ice.transport.policy`,
matched no field, and was silently dropped. `ice_servers`, `tls_cert` and
`tls_key` had already been special-cased for exactly this reason, but the
rest of the documented variables were no-ops.
Replace the generic provider with an explicit table of variable to config
key. Scalars and comma-separated lists are handled separately, so the
list keys (`ice_servers`, `cors_allowed_origins`,
`sap_multicast_addresses`) no longer need one-off blocks.
Now working, previously ignored:
- STROM_SERVER_ICE_TRANSPORT_POLICY
- STROM_LOGGING_LOG_FILE, STROM_LOGGING_LOG_LEVEL
- STROM_STORAGE_DATABASE_URL, STROM_STORAGE_DATA_DIR,
STROM_STORAGE_FLOWS_PATH, STROM_STORAGE_BLOCKS_PATH,
STROM_STORAGE_MEDIA_PATH, STROM_STORAGE_CEF_CACHE_PATH
- STROM_SERVER_CORS_ALLOWED_ORIGINS
- STROM_DISCOVERY_SAP_MULTICAST_ADDRESSES
The storage keys had working clap aliases (STROM_DATA_DIR and friends),
so only the documentation was wrong there. The logging keys had no
working path at all: log file and log level could only be set from TOML.
An invalid STROM_SERVER_PORT now reports what is wrong with it instead of
failing inside figment's extraction.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(whip/whep): expose a per-block ICE transport policy (#832)
* feat(whip/whep): expose a per-block ICE transport policy
The ICE transport policy was server-wide only: forcing TURN relay for one
endpoint behind a network that host and server-reflexive candidates cannot
cross pushed every other WebRTC block onto relay as well.
WHIP Input, WHIP Output, WHEP Input and WHEP Output now each expose an
`ice_transport_policy` property. It defaults to the empty value, which
inherits `server.ice_transport_policy`, so no existing flow changes how it
negotiates; "relay" forces that block's candidates through the configured
TURN server, and "all" opens one block up on a relay-only server.
Resolution lives on `BlockBuildContext` so all six builder paths share it,
and a value that is neither "all" nor "relay" falls back to the server
setting — the value reaches webrtcbin via `set_property_from_str`, which
panics on an unknown nick, and properties arrive over the API.
The stable `whepsrc` path never applied the policy at all; it does now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(whip/whep): set the ICE policy on the element that owns the webrtcbin
whipsink, whepsrc and the webrtcsink-based whipclientsink/whepserversink
expose `ice-transport-policy` themselves and hand it to every webrtcbin they
create, as they create it. Setting it there is more robust than reaching the
child from a `deep-element-added` handler, which depends on the child's name
and on running before that session's pipeline leaves READY — the last state
in which webrtcbin accepts the property.
The child-level handlers stay: they cover an element version without the
property, and they carry unrelated setup (opusenc, stats registration,
fakesinks on unlinked TransportReceiveBin pads) that has to stay anyway.
whipserversrc and whepclientsrc have no such property, so those two blocks
still rely on the handler alone.
Reading the value back off the built element makes five of the six paths
testable without a negotiated session, so each one now has a guard that fails
if the block stops carrying its own policy.
Also warn when a resolved policy of "relay" meets an ice_servers list with no
TURN server: the block then gathers no candidates at all and never connects,
and nothing else reports it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(agent): make triage and review look for what is already open (#841)
A LOCAL diff can settle a GLOBAL design question by being merged first, and
nothing in the protocol asked either stage to look. Issue #837 was triaged on
its own merits — two options, both inside one block, radius SHARED, recommend
the smaller — while citing #682, the open issue that owns where GL memory gets
downloaded, and without mentioning open PR #802, which changes the very linker
that block's internal links go through.
PROTOCOL.md gains a mandatory search for open issues and PRs that own the same
design decision or change the same code path, and an overlaps= marker field.
TRIAGE.md requires the proposal to name what it found, and to offer "wait for
#N, change nothing here" as an option a human can choose — pausing is a
legitimate outcome and not the agent's to decide. REVIEW.md gains the same
check, and may not approve a PR that settles an open design question on the
project's behalf. board.sh reports a non-none overlaps= against the candidate.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gst): download GL memory where a consumer cannot take it (#802)
* fix(gst): download GL memory where a consumer cannot take it
A consumer whose external video input is a videoconvert or videoscale with an
encoder behind it answers a caps query with system memory only, so a
video/x-raw(memory:GLMemory) producer shares no format with it and the link is
refused before any caps are negotiated. Nothing retries a link that failed for
want of a common format, so a Vision Mixer on the GPU backend with gl_download
off leaves the Video Encoder and everything below it with no data while the
flow reports Playing.
Fronting the consumer with a queue or an identity does not open the link: both
answer a caps query by proxying it downstream. Adapt instead at the one point
where both pads are known. When a plain link fails and the producer offers GL
memory and nothing else while the consumer wants raw video in system memory,
link them through a gldownload. A path that links on its own never reaches this
code, so no flow pays for a download it did not need, and CUDA, NVMM, D3D11 and
VA producers are left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gst): adapt only a format refusal, and undo a splice that fails
Run the GL download fallback only for a PadLinkError::Noformat refusal. An
already-linked consumer passes the caps test just as a system-memory one does,
and a gldownload cannot link to it either.
Remove the gldownload again if either half of the splice is refused, so the
producer is left unlinked rather than feeding an element with no output.
Require the consumer to take system memory, which is what a gldownload
produces, rather than any memory other than GL: a CUDA- or D3D11-only consumer
cannot be fed through one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gst): adapt GL-memory links that leave a pad unnamed
The linker adapted only links that named both pads. A link that names one
pad or none is made element to element, and GStreamer refuses it without
saying why, so a GL producer linked that way still failed.
On such a refusal, pair the named or unlinked pads on each side, link the
first pair a gldownload would join to get the concrete refusal, and send it
through the pad-level path.
Also declare gstreamer1.0-gl in CI, which the tests need for gltestsrc and
gldownload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(devicesrc): let the capture front negotiate GL memory (#842)
A capsfilter naming only `video/x-raw` means `memory:SystemMemory`, not
"any". A camera that delivers GL memory and nothing else — an AVFoundation
camera behind avfvideosrc — shares no format with the capture front and
cannot negotiate it at all.
The front now offers both memory types, system memory first so a device that
can deliver either keeps producing what it does today. No download is asked
for here: where the camera picks GL memory, the linker splices the gldownload
in front of the block's system-memory output.
Reported in #837.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* tools: add a probe for how a host bridges GPU video memory (#843)
* tools: add a probe for how a host bridges GPU video memory
Phase 1 of #682 needs the same table from every platform and GPU vendor we
support: which memory types a host can produce, what autovideoconvert selects
for each, and whether a GPU-to-GPU path stays on the GPU. Answering that by
hand produces four differently shaped answers.
One script, the same command everywhere. It detects what the host can produce
and probes only that, so a machine with no NVIDIA card reports the GL rows and
says the CUDA ones were skipped rather than failing. Every row is a real
pipeline: OK means buffers flowed.
It needs GStreamer on PATH and nothing else — no build of Strom, and no GPU
for the parts that do not use one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* tools: print the selected converter bin on a failure too
The sub-bin autovideoconvert picks is the answer the probe exists for, and a
chain that fails still says what was selected — on a headless NVIDIA host it
reaches for GL and dies on EGL, which is the finding, not the noise. The error
line is no longer truncated either: the reason is the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* tools: report the converter selection that a grep was missing
The Windows run came back with every D3D11 row blank, which read as "nothing
was selected" and was the opposite of what happened: autovideoconvert had
chosen d3d11convert. Match the element inside the bin as well as the bin name,
so a selection is reported whichever shape the log takes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(docker): pin the patched decklink plugin by digest (#836)
The runtime stage fetched libgstdecklink.so from a GitHub release by tag name and wrote it straight into the GStreamer plugin directory. Release assets are mutable, so a replaced asset would be installed into a system library path and loaded into the strom process with nothing in the build failing.
Each artifact is now pinned to its SHA-256 and verified before it is installed. The download lands in /tmp, sha256sum -c gates it, and only then does install(1) place it in the plugin directory. An architecture with no pinned digest fails the build with a clear message.
Verified locally at head 87e0a0e: both architectures install the pinned digest, a wrong digest and an unpinned architecture both fail the build before install is reached.
* fix(videoenc): reject out-of-range client properties instead of panicking (#821)
Blocks applied client properties with the raw GStreamer setters that #724 stopped
using: the checked conversion it added was private to a private module and
unreachable from a block. x264enc's bitrate is a guint of 1..=2048000, so a
bitrate of 0 aborted the thread whenever g_param_value_validate had to clamp,
unwinding across the axum handler — the connection dropped and start_flow's error
path never ran, leaking the flow.
Exposes the checked conversion as pub(crate) set_property_checked and routes all
32 text-form setters in set_encoder_properties through it.
backend/tests/block_property_range_test.rs drives the real VideoEncBuilder. The
test alone is red in CI and green with the fix, so it guards the fix rather than
demonstrating it.
Refs #769 — 31 set_property_from_str sites remain under backend/src/blocks/, so
the issue stays open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(windows): fail the job on a failing first command, and install every plugin (#838)
A run: step on a Windows runner defaults to pwsh, where a native command exiting
non-zero neither aborts the script nor fails the step — only the last command's
exit code counts. A failing cargo test followed by a passing one read as green for
as long as this job has existed. The job now takes the shell: bash default every
other job in the workflow already has.
Install GStreamer cannot take that default (it needs Invoke-WebRequest and
Start-Process), so it checks exit codes by hand instead: both msiexec calls go
through a helper using -PassThru that throws on a non-zero ExitCode, and choco
checks LASTEXITCODE. Start-Process without -PassThru discarded the code entirely,
so a failed install surfaced twenty minutes later as a missing element and looked
like a broken test.
ADDLOCAL=ALL installs every plugin group rather than the MSI's default feature
set, which left out x264enc among others and made the recorder tests skip green on
this runner alone. With the job able to report a failure and every plugin present,
STROM_REQUIRE_GST_PLUGINS is enabled here.
Three Windows exclusions, each naming a tracked defect rather than working around
one: the mpegtssrt streamheader test (#834), the recorder pipeline-start EOS test
(#835, left as ignore so it stays visible in the run), and the rtmp package-hint
assertion, where one MSI legitimately carries both packages — asserted as an
equality on Windows rather than dropped, so a future split fails loudly.
Two test guards that could not fail were closed in the same pass: recorder
container_available now shares the STROM_REQUIRE_GST_PLUGINS assert instead of
skipping silently on a missing muxer.
Verified: Build (Windows) green at a25ec9e, 26 minutes. The install step ran the
new exit-code handling, both modified tests executed on Windows, and no container
was skipped.
Closes #702
Closes #703
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(monitor): sample per-thread CPU on macOS via mach thread_info (#722)
Per-thread CPU sampling read /proc behind cfg(target_os = "linux"), so the
thread and CPU views were empty on macOS. Adds a mach thread_info backend
reporting the same quantity in the same units, sharing cpu_usage_percent()
with the Linux path, which is unchanged.
Also fixes an EXC_GUARD crash: the registry stored a mach port name taken
with pthread_mach_thread_np(), which takes no user reference. Once the
thread exited the name was freed and could be recycled to a guarded port,
and thread_info() on it killed the process. ThreadHandle now owns a send
right and releases it on drop.
Verified on this repository's CI at c0466de3: Linux, WASM and the API
contract check green, and Build (macOS) green via the ci:macos label with
clippy -D warnings and the full test suite under STROM_REQUIRE_GST_PLUGINS
and STROM_REQUIRE_GL.
* fix(vision mixer): keep per-pixel alpha on keyed pads with an alpha-less output_format (#748)
Setting an alpha-less output_format (I420, NV12, RGB, ...) on the CPU path
destroyed the per-pixel alpha of every keyed pad: a DSK graphic's transparent
areas composited as the graphic's own black, and the full-canvas multiview
overlay turned black.
compositor blends in its own output format and converts every sink pad to it
first, so pinning an alpha-less format on the compositor's src caps makes its
sink pads alpha-less too. When a compositor has keyed pads and output_format
cannot carry alpha, blend in the alpha-carrying counterpart of the requested
format and convert to output_format after the mixer. output_format still pins
what leaves the block; it no longer dictates the blend space.
The GPU path is unchanged: glvideomixer is a bin whose internal converter
absorbs the pin, so the blend stays RGBA in GL memory. That invariant is now a
comment there — keep a converting element between a mixer and any format pin.
Cost: both compositor outputs gain a full-frame conversion per frame. The blend
space matches the requested chroma subsampling, so the added hop is an alpha
strip, not a resample. Only flows with a keyed pad and a non-Auto output_format
pay it.
New vision_mixer_keyed_alpha_test.rs builds a real flow per branch of
alpha_blend_format, so a format that loses its key is named by the failure.
Reverting the blend substitution turns all four red and leaves Auto green.
* chore(release): bump version to 0.6.10 (#849)
Bump the workspace version to 0.6.10 (Cargo.toml, Cargo.lock, the OpenAPI
info.version in openapi.json) and add the 0.6.10 changelog entry for the
twelve commits since 0.6.9.
Cut for #837: a GLMemory-only avfvideosrc stalled macOS camera capture to
~1 fps, fixed by #802 and #842, both of which landed after 0.6.9 was tagged.
Every CI artifact on main carries the 0.6.9 version string, so a post-release
build could not be told apart from the release that lacks the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(vision_mixer): reject a PiP zone whose sources exceed its capacity (#808)
* fix(vision_mixer): reject a PiP zone whose sources exceed its capacity
`Zone::effective_sources` truncates from the front at render time, keeping
only the newest `capacity` entries. The `/pip` validator copied `sources`
through verbatim, so a zone with capacity 2 and sources [0, 1, 2] returned
200, stored all three, and rendered two. A later GET reported a composition
that had never been on air, and the discrepancy was permanent.
Breaking: an over-capacity PUT returns 400. A caller that relied on
server-side truncation must evict down to capacity before sending.
Which sources are on screen is semantic state, not layout, so this now
fails with 400 alongside the other semantic checks in that validator —
matching the rule the border-colour check already states: draw nothing and
tell nobody is not an acceptable outcome. Evicting the oldest stays the
client's job (FIFO); the built-in editor already did it at every mutation
point, and now also repairs state adopted from another client before it
sends, so a poisoned composition cannot wedge the panel.
Also moves the MAX_PIP_OVERLAYS ceiling out of the HTTP handler into the
validator. It counted stored sources, which could exceed the rendered
count, and it ran before the per-source checks, so a config with a source
repeated across zones reported "too many sources" instead of naming the
duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(vision_mixer): guard the PiP overlay ceiling and its ordering
The MAX_PIP_OVERLAYS check moved into the validator with no test of its
own. Add one on a full-size CPU mixer: the ceiling rejects one source
too many, and a config over the ceiling only because of a duplicate
reports the duplicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(stats): report WHIP Input jitterbuffer stats per seat (#793)
* feat(stats): report WHIP Input jitterbuffer stats per seat
/api/flows/{id}/rtp-stats covered AES67 Input only, so a WHIP endpoint
returned an empty block list. A WHIP publisher's receive path is not in
the flow's pipeline: each session runs its own, bridged over
appsink/appsrc. The collector now asks the session manager for those
pipelines and reports one set of jitterbuffer statistics per occupied
slot.
Stats are keyed slot<n>_<medium>_, so a seat's audio and its video can
be told apart. The medium cannot be read off the jitterbuffer's own
pads, where webrtcbin has stripped the RTP caps to an SSRC, so it is
resolved by following the stream downstream to the first caps that name
one.
RtpJitterbufferStats also carries the buffer's configured size. Measured
jitter is only actionable next to the buffer it is measured against, and
AES67 reports it too now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(stats): make the media-kind walk test cross element boundaries
The test read audio caps off the source's own pad, so it returned on the
first iteration and never followed a peer. The element under test now
carries webrtcbin-style caps naming only an SSRC, and the medium appears
two elements downstream.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(liveaudiorouter): give an output bus headroom and a ceiling (#795)
* feat(liveaudiorouter): give an output bus headroom and a ceiling
An output bus sums every crosspoint routed to it with nothing to catch the
result. On a mix-minus rig that is N-1 talkers at unity into one bus, and
those buses are commentators' headphone feeds.
Measured on the five-seat rig with five WHIP publishers sending real speech
at browser-AGC level: all five returns pegged at full scale, clipping in 40
to 45 of every 48 seconds. Offline on the same speech, four seats aligned to leave
16 dB of peak headroom each still sum to a median +2.3 dBFS.
Two stages per output, both no-ops by default:
output_headroom attenuates the bus, 0 to -24 dB
output_limiter_enabled rglimiter, which cannot emit full scale
The trim is the fix and the limiter is the backstop, in that order. On the
rig, -6 dB of trim takes every return to zero clipped samples; the limiter
alone only cuts clipping to 4-18 seconds in 48, because Opus overshoots a
signal that sits at exactly 0 dBFS. What the limiter covers is a trim that
was set for fewer seats than turned up.
Not make_limiter_element. lsp-rs-limiter rides gain on an envelope and its
attack loses the speech transient: on a four-seat return 5 dB over it still
passed 0.05% of samples above full scale at a -3 dB threshold, and 0.0007%
at -10 dB, where it also costs 7.5 dB of level. rglimiter is a waveshaper
with an asymptote at full scale and reaches 0.0000%. Neither adds latency:
an impulse through either shows no sample delay and the pipeline latency
query is unchanged.
caps_out_O pins F32LE once either stage is engaged. audiomixer saturates at
the sum in a fixed-point format, so with the format left open whether a
fan-in overload clipped inside the router or reached the output intact
depended on what the next block negotiated, and neither stage can act on a
sum that is already gone.
A router that asks for neither is left alone down to its negotiation: no
limiter element in the chain, the trim at unity, and the bus format
unpinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(liveaudiorouter): stop the fan-in tests assuming four tones arrive in phase
Each audiotestsrc starts its sine at its own first buffer, so four copies of
one tone summed at whatever relative phase the machine handed out: coherent
at +6 dBFS here, 3.5 dBFS on a Linux runner, which failed the bound.
Four spread frequencies instead. Their relative phase sweeps through
alignment hundreds of times a second, so the loudest moment in the window
lands on the coherent sum — a stable +6.0 dBFS over repeated local runs —
and the bound now sits 4 dB clear of it.
The trim test no longer asserts an absolute level at all. It measures the
same router with and without the trim and asserts the difference, so it
tests what the trim does rather than what four live sources happened to
sum to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(liveaudiorouter): let the fan-in bus wait for all four sources
An output bus emits after `latency` whether or not every pad has delivered,
and `make_audiomixer` also sets `ignore-inactive-pads`. At the 30 ms default
a starved source misses the deadline and the bus sums three of the four, so
the level asserted becomes a property of the runner.
Raise it to 500 ms in the fan-in rig only. Twelve runs of the whole binary
at --test-threads=8 against ten spinning cores pass with this and the spread
tones in place; the version before both failed on two Linux runners and
under the same local load. The two were not separated, so this stands on the
aggregator's documented behaviour rather than on an isolated measurement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(liveaudiorouter): name the output stages the way a console does
"Trim" is input gain at the top of a channel strip, not a bus output level,
and "headroom" is the margin left below full scale, so `output_headroom = -6`
read backwards. The control is a bus master fader: `output_fader_db`, label
"Output Fader (dB)", matching the mixer block's `aux{N}_fader` and the
`fader_db` example in the API docs.
`rglimiter` has no attack or release. It is a fixed per-sample curve, which
is a soft clipper, and it behaves differently from the mixer block's Main
Limiter. The property becomes `output_soft_clip_enabled`, label "Output Soft
Clipper", and its description says it distorts rather than turns down when
driven hard.
Element ids, constants, `fader_db_for_sources` and test names follow. The
module comment no longer claims the fader-then-clipper order is a console
convention; it gives the measured reason. The fader comment now says `volume`
narrows the bus to signed and float formats instead of claiming it passes
any raw format.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(liveaudiorouter): guard the float pin for a fader-only bus
Every fan-in test fed F32LE sources, and the only running pin check enabled
the soft clipper, whose F32LE-only caps force float on their own. Nothing
failed if `sum_in_float` lost its `|| fader_db != 0.0` half.
The new test engages the fader alone between S16 sources and an
audioconvert-then-S16 consumer. It asserts the audible consequence first:
the faded sum arrives near -6 dBFS, where a sum saturated inside audiomixer
would arrive at -12 dBFS. Then it asserts mixer_0 negotiated F32LE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(liveaudiorouter): check an unconfigured bus negotiates at runtime
The existing default-router test inspects the build output: no clipper
element, no format field on caps_out. It never runs the bus. The new test
feeds S16 sources into an S16-only consumer with no converter and requires
audio to arrive. The router's output carries its sources' format, so a bus
pinned to float or holding an F32LE-only rglimiter refuses that consumer.
The fader comment claimed its missing unsigned formats were a change to an
unconfigured bus. They are not: every crosspoint is a `volume` too, so
unsigned audio never reaches the mixer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(liveaudiorouter): suggest a fader for two voices, not N-1
`fader_db_for_sources(n)` sized the fader for every other seat talking at
once: -6 dB for four. Conversation does not do that. Simultaneous speech is
3.8% of two-party phone calls (Levinson & Torreira 2015) and 11.6% of
speaking time in 3-9 person meetings, where over 90% of overlapped time has
a single other talker (Cetin & Shriberg 2006). A two-person rehearsal on the
rig overlapped for 3.3% of speaking time, with a 200 ms median.
`SUGGESTED_OUTPUT_FADER_DB` is -3 dB with the soft clipper on, whatever the
seat count. Two voices at WebRTC's default AGC peak target of -3 dBFS reach
+3 dBFS where their peaks meet; -3 dB puts that at full scale and leaves the
clipper the rest. The helper's only caller was the property description,
which now gives this value and why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(frontend): show RTP statistics for any block that reports them (#857)
Poll rtp-stats for running flows containing any block type listed in
RTP_STATS_BLOCK_DEFINITION_IDS (AES67 Input and WHIP Input), and draw the
RTP Statistics panel for any block that has an entry in the response
instead of only for AES67 Input. Block types in the list that have no
stats yet get a hint; a running flow without a jitterbuffer yet now says
stats appear once a stream is received instead of asking to start the flow.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(videoenc): default to the codec's 8-bit 4:2:0 profile (#850)
The block pinned no profile by default, so x264enc followed whatever
pixel format reached it. From a 4:2:2, 4:4:4 or 10-bit input that
produced a profile most delivery targets refuse, with nothing logged.
This is not latent. The vision mixer pins format=RGBA on its output on
both the CPU and GPU paths, and videoconvert scores RGBA to Y444 as less
lossy than RGBA to I420, so an ordinary mixer to encoder flow already
encoded High 4:4:4 Predictive. Measured on GStreamer 1.28.1:
input unpinned profile pinned
I420 High / yuv420p High / yuv420p
RGBA High 4:4:4 Predictive High / yuv420p
UYVY High 4:4:4 Predictive High / yuv420p
Y42B High 4:2:2 High / yuv420p
I420_10LE High 10 High / yuv420p
P010_10LE High 4:4:4 Predictive High / yuv420p
H.265 has the same escalation: RGBA gave Rext / yuv444p unpinned.
Add Profile::Auto and make it the default. It resolves per codec rather
than to a single name, because "high" is not an H.265 profile: pinning
it on an h265 capsfilter fails to negotiate outright, so a codec-blind
default would have traded a bad stream for a dead pipeline. H.264 gets
high, H.265 gets main, AV1 and VP9 are unchanged and carry no profile
field. profile=none stays available for free negotiation.
Nothing pins the raw pixel format. Only the profile field on the
existing capsfilter changes, and it reaches the encoder by propagating
back up through the parser. A capsfilter on the converter's output would
force a GPU readback on the memory paths that avoid one.
* ci: stop Static JS Tests re-running on every label event (#858)
Every job in the workflow either carries `if: github.event.action != 'labeled'`
or reaches it through `needs: preflight`, which is skipped for any label that is
not `ci:macos` or `ci:windows`. `static-js` had neither: no guard, and no
`needs`, so it was the one job a label event still started.
Dependabot pull requests are labelled after they open, so each one paid for a
full extra run of this job per label. #845 and #847 ran it five times, #848
three - every repeat with all other jobs correctly skipped.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(deps): group cargo patch bumps into one pull request (#859)
Dependabot opened one pull request per crate, and the crate was rarely the
interesting part of the diff. Each run re-resolves Cargo.lock, so the four open
today - tower-http 0.7.1, anyhow 1.0.104, reqwest 0.13.5, tokio-stream 0.1.19 -
changed 13 to 16 lines each, of which all but one per pull request were
windows-sys dependency edges. The four disagree about that resolution, so
merging any one of them conflicts the other three and each rebase is another CI
run. All four are patch bumps and would have arrived as a single pull request
under this config.
Patch only. Cargo reads the leading non-zero digit as major, so a 0.x minor bump
is a breaking change; egui-phosphor 0.12 -> 0.13 (#733) existed only to follow
egui to 0.35 and could not pass CI alone. Grouping that class would park every
safe bump behind one that cannot merge.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(deps): drop the unused tower-http fs feature (#860)
`fs` gates `tower_http::services::fs` - `ServeDir` and `ServeFile`, which serve
files from disk with directory indexes, Range requests, precompressed variants
and extension-based mime guessing. Strom serves no files from disk: everything
under backend/static is embedded at compile time by rust-embed and returned by
its own handlers, and neither type is named anywhere in the tree. The only
tower-http imports are `cors::{Any, CorsLayer}` and `trace::TraceLayer`.
Removing it drops `http-range-header` from the build graph entirely and nine
dependency edges from tower-http; `mime_guess`, `httpdate`, `futures-util` and
`tokio-util` stay, reached through other crates.
It also settles a question 0.7.1 raises. That release changes `ServeDir::try_call`
to propagate filesystem I/O errors instead of converting them to 404 - a
documented behavioural change that cannot reach a build with the feature off.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(deps): exclude GStreamer from the cargo patch group (#866)
The first grouped run, #861, did not compile:
error[E0308]: mismatched types
--> gstreamer-video-0.25.4/src/video_format_info.rs:415:80
ffi::gst_video_format_info_component(.., plane, &mut comp);
expected `*mut i32`, found `&mut [i32; 4]`
The group lifts gstreamer-video 0.25.3 -> 0.25.4 while gstreamer-video-sys
stays at 0.25.2 - the string does not appear in the diff at all. 0.25.4 calls a
`-sys` signature newer than the floor it declares, so cargo cannot see the
problem and no lockfile resolution avoids it.
Grouping did not cause that; the same bump would have failed alone. What it did
was park twenty working bumps behind one broken crate. Excluding the two
GStreamer families keeps them arriving as individual pull requests, where one
can be closed without holding the rest - which is the right shape for them
anyway, being this project's core dependency.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: write down the output block input convention (#867)
* docs(claude): write down the output block input convention
Video into an output block must arrive encoded and is refused otherwise;
audio may arrive raw and is encoded with target-suited defaults. A refusal
fails the flow with an element error the operator can act on, instead of a
log line and an unlinked pad. WebRTC outputs and raw-media targets are
exempt from the video rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs: note restarting a flow on error as an idea
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(liveaudiorouter): pin the tap to float so the suite measures the router (#870)
`a_fan_in_overload_leaves_the_output_over_full_scale_when_nothing_catches_it`
failed on Check (Linux) with `got 0 dBFS` against an `assert!(peak > 2.0)`,
then passed on a re-run of the same commit with no edits.
The test runs with the fader and soft clipper both off. In that configuration
`caps_out_O` deliberately pins only channels and channel-mask and leaves the
sample format open - the module header says so, and it is the right behaviour
for the block. Nothing else in the harness fixed it either: `tap()` linked
`queue_out_N` straight to `level`, which accepts S16LE through F64LE, onto a
`fakesink` that takes anything. So the bus summed in whatever the runner's
GStreamer happened to negotiate, and an `audiomixer` summing in a fixed-point
format saturates: a fan-in overload reads exactly 0.0 dBFS instead of the sum.
That is a property of the tap, not of the router. The test was measuring the
runner.
Pinning `F32LE` on the tap removes the degree of freedom. It is a test change
only; no block behaviour is touched.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(outputs): fail the flow with the block's reason when it refuses an input (#869)
An output block that refused an input only logged it and left the input's
src pad unlinked. Upstream then went not-linked, so the flow's only error
was the source's "Internal data stream error". In the recorder, which
already dropped the refused track's buffers, there was no error at all and
the track was simply missing from the recording.
A shared refuse_input() now posts the reason as an element error from the
block's input identity and sets drop-probability=1.0 on it, so the refused
stream stops quietly and the block's reason is the flow's only error.
video_refusal() and audio_refusal() word the reason the same way in every
block and name the fix: builtin.videoenc for raw video, the encoder's codec
property otherwise.
Applied to RTMP, MPEG-TS/SRT, EFP/SRT, Recorder and TAMS Output. Each
block's accepted codecs are unchanged.
Fixes #840
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(rtmp_output): refuse H.264 in a profile RTMP receivers reject (#871)
flvmux accepts H.264 in any profile, so a 4:2:2, 4:4:4 or 10-bit stream
published without a complaint and was then rejected by the platform, with
nothing in Strom saying so. video_plan now reads the caps' profile and
refuses anything outside the 8-bit 4:2:0 profiles, High and below, through
refuse_input, naming the profile and builtin.videoenc's profile property.
An absent profile is accepted.
The pipeline test's H.264 source now pins I420: left open, x264enc
followed videotestsrc's first format into a profile the block refuses.
Closes #783
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(pipeline): bound the set_state(NULL) join in stop and drop (#874)
An element can park set_state(NULL) indefinitely (splitmuxsink waiting
on a track while holding a pad's stream lock), and both stop() and Drop
joined that thread with no deadline, so POST /api/flows/{id}/stop never
answered.
Both joins now go through run_with_deadline with a 30 s limit. On
timeout stop() logs ERROR, marks the manager wedged and returns an
error; Drop skips its own attempt instead of blocking on the same
transition. The abandoned pipeline leaks, as a wedged one already did.
Refs #804
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(properties): read enum properties on elements and pads as their nick (#875)
The element reader matched the literal type name "GEnum", which no enum
reports, and the pad reader read an enum GValue as i32, which glib
refuses. Both listing calls swallow a failed read, so enum properties
were silently missing from GET .../properties and
GET .../pads/{pad}/properties.
Both paths now share one reader. Enums are read through the GValue and
returned as their nick, the form the write path accepts. Other
unsupported types (flags, caps, ...) still fall out as before.
Refs #770
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(pipeline): give the run_with_deadline panic test room for the panic hook (#878)
The channel disconnects only after the panic hook has run. CI sets
RUST_BACKTRACE=1, so the hook symbolicates a debug binary first, and
under the parallel test run that took longer than the 5 s deadline:
the test saw TimedOut instead of Panicked (seen on PR #876).
The deadline is only an upper bound; the call returns as soon as the
closure unwinds, so raising it to 120 s costs nothing when it passes.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(contributing): write down how quiet draft PRs are handled (#872)
A draft without new work or discussion for 2 weeks is asked whether it
should stay open; with no reply in another week it is closed. A rebase
does not count as activity, and closing keeps the branch.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(tests): measure the mach port reference, not whether the name is free (#873)
Five tests across `thread_handle.rs` and `thread_registry.rs` asserted that a
port name was no longer allocated once the last handle had been dropped. That
is a process-wide property, and `cargo test` runs the backend's tests in
parallel threads: a freed name is exactly what the kernel hands to the next
thread port anyone allocates. A recycled name reads as allocated, so the
assertion fired as "the reference leaked" when nothing had leaked.
Measured on macOS, full `--lib` suite: 2 failures in 10 runs, and 0 in 10 with
`--test-threads=1`. `Build (macOS)` on main hit the same thing at `05863dd`.
The tests now count the port's user references instead, holding a second handle
on the same port for the duration so the name cannot be freed - and so cannot be
recycled - while they run. That measures the invariant the type actually owes:
one `mach_port_deallocate` per `mach_thread_self`, no more and no less.
Three things had to be measured rather than assumed, each wrong on the first
attempt:
- The handle comes from a thread that has exited, so its send right has become
a dead name. Asking only for `MACH_PORT_RIGHT_SEND` reports 0 references on a
name holding two.
- Across that transition the kernel reports the same references under both
rights, so summing them double-counts: a name holding two read as 4. The
larger of the two is stable on either side of the transition.
- `join()` does not mean the transition is done. Reading while it is in flight
gave counts of 3 for a name holding two. The helper now waits for the port to
settle into a dead name before any test reads a count.
Verified: 0 failures in 30 full `--lib` runs, against a ~20% baseline. All five
tests still fail if `MachThreadPort::drop` stops releasing its reference.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(clocks): point to the chrony setup script when the clock is poorly disciplined (#888)
* feat(clocks): point to the chrony setup script when the clock is poorly disciplined
The Clocks panel flagged a Degraded or Unsynced system clock but did not say
how to fix it, and nobody saw it unless they opened the panel. Add the chrony
setup script as a finding in the panel, and warn once at startup on Linux when
the clock is unsynchronized, has no TAI/UTC leap table, or has a max error
estimate above 500 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(clocks): keep file paths out of the chrony setup hint
The hint shows in the web UI, where a repository path reads oddly, and
the Docker path suggested running the script inside the container. It
installs chrony on the host, so say that and leave the location to the
repository.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(clocks): judge clock health in one place for panel and startup log
The startup warning had its own copy of the panel's thresholds and missed
two of them: state "error" and an offset above 1 ms. Move the assessment
to strom-types so both read the same verdict, and warn at startup with
the same findings the panel shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ports): port pool — reserve port numbers from a Strom-administered set (#852)
* feat(ports): port pool — reserve port numbers from a Strom-administered set
Implements #851.
Nothing in Strom could answer "which ports may I use?". A caller building flows
over the API had to guess, and two flows binding the same UDP port failed only
at start, with an error that said nothing about the collision.
Strom now administers a set of port numbers and hands them out, knowing nothing
about who is asking or what they do with the numbers:
- `GET /api/ports`, and `/api/ports/reservations` with create, list, get, renew,
release, plus `assign`/`assign/{flow_id}` for the optional flow association
- off until configured: the reservation routes answer 409 with the setting to
change in the body, and `GET /api/ports` answers 200 with `enabled: false`
- `probe_before_handout` (default on) binds a candidate UDP and TCP before
handing it out, marks it `blocked` if something outside Strom holds it, and
logs on the transition rather than per probe
- reservations persist to `port_reservations.json` in the data directory, never
through the flow storage backend: port numbers are host-local, and a shared
PostgreSQL serving two Strom nodes would conflate two hosts' port spaces
The pool is a set of port numbers rather than a range. A range is notation the
config expands, which is what lets a pool have holes and lets a blocked port sit
anywhere; `[ports] ports` accepts both `"47100-47199"` and `47250` in one list.
`GET /api/ports` reports the pool as compacted runs and lists only the ports
that are not free, so the response is proportional to what is interesting rather
than to pool size.
No flow path changes. The pool reads the flow list to clean up after deleted
flows, so the dependency runs one way and flow create, update, start and delete
are untouched.
Tests: backend --lib (685 passed, 17 over the pool), tests/port_pool_test.rs (5),
strom-types (79), openapi_test with the snapshot updated. clippy --all-targets
-D warnings clean.
Also driven against a running server: a pool of `48000-48009,48020` grew a
reservation across its hole, kept assigned ports held through reservation
deletion and released them on flow deletion, and — with a real socket held on
48000 by another process — skipped it, marked it blocked, logged once, and
handed it out again once freed.
* fix(ports): reconcile on every route that reads the pool
`GET /api/ports/reservations` and `/{id}` read the pool without reconciling
against the flow list first, so a reservation could be read back still naming a
flow that had been deleted — a port reported as in use by something that no
longer exists. #851 says the pool reconciles "when the pool is read or
allocated from"; allocation and the pool view did, the reservation reads did
not.
Every entry point now reconciles, including renew, assign and unassign, which
return or mutate a reservation view and would otherwise show the same stale
association. The pass is over a handful of reservations, so doing it everywhere
costs less than reasoning about which routes can skip it.
Found by the round-trip test the issue asks for, added here along with the
probe test it also specifies:
- ports return to the reservation when a flow goes and to the pool only when
the reservation is released — the issue's order, which is what exposed this
- a port held by another process is skipped and marked blocked, binding a real
socket rather than stubbing the probe. It takes an ephemeral port from the OS
and pools that port plus the two after it, so it does not depend on a number
being free on the build machine.
Tests run: backend --lib (685), port_pool_test (7, two new), strom-types (79),
openapi_test. clippy --all-targets -D warnings clean.
* fix(ports): address port pool review feedback
* fix(ports): update assignment conflict OpenAPI snapshot
* fix(ports): preserve unreadable reservations and distinguish disabled pools
* fix(ports): refuse an unknown flow on assign and a garbled probe setting (#890)
- POST .../assign with a flow_id that does not exist now answers 404.
It used to answer 200 and the next reconcile dropped the association
without a word.
- STROM_PORT_PROBE_BEFORE_HANDOUT accepts true/false/1/0/yes/no/on/off
and fails startup on anything else. An unrecognised value used to
turn the probe off silently.
- Drop "over-reserving is the intended usage" from the sizing notes;
the reason to reserve ahead is that a reservation cannot shrink.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(deps): take security updates only from Dependabot, keep up monthly (#891)
Version updates are switched off with open-pull-requests-limit: 0.
Security updates stay enabled in the repository settings and keep
arriving as they happen.
Keeping up moves to a monthly dependency round run by the
implementation agent (scripts/agent/DEPS.md): one pull request that
runs cargo update, holds back any crate that breaks the build, raises
direct requirements that build with no code change (one commit each),
and lists what is still behind.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat: add optional structured event logging and JSON stdout format (#789)
* feat: add optional structured event logging and JSON stdout format
Add three logging config keys: stdout_log_format (compact/json),
structured_events, and include_high_frequency_events. When enabled,
selected StromEvent broadcasts are emitted as structured tracing log
records with stable fields (event.name, strom.flow.id) suitable for
log aggregation pipelines.
Add event_type(), flow_id(), and is_high_frequency() accessors on
StromEvent in strom-types. All three are exhaustive, no-wildcard
matches. event_type() returns the serde wire tag, verified by test.
is_high_frequency() consolidates the two divergent classification
lists in EventBroadcaster::broadcast() and the MCP notification
bridge into a single source of truth.
StromEvent itself is unchanged — no new variants, no field changes,
no OpenAPI or WebSocket contract churn.
Co-authored-by: Claude <noreply@anthropic.com>
* test(events): guard the structured_events logging gate, fix test build
Add coverage for EventBroadcaster::broadcast()'s structured-logging
gate: disabling structured_events emits no record, and disabling
include_high_frequency_events drops high-frequency events while still
logging others.
Fix backend/tests/vision_mixer_failed_start_test.rs, which still
called AppState::new() with the pre-refactor 6-arg signature.
Also reword is_high_frequency()'s doc comment: it previously implied
a uniform "more than once per second" rate for every listed variant,
which doesn't hold for events that flood only once fanned out per
connection/element/flow.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(tests): update call sites missed by the structured-logging signature change
`EventBroadcaster::new()` and `AppState::new()` gained `structured_events` /
`include_high_frequency_events` parameters, but 4 test files were missed when
updating call sites:
- backend/tests/recorder_stalled_track_test.rs
- backend/tests/rtp_hdrext_aggregation_test.rs
- backend/tests/rtmp_output_pipeline_test.rs
(switched to the `EventBroadcaster::with_capacity()` convenience constructor)
- backend/tests/bus_signal_watch_balance_test.rs
(added the two trailing `false, false` args to `AppState::new()`)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(tests): update EventBroadcaster constructor in the new gl_memory_link_test
* test(events): assert the enabled path of include_high_frequency_events
The disabled path was already covered, but the enabled path (structured
events on, high-frequency events included) rested on manual testing
only. Extend the existing gate test with a second broadcaster to close
that gap.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(tests): use with_capacity in test call sites added on main
Four integration tests that landed on main after this branch's base still
called the one-argument EventBroadcaster::new, which now takes the two
structured-logging flags as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Per Enstedt <per.enstedt@eyevinn.se>
* feat(gst): give pipeline threads a macOS QoS class so they run on P cores (#723)
* feat(gst): give pipeline threads a macOS QoS class so they run on P cores
On Apple Silicon it is the QoS class, not the pthread priority, that decides
whether a thread is eligible for a performance core. Nothing in the tree called
pthread_set_qos_class_self_np, so a streaming thread could be scheduled onto an
efficiency core with nothing to signal it had happened.
The two settings turn out to be mutually exclusive on macOS, in both orders --
pthread_setschedparam makes a later pthread_set_qos_class_self_np return EPERM,
and setting a class first is silently reset to UNSPECIFIED by a later
pthread_setschedparam (both verified on macOS 15 / M2). So this is a
replacement on macOS rather than an addition: the QoS class is the setting that
governs core placement, and macOS no longer calls the priority API at all.
Linux and Windows keep their existing priority paths untouched.
High maps to USER_INITIATED rather than USER_INTERACTIVE. Both are
performance-core classes -- only BACKGROUND is confined to the efficiency
cluster -- but USER_INTERACTIVE is the band Apple reserves for main-thread UI
work, which is where Strom's own native GUI runs. Encoder threads saturate
every core they are given, so sharing that band with the GUI makes the GUI
compete with them at equal priority. Realtime, an explicit request for the
maximum, does take USER_INTERACTIVE, falling back to USER_INITIATED when the
task runs under a QoS clamp and the kernel refuses it with EPERM.
The call goes on the streaming thread itself, in the bus StreamStatus::Enter
handler and the session pad probe, because a thread that sets no class of its
own gets QOS_CLASS_DEFAULT rather than inheriting anything meaningful. Placing
it there also reaches the element-internal workers that never post a
StreamStatus of their own (libx264's frame threads): those are created from the
streaming thread during caps negotiation, after Enter, and a thread created by
a thread that has an explicit class inherits it.
Setting the class is followed by a read-back, so a class that does not stick is
reported as an error instead of leaving a silently unplaced thread.
Incidentally this also retires a macOS priority path that never worked: the
crossplatform value 80 is outside the 15..=47 range macOS accepts, so every
"High" flow logged a warning and reported achieved=false on every thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9VpaRyFRD76GpdtLdAczi
* fix(gst): drop the unreachable Realtime QoS fallback
Realtime now sets USER_INTERACTIVE or reports the error. The EPERM fallback to
USER_INITIATED was never reached: USER_INTERACTIVE was accepted unclamped, under
taskpolicy -b and -c utility/background/maintenance, and in a launchctl job. The
Realtime test now asserts USER_INTERACTIVE exactly, so the mapping is guarded.
The read-back confirms the class was recorded, not where the thread runs: a
task-level clamp overrides the class and it still reads back as set. The comment
now says so.
The per-flow failure warning suggests setcap cap_sys_nice only on Linux, the one
platform where that is the remedy.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(gst): state which threads the macOS QoS class reaches
Only the thread that sets a QoS class carries it; threads it creates start at
QOS_CLASS_DEFAULT. Measured in a launchd Interactive job: a USER_INITIATED
streaming thread runs at kernel base priority 37 while the worker pools that
x264enc (threads=4) and svtav1enc create from it stay at 31. The comments no
longer claim encoder workers inherit the class.
In a process that is not a foreground app, USER_INTERACTIVE and
USER_INITIATED both schedule at 37, so Realtime and High behave the same on
the headless server. The mapping comment now says so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(liveaudiorouter): show the bus its consumer's format before input (#881)
* fix(liveaudiorouter): show the bus its consumer's format before input
With its fader and soft clipper off, the output bus leaves the sample
format open and takes it from its first input. When the bus negotiates
before any input has caps, which happens whenever an input joins after
the flow started and, on GStreamer 1.24, whenever the mixer's first
timeout fires before PLAYING, it queries downstream instead. That query
passes through capssetter, which answers with its filter or ANY and
never with what is downstream of it. The mixer then picks S32LE blind,
and a consumer that takes a single format without converting refuses
it: not-negotiated from the source.
Answer caps queries on the capssetter's sink pad with what downstream
of it accepts, with the fields the setter stamps opened again. A
consumer that converts still sees every format and the blind pick is
unchanged (S32LE); one that pins a format now gets it.
This is the intermittent not-negotiated failure of the liveaudiorouter
tests on Linux CI. The new tests connect the input only after the bus
has negotiated, which fails without this change on every run.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(liveaudiorouter): start the harness interleaver at its first input
A live audiointerleave whose first deadline passes before its inputs'
caps arrive aggregates with no output format and posts "not negotiated".
Starting its output at the first input buffer removes that window.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(liveaudiorouter): prefer float; keep the downstream rate off inputs
The caps answer from the output bus's capssetter now lists downstream's
caps twice: first with downstream's rates, so a bus that picks its
output blind picks a rate a non-resampling consumer takes, then with any
rate, so that a rate locked by a mixer further downstream does not
restrict the bus's inputs. The mixer passes the answer on to its own
input pads; carrying the rate alone refused a 48 kHz input joining a
router that feeds another mixer. Once an input has caps, the mixer
still pins its other inputs to that input's rate, as before.
Float formats come first within each, since the mixer fixes its format
from the first entry and a converting consumer lists its own output
format first: the bus would otherwise sum in 16-bit and saturate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(mpegtssrt_input): relink an output when the demuxer replaces its pad (#877)
* test(mpegtssrt_input): reproduce 856 (#856)
Drive the block with caller A on audio PID 0x41, then caller B on 0x42,
in passthrough and decode mode, and assert B's audio reaches the output.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mpegtssrt_input): relink an output when the demuxer replaces its pad
A reconnecting caller whose stream moves a PID or changes codec makes the
demuxer replace its source pad. Each output was claimed once and never given
back, so the replacement stayed unlinked and the input stopped with
not-linked. Track the pad each output is linked through: decodebin's
replacement reuses the freed chain, and tsdemux's, which arrives first,
takes over the output linked longest ago with the retiring pad's EOS
dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(mpegtssrt_input): build the event broadcaster with with_capacity
EventBroadcaster::new now takes the structured-logging flags as well (#789).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Agent <agent@claude.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* ci(windows): put GStreamer's DLLs ahead of Git's in the test PATH (#892)
* ci(windows): check the GStreamer plugins load before building
srtsink is in the registry on the Windows runner and still fails to
load, so output_refusal_test fails on every merge to main with only
"Failed to load element factory 'srtsink'". Load the plugins the tests
need right after the install and print the real error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ci(windows): put GStreamer's DLLs ahead of Git's in the test PATH
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(liveaudiorouter): warn when an input's channels differ from its declaration (#889)
* test(liveaudiorouter): specify the declared-channel mismatch warning (#785)
An input whose negotiated channel count differs from its declared
input_<i>_channels must post a warning from that input naming the
property, and a matching input must stay quiet. Nothing implements the
warning yet, so the first test fails here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(liveaudiorouter): warn when an input's channels differ from its declaration
Option D of #785: the declared input_<i>_channels stays authoritative.
A caps-event probe on each input compares the negotiated channel count
with it and posts an element warning naming the property to set, which
the bus watch forwards to clients as a PipelineWarning. Nothing resizes
under a saved routing matrix.
Also part 3 of #785: a folded input row now shows a ring per output
channel that any of its channels is routed to, in both the normal and
the flipped grid, as the comment above it already promised. The flipped
grid keeps one column for a folded input so its rows stay aligned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(liveaudiorouter): match the warning source in the arm guard
Clippy's collapsible_match rejected the nested if, which failed Check (Linux).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Agent <agent@claude.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(frontend): show units and a generic heading in the block statistics panel (#882)
* fix(frontend): show units and a generic heading in the block statistics panel
The panel rendered values with StatValue::format(), which ignores
StatMetadata.unit, so 40 ms read as "40.00" and packet counters as bare
numbers. Add StatValue::format_with_unit and Statistic::format_value in
strom-types and use them in the panel. DurationNs keeps the unit it
picks itself and Bool stays Yes/No; an empty unit counts as none.
The heading and empty-state hints said RTP, but the panel serves any
block that reports statistics. Make them generic.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(types): state the empty-unit rule generically
/rtp-stats emits no Bool statistics on this tree; the empty-unit Bool
stats come from the Audio Bridge helper in #844.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* perf(video): thread t…
Opened by Claude (AI) on behalf of @wagenet.
Fixes the intermittent
not-negotiatedfailures inliveaudiorouter_test.rsonCheck (Linux):a_routing_change_moves_audio_between_output_channels_while_playing(runs 36216640907, 36123655314) andclosing_every_crosspoint_silences_the_output_without_a_silence_source(run 36143990431). The cause is in the block.Cause
With its fader and soft clipper off, the output bus (
audiomixer) takes its sample format from its first input. If it negotiates before any input has caps, it asks downstream. That caps query passes throughcapssetter_out_O, andcapssetteranswers an upstream caps query with the query's own filter, or ANY. It never reports what is downstream of it. So the mixer picks its default, S32LE at 44100 Hz, and a consumer that takes a single format without converting refuses it withnot-negotiatedfrom the source.Why only sometimes, and only on Linux CI: on GStreamer 1.24 (CI runs 1.24.2), a live aggregator waits on the clock before PLAYING, while its base time is still 0. Its first deadline has therefore already passed, and it can negotiate before its inputs have caps. GStreamer 1.26 gates this ("live but not PLAYING yet"). In a real flow the bus negotiates blind on any GStreamer version whenever an input joins after the flow has started.
Change
A caps-query probe on
capssetter_out_O's sink pad answers with what downstream of the setter accepts, with the stampedchannel-maskopened again. It fires per query, not per buffer, and holds no reference to the element. The mixer fixes its output from the first entry of that answer and also hands the answer to its own input pads, so the answer is ordered:Behaviour changes, with the fader and soft clipper off (they already pin F32LE):
not-negotiated. A bus that negotiates before any input picks the consumer's rate (48 kHz for a 48 kHz consumer) instead of 44.1 kHz.Tests
All build the block through its builder.
an_input_that_joins_after_the_bus_started_reaches_{a_float,an_integer}_consumer: bus negotiates with nothing connected, then an input joinsa_late_input_reaches_a_mixer_that_locked_its_rate_on_the_router: router →audiomixerlocked at 44.1 kHz, then a 48 kHz input joinsa_late_input_reaches_a_consumer_that_converts_format_but_not_rate:audioconvertthen a 48 kHz-only consumera_consumer_that_converts_to_integer_{leaves_a_float_bus_in_float,gets_a_float_bus_from_an_integer_input}:audioconvert ! S16LEconsumerEach revert was run on macOS (GStreamer 1.28.6). On the rig (GStreamer 1.24.2), the rest of the file over 30 interleaved runs per arm: base block and harness 15/30 runs fail, this PR 0/30. The full file including the new tests: 10/10 pass.
The rig is
ubuntu:24.04with theci.ymlpackage list, run with--cpus=4. It is arm64; CI is x86_64.Test harness:
audiointerleaveThe one multi-channel test also failed on its own plumbing, about 1 in 55 rig runs with the block fixed:
audiointerleave0"not negotiated". The harness'saudiointerleaveis a live aggregator. When its first deadline passes after the sources have answered the latency query but before their caps reach it, it aggregates anyway, cannot build an output format, and errors.start-time-selection=firstmakes it wait for the first input buffer, which always follows the caps. To make the window deterministic, a scratch build held the caps event on the interleaver's sink pads for 300 ms (one binary, the property switched by an env var, arms interleaved):firstUnheld, CPU load did not raise the base rate (8 full-file pairs at
--cpus=2plus 4 busy loops: 0 failures either arm). The held-caps build is scratch only: no committed test guards this setting, because the window cannot be opened from the test without the scratch hook.Not addressed here: under
taskpolicy -bon a loaded Mac, both arms sometimes read silence: sources starved past the bus's 30 ms aggregation deadline. The reverted block also produced 44not-negotiatederrors in 12 runs; this PR produced none. The silence failures never occurred in the rig.Ran:
cargo test --test liveaudiorouter_test(counts above; 34/34 on macOS),cargo test --workspaceon macOS (995 passed, 0 failed, 2 ignored),cargo fmt --all,cargo clippy --all-targets -- -D warnings.🤖 Generated with Claude Code