feat(liveaudiorouter): live audio router on synchronising buses, with per-sample crosspoint fades - #740
feat(liveaudiorouter): live audio router on synchronising buses, with per-sample crosspoint fades#740srperens wants to merge 9 commits into
Conversation
The mixer block is where this project decided how to configure an aggregator-based audio bus: force-live so it keeps producing when not every input is connected, a latency timeout, ignore-inactive-pads, and start-time-selection=zero so audio and video stay in sync through separate aggregators. The Live Audio Router sums its crosspoints onto the same kind of bus and needs the same answers. Make the helper pub(crate) rather than letting a second block drift its own copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `builtin.liveaudiorouter`: the same routing model as
`builtin.audiorouter`, but the crosspoints can be changed on a running
flow, each crosspoint carries a gain rather than an on/off flag, and a
change fades instead of stepping. `builtin.audiorouter` is untouched.
Topology, following the pattern the mixer block already uses for its aux
sends (`tee → volume → audiomixer`):
audio_in_I → identity_in_I → deinterleave_in_I → tee_iIcC
tee_iIcC → xp_iIcC_oOcD (volume) → mixer_O sink pad (places channel D)
mixer_O → caps_out_O → capssetter_out_O → queue_out_O → audio_out_O
The whole crossbar is built up front and stays linked, so a routing
change only writes gains — it never relinks, and nothing restarts.
Why not a matrix element, measured rather than assumed:
* Synchronisation. `audiomixer` is a `GstAudioAggregator`: it aligns
inputs on timestamps, fills gaps with silence and normalises differing
buffer sizes. `interleave` is a `GstCollectPads` element that waits for
one buffer on every pad. Measured over 3 s: with a configured input
pad left unconnected, `interleave` produced 0 level messages against
`audiointerleave`'s 59; with one source stopping mid-stream, 0 after
the stall against 43. A router that deadlocks when an input is not
connected is not usable, and that is the default state of any router
the operator has not finished wiring.
* Fades. Only the standalone `volume` element samples its `volume`
property per sample (`volume_transform_ip` in `gstvolume.c`), which is
what `crate::gst::volume_ramp` already relies on. Over a 40 ms linear
ramp the gain changed 880 times through a `volume` element, 4 times
through an `audiomixer` sink pad's `volume` (once per 10 ms output
block), and `audiomixmatrix`' `matrix` is not controllable at all.
Both alternatives step, and a step is an audible click.
* Gain per crosspoint. The coefficient is a `gdouble` on a real element,
so a crosspoint is a level and not a checkbox.
No queue per crosspoint. A tee branch normally needs one so it cannot
block its siblings, but every branch here ends on a `GstAggregatorPad`,
which queues the buffer and returns rather than waiting for the mix, and
the bus never waits indefinitely on a pad. Verified identical over 20 s
at 70 and at 512 crosspoints, with and without queues: 401 level
messages either way. Each queue is a streaming thread, so this is the
difference between 2 threads and 512 for one block.
Live updates: one `routing_matrix` write fans out to every crosspoint of
the instance, resolved through `crosspoint_targets` — the same pure
function the tests use, so the two cannot drift. Crosspoints are
enumerated from the running pipeline's element map, so there is no
build-time registry to keep in step or leak. Each write goes through
`set_property`, which routes a `volume` element through the ramp
manager, so an opening or closing crosspoint fades.
`crosspoint_fade_ms` defaults to the mixer's `DEFAULT_VOLUME_RAMP_MS`.
`force_live`, `latency`, `min_upstream_latency` and
`output_buffer_duration` use the mixer block's names, types and
defaults: both blocks sum onto an `audiomixer`, so an operator who has
tuned one should not have to learn a second vocabulary.
`routing_matrix` accepts either form, so routing written for
`builtin.audiorouter` keeps working:
{"i0c0": ["o0c0", "o1c0"]} crosspoints open at unity
{"i0c0": {"o0c0": 1.0, "o1c0": 0.35}} explicit gain per crosspoint
Tests: 14 integration tests and 8 unit tests. The audio-path tests run
real audio through the block's own builder rather than re-implementing
the routing model. New guards for the two failures above
(`an_unconnected_input_does_not_stall_the_router`,
`a_source_that_stops_does_not_stall_the_other_inputs`), for the fade
(`every_crosspoint_is_a_volume_element_with_a_controllable_gain`) and
for the thread cost (`the_crossbar_costs_no_thread_per_crosspoint`).
Every element they need is in the CI package list, so none of them skip.
The capability-parity test now allows five documented additions rather
than requiring an exact property match, and still fails on any other
addition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…661) The routing editor could not change routing on a running flow at all. Its Save wrote the whole flow through `POST /api/flows/{id}`, which applies pad properties live but never block properties — so a routing change only took effect on the next restart, which tears down WHEP sessions and resets meters. The one thing `routing_matrix` being live was for was unreachable from the UI. Save now also writes through the block-property endpoint, and a "Live" toggle (on by default) sends every change as it is made. Block properties persist by default, so in live mode the endpoint stores the value too and Save has nothing left to do. Changes are debounced through the same filter the property inspector's live controls use, so dragging a gain does not put one request per frame on the wire. Crosspoints carry a gain, so the editor's model is now `src -> dest -> gain` and each open crosspoint gets a compact dB control next to its checkbox. dB rather than a raw coefficient because that is what the meters beside it read in. Both routing forms are parsed. This matters beyond convenience: the old editor parsed only `HashMap<String, Vec<String>>` and fell back to an empty matrix, so opening it on a matrix with gains and pressing Save wiped the routing. Live mode and the gain control are gated on capability, not on a block id — the `live` flag of the block's own `routing_matrix` property, and the presence of `crosspoint_fade_ms`. `builtin.audiorouter` therefore keeps its Save button, its plain checkboxes, and byte-identical JSON: the list form is still written whenever every gain is unity. Not covered by tests: the frontend has no test for the new cell or for live mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend and the graph editor each had their own parser for the same JSON. Two parsers for one wire format drift, and the editor's copy is the one that decides whether a saved routing survives a round trip. Moves the format to `strom_types::routing`: `Crosspoint`, `parse_routing_gains`, `serialize_routing_gains` and the dB conversion, with the tests that pin the contract. Both sides now use it, and the editor's internals are keyed by `Crosspoint` rather than by formatted strings — which also makes `cleanup_routing` one bounds check instead of two key sets, and `clear_output_routing` a single retain. The tests moved for a second reason: the workspace has `default-members = ["backend"]` and CI runs `cargo test` per package for strom, strom-mcp-server and strom-types — never strom-frontend. Tests next to the editor would never have run. In strom-types they do. Two fixes found while checking that `builtin.audiorouter` still works: * The live write is now gated on the block's `routing_matrix` being declared live. It was sent unconditionally, so saving on the old block made a request the backend answers with `rejected` and the frontend discards — harmless, but a wasted round-trip and a swallowed rejection. * Serialisation is ordered. The destination list came out of a HashMap, so saving the same routing twice produced different JSON and churned the flow file. `a_plain_routing_round_trips_as_the_list_form` now pins that the list form comes back byte for byte. Adds `the_original_audiorouter_still_routes_audio`, which runs real audio through `builtin.audiorouter` on this code, and a test that it is not offered live routing or gain controls. `serde_json` becomes a real dependency of strom-types rather than a dev-dependency. It is a pure utility crate, in the same class as the serde and uuid the crate already depends on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment — a well-argued alternative to #737 for the same issue, with genuine end-to-end audio coverage. Not approvable only because Check (Linux) (the job that runs these tests) was still pending at review time — nothing found in the diff blocks it.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
Check (Linux) (runs cargo test) has completed and is green |
UNVERIFIED |
gh pr checks 740 at review time: Check (Linux) pending; only API Contract Check and Check & Build (WASM) had finished |
| The element guard fails hard rather than skipping silently | CONFIRMED |
backend/tests/liveaudiorouter_test.rs:221 — `assert!(` on missing.is_empty(), not a skip — satisfies CLAUDE.md |
builtin.audiorouter's own routing_matrix is unaffected by the new live-write intercept |
CONFIRMED |
backend/src/blocks/builtin/audiorouter.rs:842-847 — `element_id: "_block".to_string(),` with `live: false,`, never reaches instance_from_anchor's ":mixer_0" match |
No crosspoint captures a strong pipeline/element ref in the deinterleave pad-added closure |
CONFIRMED |
backend/src/blocks/builtin/liveaudiorouter.rs:400 — `let instance_for_cb = instance_id.to_string();` — only a String moves in; the bin comes from the callback's own arg |
Omitting the per-crosspoint queue matches this repo's own tee→volume→mixer aux-send pattern |
CONTRADICTED |
backend/src/blocks/builtin/mixer/builder.rs:971 — `` `// Link: (pre |
Diagnosis — Directly rebuts #737's foundation (interleave stalling on an unconnected/stopped input, a matrix coefficient that cannot fade, live writes the flow-save path silently drops) with measurements; #737's own body already lists the first and third as its own unverified gaps, so the critique lines up with what #737 admits. tee→volume→audiomixer is not new to this tree — aux sends already do it — a real point in this design's favour.
On the missing queue — the PR argues every crosspoint lands on a GstAggregatorPad, which queues and returns rather than blocking the tee, so a per-crosspoint queue buys no decoupling the bus doesn't already have. A claim about upstream behaviour (EXTERNAL), plausibly correct, but it's the one place this diverges from a pattern the codebase already uses for the same shape, and the_crossbar_costs_no_thread_per_crosspoint only counts elements — no backpressure stress. Worth a sanity-check given the aux-send precedent, not a blocker.
Blast radius — SHARED: new branches in update_element_property (properties.rs) and update_block_properties (state.rs) sit ahead of the existing per-property paths, but both are guarded — by instance_from_anchor's ":mixer_0" suffix match, and by definition_id == liveaudiorouter::BLOCK_ID — and leave builtin.audiorouter untouched (row above). make_audiomixer widens pub(super) → pub(crate), one new caller, no signature change.
Tests & CI — API Contract Check, Check & Build (WASM) green at b920c5f. Check (Linux), Build (Linux x86_64/ARM64) still running; macOS/Windows skipping as expected. The 4 tests named as new guards for the PR's own measured failures, plus the_original_audiorouter_still_routes_audio, all exist in liveaudiorouter_test.rs as named. The frontend Live-toggle/debounce path has no CI coverage — not unique to this PR (strom-frontend never appears in a cargo test --package line in .github/workflows/ci.yml), and the PR body already flags it.
Design record — States the rejected alternative (#737) and why.
Confidence: HIGH
Two bugs in the routing block's property inspector, both found by testing the GUI rather than reading it. The inspector gives the router a hand-laid property view rather than the generic one, because the definition always carries eight `input_N_channels` and eight `output_N_channels` and only `num_inputs` / `num_outputs` of them are relevant. That view rendered exactly those and nothing else, so `crosspoint_fade_ms`, `force_live`, `latency`, `min_upstream_latency` and `output_buffer_duration` were never drawn at all. Anything the view does not know about is now rendered generically, so a block can gain a property without also having to be added here. The same view called `show_exposed_property` and dropped its `changed` return on the floor. The generic view uses that flag to queue a live write, so a property declared `live: true` in this view only took effect on the next flow save — which is exactly what `crosspoint_fade_ms` did. Rendering now goes through one helper that queues the write, for every property in the view rather than only the new ones. `builtin.audiorouter` exposes none of these and has no live properties, so its inspector is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…view (#661) Three of the things the original report asks for, none of which the routing editor did. They are UX, so the guards below are on the parts that can be tested; the interaction itself needs an operator's eyes. **A default connection.** A router that has just been added had an empty matrix and passed nothing until someone opened the editor and pressed 1:1 Diagonal. It now routes straight through: input channels onto output channels by position in the concatenated channel space. The default applies only when `routing_matrix` is *absent*, which is what a newly added block has — the graph creates blocks with no properties at all. An empty matrix is a decision and is honoured as written, so closing every crosspoint survives a restart. `default_routing` lives in `strom_types::routing` and both the builder and the editor use it, so the grid shows what the flow will actually run. Deliberately not applied to `builtin.audiorouter`. An existing flow whose router was never configured must not start passing audio because of an upgrade, and its builder is unchanged. `the_original_audiorouter_keeps_its_silent_default` pins that. **A dot matrix.** A crosspoint is now a dot rather than a checkbox, so a straight-through routing reads as a line of dots on the diagonal — which is what the report describes and what a router looks like. Where the block supports a gain the dot's radius follows it, and the exact value moved to the cell's context menu; the previous inline dB control widened every open cell and broke the diagonal it was supposed to draw. The old block gets the dots too, with no gain and no context menu. **A foldable view.** Input streams fold to their group header from the header itself, with Minimize and Maximize for all of them, in both the normal and the flipped layout. A large router is mostly rows nobody is looking at. Outputs already minimise, one at a time, through the existing tab strip. Not addressed here, and worth their own scoping: auto-detecting the channel count of an input (the detection is a caps probe, but the topology is built from the channel counts, so acting on a change means a rebuild — what should happen automatically is a design decision), and naming the in- and outpoints after the devices connected to them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#661) Dot radius was a poor gain indicator — it reads as distance or importance rather than as a level, and two neighbouring values look the same. A crosspoint is now a knob: a filled dot with a pointer from its centre to just past the rim. The travel runs 7 o'clock to 5 o'clock, fully anticlockwise silent and fully clockwise unity, with the midpoint of the dB range straight up. Click connects and disconnects, drag turns the knob, and the context menu still sets an exact value. Unity at the top of the travel rather than in the middle, because a crosspoint attenuates and never amplifies — the geometry follows from that. The `volume` element carrying the gain would go to +20 dB, but boost belongs somewhere with a meter and a limiter in front of it (`builtin.audiogain`, or the mixer block) rather than on a routing crosspoint: the output bus sums without headroom, so a boosted crosspoint plus fan-in clips with nothing to catch it, while attenuation is the useful half and cannot clip. An unconnected crosspoint now draws a small dot instead of a faint ring, so the lattice is visible at all times and a routing reads as a pattern against it. Cells went from 16 to 20 px to give the pointer room. `knob_angle` sits in `strom_types::routing` with the rest of the format rather than beside the painting code, so its geometry is pinned by a test in a package CI actually runs — the ends of the travel, straight up at the midpoint, and monotonic in between so the pointer never doubles back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pointer was drawn in the theme's foreground colour, on top of a dot filled with that same colour — legible only because the stroke was thin enough to be a hairline. It is now a broad black stroke with a thin white one over it, which is how a knob pointer is normally drawn and which reads against the dot's fill whatever the theme does to it. Fixed black and white rather than theme colours for that reason. Turning a knob showed no value: hover text is suppressed during a drag, so the one moment the number matters was the one moment it was hidden. The dB value now follows the drag in a small box above the cell, painted on the tooltip layer so the grid's clip rect does not cut it off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The input fold toggles used the raw geometric glyphs U+25B8 and U+25BE. Those are not in the icon font this application loads, so whether they rendered at all depended on the platform's fallback — which is why they came out wrong. Every other icon in this frontend comes from egui-phosphor; these now do too, as CARET_RIGHT and CARET_DOWN, with ARROWS_IN and ARROWS_OUT on the Minimize and Maximize buttons. Also corrects the module's topology sketch, which still showed the per-crosspoint queue that was removed when the crossbar stopped costing a thread per crosspoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — supersedes my 2026-09-01 v3 review (id 5079073686, COMMENTED) at head b920c5f2, which withheld approval only on Check (Linux) being pending. That run is now green, and the commits added since (4a240d4..784a58e) are backend-tested and don't reopen any row from that review.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
Check (Linux)/Build (Linux)/WASM/API-contract all completed green at the new head |
CONFIRMED |
gh pr checks 740 at 784a58e: all pass except macOS/Windows skipping (expected — no push/PR trigger) |
The new straight-through default is confined to liveaudiorouter and doesn't change builtin.audiorouter |
CONFIRMED |
backend/tests/liveaudiorouter_test.rs:976 — `fn the_original_audiorouter_keeps_its_silent_default()` builds the old block and asserts zero :xp_ elements |
| An explicitly-empty matrix (an operator closing every crosspoint) survives and isn't overwritten by the new default | CONFIRMED |
backend/tests/liveaudiorouter_test.rs:957 — `fn an_explicitly_empty_routing_is_honoured_rather_than_defaulted()` |
| Tests covering the new code path actually execute in CI rather than skip | CONFIRMED |
.github/workflows/ci.yml: Check (Linux) runs cargo test --package strom --features efp,nvidia with STROM_REQUIRE_GST_PLUGINS=1 (skip-to-failure) and a separate cargo test --package strom-types step, covering both new test files |
Diagnosis — backend/src/blocks/builtin/liveaudiorouter.rs:305 now calls routing::default_routing() when routing_matrix is absent (a block just dropped in), while an explicit "{}" still parses to an empty matrix — the two "no routing" states are distinguished, which is the right invariant for the reason the tests name (an upgrade must not silently reopen a deliberately-closed router). types/src/routing.rs:182 builds the diagonal by zipping flattened (stream, channel) pairs and stops at the shorter side, which is the same behaviour the maintainer would get by hand-wiring 1:1 and cheap to reason about.
Minor, non-blocking — frontend/src/audiorouter.rs:588 comments that a folded input row "still shows whether it is routed at all, so nothing is hidden without a trace," but the row underneath emits out_ch_count empty ui.label("") calls with no such indicator, in both the normal and flipped grid renderers. The claim isn't in the PR body and doesn't affect functionality — flagging so the comment gets fixed or the indicator gets added, whichever was intended.
Blast radius — unchanged from my last review: SHARED (new branches in update_element_property/update_block_properties, both gated on the liveaudiorouter block/definition id), not reopened by this delta — default_routing and the frontend fold state are additive and touch no existing call site outside this block.
Tests & CI — green at 784a58e0 per the claim row above. WASM frontend changes (knob rendering, fold state) have no UI-level test, consistent with strom-frontend never appearing in a cargo test --package line in CI — not new to this PR.
Confidence: HIGH
Closes #661. An alternative to #737, which targets the same issue with a different design.
#737 expresses the routing as coefficients in a single
audiomixmatrix, fed byinterleaveand split back out withdeinterleave. I built and ran it locally before writing this, and found three problems that are properties of that foundation rather than bugs in the implementation. All numbers below are measured on this machine (GStreamer 1.28.6, macOS), not inferred.The router stalls when an input is not connected
interleaveis aGstCollectPadselement: it waits for one buffer on every sink pad before producing output.audiomixer/audiointerleaveareGstAudioAggregators, which align on timestamps, fill gaps with silence and aggregate on a timeout in live mode.Same graph, 3-second windows, counting
levelmessages:interleaveaudiointerleaveA configured-but-unconnected input is the default state of any router the operator has not finished wiring, and a source dropping out is the thing a router exists to survive. #737 lists "
interleaveon an unconnected input" among its own unverified items.A crosspoint change cannot fade
Only the standalone
volumeelement samples itsvolumeproperty per sample (volume_transform_ipingstvolume.c) — which is whatcrate::gst::volume_rampalready relies on, and which its module header already documents. Driving a 40 ms linear ramp and counting how often the gain actually changed:audiomixmatrix.matrixaudiomixersink padconverter-configaudiomixersink padvolume, 10 ms blocksvolumeelementThe mixer pad produces a staircase in step with
output-buffer-duration; the envelope went 0 → 25 → 50 → 75 → 100 %FS in 10 ms steps. A matrix property cannot interpolate at all. Both step, and a step is an audible click on every take.It was unreachable from the UI
routing_matrixis declaredlive: true, but the routing editor's Save writes the whole flow throughPOST /api/flows/{id}, which applies pad properties live and never block properties. Verified directly: a live write moved audio in 64 ms, while the same change through the flow-save path left the pipeline on the old routing. The only way to see a change was to restart the flow — which renegotiates every WHEP session and resets the meters.This design
The pattern the mixer block has used for its aux sends all along (
tee → volume → audiomixer), which answers all three at once:audiomixerdoes the synchronisation. The coefficient is avolumeelement, so it fades per sample. And because the coefficient is agdoubleon a real element, a crosspoint is a level rather than a checkbox —{"i0c0": {"o0c0": 1.0, "o1c0": 0.35}}alongside the existing{"i0c0": ["o0c0"]}, so routing written forbuiltin.audiorouterkeeps working.builtin.audiorouteritself is untouched.The whole crossbar is built up front and stays linked, so a routing change only writes gains — it never relinks and nothing restarts.
interleave,audiomixmatrix,capssetter_inand the dynamic pad-added queue construction are all gone, as is the global layout registry keyed by element id that #737 flagged as worth a look: crosspoints are enumerated from the running pipeline's element map instead.No queue per crosspoint
A tee branch normally needs a queue so it cannot block its siblings, but every branch here ends on a
GstAggregatorPad, which queues the buffer and returns rather than waiting for the mix — and the bus is built withforce-live, alatencytimeout andignore-inactive-pads, so it never waits indefinitely on a pad and always drains. Verified over 20 s at two scales:Each
queueis a streaming thread. On the test flow below this took one router block from 161 elements and 70 queue threads to 91 elements and 2 — one per output bus, which is where downstream decoupling belongs.Verified in a running flow
A test flow with three inputs of different channel counts (1 / 2 / 4), each channel a distinct tone at a distinct level ~2.5 dB apart, into an 8-channel monitor bus on a meter and a stereo bus on a WHEP output. Measured peaks against the routing:
Fan-out, fan-in, per-crosspoint gain, all-closed (silent, with no silence source), and extreme cases all behave. Live changes applied without a single pipeline restart, and the fade is visible on the meter — with
crosspoint_fade_msat 800 ms:−64.2 → −56.2 → −52.6 → … → −2.5 dB, evenly spaced in dB, which isvolume_ramp.rs' log curve for ramps over 50 ms.Properties
crosspoint_fade_msdefaults to the mixer'sDEFAULT_VOLUME_RAMP_MS.force_live,latency,min_upstream_latencyandoutput_buffer_durationuse the mixer block's names, types and defaults — both blocks sum onto anaudiomixer, so an operator who has tuned one should not have to learn a second vocabulary.output_buffer_durationis documented as a latency-versus-CPU knob only: because the coefficient moved tovolume, it no longer affects how smooth a fade is.UI
Save now applies live, and a "Live" toggle (on by default) sends every change as it is made — block properties persist by default, so in live mode the endpoint stores the value too and Save has nothing left to do. Changes are debounced through the same filter the property inspector's live controls use. Each open crosspoint gets a compact dB control next to its checkbox.
Both routing forms are parsed, which matters beyond convenience: the old editor parsed only the list form and fell back to an empty matrix, so opening it on a matrix with gains and pressing Save wiped the routing. This actually happened once during development.
Live mode and the gain control are gated on capability rather than a block id — the
liveflag of the block's ownrouting_matrixproperty, and the presence ofcrosspoint_fade_ms.builtin.audioroutertherefore keeps its Save button and its plain checkboxes, andthe_original_audiorouter_still_routes_audioruns real audio through it on this code.One routing-matrix format
The backend and the editor each had their own parser for the same JSON, and the editor's copy is the one that decides whether a saved routing survives a round trip. The format now lives in
strom_types::routingand both sides use it. The editor's internals are keyed byCrosspointrather than formatted strings, which makescleanup_routingone bounds check instead of two key sets.The tests moved there for a second reason: the workspace has
default-members = ["backend"]and CI runscargo testper package forstrom,strom-mcp-serverandstrom-types— neverstrom-frontend. Tests next to the editor would never have run.Two things found while checking that the old block still works: the live write was sent unconditionally, so saving on
builtin.audioroutermade a request the backend answers withrejectedand the frontend discards (harmless, but a wasted round-trip and a swallowed rejection) — now gated on the property being declared live. And serialisation was unordered, so saving the same routing twice produced different JSON and churned the flow file;a_plain_routing_round_trips_as_the_list_formnow pins that the list form comes back byte for byte.Tests
16 integration tests, 4 backend unit tests and 9 format tests in
strom-types. The audio-path tests run real audio through the block's own builder rather than re-implementing the routing model — #737's two matrix tests were pure unit tests with no audio, and its one end-to-end test failed on its own baseline. New guards for each measured failure above:an_unconnected_input_does_not_stall_the_router,a_source_that_stops_does_not_stall_the_other_inputs,every_crosspoint_is_a_volume_element_with_a_controllable_gain,the_crossbar_costs_no_thread_per_crosspoint. Every element they need is in the CI package list, so none of them skip.Ran locally: the 16 integration tests, the 8 unit tests,
cargo test --lib(545),cargo test --package strom-types(67),openapi_test,volume_ramp_test,pipeline_lifecycle_test,block_properties_persist_test,api_tests.cargo fmt --checkandcargo clippy --all-targetsclean, including wasm32.Not verified
The frontend has no test for the new cell or for live mode, and CI does not test the
strom-frontendpackage at all — worth fixing separately. Rates other than 48 kHz, more than 8 channels per stream, Windows and Linux (developed and measured on macOS).MAX_CROSSPOINTSis 1024 — no longer 1024 threads, but still 1024 elements in one block, and worth a second opinion. The capability-parity test now allows five documented additions instead of requiring an exact property match; it still fails on any other addition, but it is a relaxation of the guard requested on #661.audiomixersums without headroom, so enough crosspoints on one output channel can clip — the same is true ofbuiltin.audiorouter, but the matrix form makes it easier to hit.🤖 Generated with Claude Code