weavesignal/crates/desk/tests/local.rs
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

1160 lines
43 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! The desk's pipeline with local tone sources in place of the room: no LiveKit, no network
//! beyond an SRT link on loopback. Needs GStreamer with the base, good and bad plugins (run
//! in the desk image: `just desk-check`).
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use gstreamer as gst;
use gstreamer::prelude::*;
use weavesignal_desk::analysis::{Tap, peak_dbfs, ratio_db, tone_power};
use weavesignal_desk::config::Config;
use weavesignal_desk::matrix::Bus;
use weavesignal_desk::pipeline::{Desk, ErrorScope, Outputs, RAW_CAPS};
use weavesignal_proto::Role;
/// Settings with no input processing, so tone levels stay as sent.
fn config(srt_port: u16) -> Config {
Config::from_toml(
&format!(
r#"
[livekit]
url = "ws://127.0.0.1:1"
api_key = "unused"
room = "local"
[programme]
srt_port = {srt_port}
# Tones are measured by their pitch, and the delay's rebuild plays them a little slow.
[programme.delay]
target_ms = 0
[inputs.presenter]
[inputs.dj]
"#
),
"test",
)
.unwrap()
}
/// A live sine at `freq` and `volume`, behind a ghost pad named like a LiveKit audio pad.
fn tone_at(pipeline: &gst::Pipeline, name: &str, freq: f64, volume: f64) -> (gst::Bin, gst::Pad) {
let bin = gst::Bin::with_name(name);
let src = gst::ElementFactory::make("audiotestsrc")
.property("is-live", true)
.property("freq", freq)
.property("volume", volume)
.property("samplesperbuffer", 480i32)
.build()
.unwrap();
let caps = gst::ElementFactory::make("capsfilter")
.property("caps", RAW_CAPS.parse::<gst::Caps>().unwrap())
.build()
.unwrap();
bin.add_many([&src, &caps]).unwrap();
src.link(&caps).unwrap();
let ghost = gst::GhostPad::builder_with_target(&caps.static_pad("src").unwrap())
.unwrap()
.name("audio_0")
.build();
bin.add_pad(&ghost).unwrap();
pipeline.add(&bin).unwrap();
(bin, ghost.upcast())
}
fn tone(pipeline: &gst::Pipeline, name: &str, freq: f64) -> gst::Pad {
tone_at(pipeline, name, freq, 0.3).1
}
fn errors(p: &gst::Pipeline) {
let bus = p.bus().unwrap();
while let Some(msg) = bus.pop() {
if let gst::MessageView::Error(e) = msg.view() {
p.set_state(gst::State::Null).unwrap();
panic!(
"pipeline error from {:?}: {} ({:?})",
msg.src().map(|s| s.path_string()),
e.error(),
e.debug()
);
}
}
}
fn run_for(p: &gst::Pipeline, d: Duration) {
let deadline = Instant::now() + d;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
errors(p);
}
}
fn play_for(desk: &Desk, d: Duration) {
let p = desk.pipeline();
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
run_for(p, d);
}
fn tap(desk: &Desk, bus: Bus, _name: &str) -> Tap {
desk.tap(&bus).unwrap()
}
fn mm(id: &str) -> Bus {
Bus::MixMinus(id.to_owned())
}
/// `a` against `b` in a recording, in dB.
fn balance(t: &Tap, a: f64, b: f64) -> f64 {
let s = t.samples();
ratio_db(tone_power(&s, a), tone_power(&s, b))
}
/// A tone's power against silence, in dB: above about 60 dB it is there.
fn presence(t: &Tap, f: f64) -> f64 {
ratio_db(tone_power(&t.samples(), f), 1.0)
}
/// The tones these tests send.
const TONES: [f64; 6] = [300.0, 1000.0, 2000.0, 3000.0, 5000.0, 7000.0];
/// Whether a tone is absent: under 30 dB over silence, or at least 60 dB under the loudest
/// other tone on the bus (a window's leakage from a loud tone is not a voice).
fn absent(t: &Tap, f: f64) -> bool {
let s = t.samples();
let me = ratio_db(tone_power(&s, f), 1.0);
let loudest = TONES
.iter()
.filter(|&&g| g != f)
.map(|&g| ratio_db(tone_power(&s, g), 1.0))
.fold(f64::MIN, f64::max);
me < 30.0f64.max(loudest - 60.0)
}
/// Sets a pipeline to NULL when dropped, so a failed assertion does not leave it playing.
struct Stop(gst::Pipeline);
impl Drop for Stop {
fn drop(&mut self) {
let _ = self.0.set_state(gst::State::Null);
}
}
#[test]
fn every_bus_outputs_from_its_first_moment_with_nobody_in_the_room() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
let prog = tap(&desk, Bus::Programme, "prog");
let mmg = tap(&desk, mm("guest"), "mm");
play_for(&desk, Duration::from_millis(1000));
// 20 ms blocks: about 50 in a second; allow for start-up.
assert!(prog.buffers() > 40, "programme: {} buffers", prog.buffers());
assert!(mmg.buffers() > 40, "mix-minus: {} buffers", mmg.buffers());
assert!(peak_dbfs(&prog.samples()) < -90.0, "the bed is silent");
}
#[test]
fn each_mix_minus_leaves_out_its_own_voice() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
desk.route_pad("guest", &tone(p, "guest-voice", 3000.0))
.unwrap();
assert_eq!(desk.links(), 2);
let prog = tap(&desk, Bus::Programme, "prog");
let mm_guest = tap(&desk, mm("guest"), "mmg");
let mm_pres = tap(&desk, mm("presenter"), "mmp");
play_for(&desk, Duration::from_millis(1500));
let guest_own = balance(&mm_guest, 3000.0, 1000.0);
let presenter_own = balance(&mm_pres, 1000.0, 3000.0);
let programme_balance = balance(&prog, 3000.0, 1000.0);
println!(
"guest own voice {guest_own:.1} dB, presenter own voice {presenter_own:.1} dB, programme 3k/1k {programme_balance:.1} dB"
);
assert!(
guest_own < -60.0,
"the guest hears themselves: {guest_own:.1} dB"
);
assert!(
presenter_own < -60.0,
"the presenter hears themselves: {presenter_own:.1} dB"
);
assert!(
programme_balance.abs() < 3.0,
"the programme carries both: {programme_balance:.1} dB"
);
}
#[test]
fn a_person_joining_late_hears_everyone_already_there() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
// The guest arrives after the presenter's voice is routed: their mix-minus must still
// carry the presenter.
desk.add_person("guest", Role::Guest).unwrap();
let mmg = tap(&desk, mm("guest"), "mmg");
play_for(&desk, Duration::from_millis(1000));
let heard = presence(&mmg, 1000.0);
assert!(
heard > 60.0,
"the late guest does not hear the presenter: {heard:.1} dB"
);
}
#[test]
fn taking_a_voice_off_air_takes_it_off_every_bus_it_airs_on() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
desk.route_pad("guest", &tone(p, "guest-voice", 3000.0))
.unwrap();
assert!(desk.set_on_air("guest", false));
assert!(!desk.set_on_air("stranger", false));
let prog = tap(&desk, Bus::Programme, "prog");
let mmp = tap(&desk, mm("presenter"), "mmp");
play_for(&desk, Duration::from_millis(1000));
let r = balance(&prog, 3000.0, 1000.0);
assert!(
r < -60.0,
"the guest off air is still in the programme: {r:.1} dB"
);
assert!(
absent(&mmp, 3000.0),
"the presenter still hears the guest off air: {:.1} dB",
presence(&mmp, 3000.0)
);
}
#[test]
fn a_caller_waits_with_the_screener_until_sent_to_air() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
for (id, role) in [
("hamish", Role::Presenter),
("callum", Role::Caller),
("iona", Role::Caller),
("moira", Role::Screener),
] {
desk.add_person(id, role).unwrap();
}
desk.route_pad("iona", &tone(p, "iona-voice", 7000.0))
.unwrap();
// The screener talks to Callum (her `T`, held): he hears her, Iona waiting does not.
assert!(desk.set_talk_to("moira", "callum", true));
desk.route_pad("hamish", &tone(p, "hamish-voice", 1000.0))
.unwrap();
desk.route_pad("callum", &tone(p, "callum-voice", 3000.0))
.unwrap();
desk.route_pad("moira", &tone(p, "moira-voice", 5000.0))
.unwrap();
let prog = tap(&desk, Bus::Programme, "prog");
let mm_hamish = tap(&desk, mm("hamish"), "mm-hamish");
let mm_callum = tap(&desk, mm("callum"), "mm-callum");
let mm_moira = tap(&desk, mm("moira"), "mm-moira");
let mm_iona = tap(&desk, mm("iona"), "mm-iona");
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
run_for(p, Duration::from_millis(1200));
// Off air: only the screener hears the caller; the caller hears the programme and the
// screener; the screener never reaches listeners.
let prog_caller = presence(&prog, 3000.0);
let hamish_caller = presence(&mm_hamish, 3000.0);
let moira_caller = presence(&mm_moira, 3000.0);
let prog_screener = presence(&prog, 5000.0);
let callum_screener = presence(&mm_callum, 5000.0);
let hamish_screener = presence(&mm_hamish, 5000.0);
let callum_presenter = presence(&mm_callum, 1000.0);
println!(
"off air (dB over silence): caller in programme {prog_caller:.0}, to presenter {hamish_caller:.0}, \
to screener {moira_caller:.0}; screener in programme {prog_screener:.0}, to caller {callum_screener:.0}, \
to presenter {hamish_screener:.0}; presenter to caller {callum_presenter:.0}"
);
assert!(
absent(&prog, 3000.0) && absent(&mm_hamish, 3000.0),
"the caller leaks before air"
);
assert!(moira_caller > 60.0, "the screener cannot hear the caller");
assert!(
callum_screener > 60.0,
"the caller cannot hear the screener"
);
assert!(
callum_presenter > 60.0,
"the caller waiting cannot hear the show"
);
assert!(
absent(&mm_iona, 5000.0),
"a caller the screener is not talking to hears her"
);
assert!(
presence(&mm_moira, 7000.0) > 60.0,
"the screener cannot hear a second caller"
);
assert!(absent(&mm_iona, 3000.0), "one waiting caller hears another");
assert!(absent(&prog, 5000.0), "the screener is on air");
assert!(
absent(&mm_hamish, 5000.0),
"the screener reaches the presenter without talkback"
);
// To air, and talkback held: the caller is in the programme, the screener in the
// presenter's ear only.
let prog2 = tap(&desk, Bus::Programme, "prog2");
let mm_hamish2 = tap(&desk, mm("hamish"), "mm-hamish2");
assert!(desk.set_on_air("callum", true));
assert!(desk.set_talk("moira", true));
run_for(p, Duration::from_millis(1200));
p.set_state(gst::State::Null).unwrap();
let prog_caller = presence(&prog2, 3000.0);
let prog_screener = presence(&prog2, 5000.0);
let hamish_screener = presence(&mm_hamish2, 5000.0);
println!(
"on air: caller in programme {prog_caller:.0}; talkback: screener to presenter {hamish_screener:.0}, in programme {prog_screener:.0}"
);
assert!(
prog_caller > 60.0,
"the caller sent to air is not in the programme"
);
assert!(
hamish_screener > 60.0,
"talkback does not reach the presenter"
);
assert!(absent(&prog2, 5000.0), "talkback reached listeners");
}
#[test]
fn the_producers_talkback_reaches_only_the_presenter_and_only_while_held() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
for (id, role) in [
("hamish", Role::Presenter),
("gregor", Role::Guest),
("ailsa", Role::Producer),
] {
desk.add_person(id, role).unwrap();
}
desk.route_pad("ailsa", &tone(p, "ailsa-voice", 2000.0))
.unwrap();
let prog = tap(&desk, Bus::Programme, "prog");
let mmh = tap(&desk, mm("hamish"), "mmh");
let mmg = tap(&desk, mm("gregor"), "mmg");
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
run_for(p, Duration::from_millis(800));
let before = presence(&mmh, 2000.0);
let mmh2 = tap(&desk, mm("hamish"), "mmh2");
assert!(desk.set_talk("ailsa", true));
run_for(p, Duration::from_millis(800));
p.set_state(gst::State::Null).unwrap();
assert!(
before < 30.0,
"talkback reached the presenter before it was held"
);
assert!(
presence(&mmh2, 2000.0) > 60.0,
"held talkback did not reach the presenter"
);
assert!(absent(&mmg, 2000.0), "talkback reached a guest");
assert!(absent(&prog, 2000.0), "talkback reached listeners");
}
#[test]
fn cue_mute_and_talk_to_one_person_move_only_the_voices_they_should() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
for (id, role) in [
("hamish", Role::Presenter),
("gregor", Role::Guest),
("finlay", Role::Dj),
] {
desk.add_person(id, role).unwrap();
}
desk.route_pad("hamish", &tone(p, "hamish-voice", 1000.0))
.unwrap();
desk.route_pad("gregor", &tone(p, "gregor-voice", 3000.0))
.unwrap();
desk.route_pad("finlay", &tone(p, "finlay-voice", 5000.0))
.unwrap();
// Finlay off air but cued: the presenter hears him, listeners and the guest do not.
assert!(desk.set_cue("finlay", true));
// Gregor muted: off the programme and the presenter's ear, his on-air state kept.
assert!(desk.set_mute("gregor", true));
let prog = tap(&desk, Bus::Programme, "prog");
let mmh = tap(&desk, mm("hamish"), "mmh");
let mmg = tap(&desk, mm("gregor"), "mmg");
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
run_for(p, Duration::from_millis(1200));
assert!(
presence(&mmh, 5000.0) > 60.0,
"the cue did not reach the presenter"
);
assert!(absent(&prog, 5000.0), "a cued source reached listeners");
assert!(absent(&mmg, 5000.0), "a cued source reached a guest");
assert!(absent(&prog, 3000.0), "a muted guest is on air");
assert!(
desk.status()
.iter()
.any(|s| s.identity == "gregor" && s.on_air && s.mute)
);
// The presenter holds talk to Gregor: Gregor hears him, the programme loses him.
// (Taps after the switch has settled: buffers already queued still carry the old mix.)
assert!(desk.set_talk_to("hamish", "gregor", true));
assert!(desk.set_mute("gregor", false));
run_for(p, Duration::from_millis(300));
let prog2 = tap(&desk, Bus::Programme, "prog2");
let mmg2 = tap(&desk, mm("gregor"), "mmg2");
let mmf2 = tap(&desk, mm("finlay"), "mmf2");
run_for(p, Duration::from_millis(1200));
assert!(
presence(&mmg2, 1000.0) > 60.0,
"the guest does not hear the presenter"
);
assert!(absent(&prog2, 1000.0), "private talk reached listeners");
assert!(absent(&mmf2, 1000.0), "private talk reached someone else");
assert!(
presence(&prog2, 3000.0) > 60.0,
"the unmuted guest is not back on air"
);
// The meter reads each voice: a 0.3 sine at 1 kHz is about −10.5 LUFS.
let st = desk.status();
let hamish = st.iter().find(|s| s.identity == "hamish").unwrap();
let lufs = hamish.lufs.expect("the presenter's voice is metered");
assert!((lufs + 10.5).abs() < 1.0, "{lufs}");
assert!(hamish.lufs_peak.unwrap() >= lufs - 0.1);
assert!(desk.set_talk_to("hamish", "gregor", false));
assert!(!desk.set_talk_to("hamish", "nobody", true));
p.set_state(gst::State::Null).unwrap();
}
#[test]
fn the_programme_limiter_keeps_a_loud_sum_under_the_ceiling() {
// Two voices at about -1 dBFS each sum to about +5 dBFS: in 16-bit integers the mixer
// clipped them; in float the limiter brings them under -1 dBFS.
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone_at(p, "pv", 1000.0, 0.9).1)
.unwrap();
desk.route_pad("guest", &tone_at(p, "gv", 1500.0, 0.9).1)
.unwrap();
let prog = tap(&desk, Bus::Programme, "prog");
play_for(&desk, Duration::from_millis(1500));
// Leave out the first 100 ms: the look-ahead's settling.
let s: Vec<i16> = prog.samples().into_iter().skip(4800).collect();
let peak = peak_dbfs(&s);
println!("programme peak with two voices near full scale: {peak:.2} dBFS");
assert!(
peak <= -0.9,
"the programme peaks at {peak:.2} dBFS (ceiling -1)"
);
assert!(peak > -4.0, "the limiter took too much: {peak:.2} dBFS");
let b = balance(&prog, 1000.0, 1500.0);
assert!(
b.abs() < 1.0,
"the limiter unbalanced the voices: {b:.1} dB"
);
}
#[test]
fn the_presenters_input_chain_brings_a_quiet_presenter_up() {
// The default presenter chain (the spike's live run): compressor, +5.6 dB steered by the
// leveller toward -25 dB of speech, limiter. A presenter at about -37 dBFS RMS against an
// equal guest with no chain comes out louder, by the make-up and a little leveller.
let desk = Desk::new(
Config::from_toml(
"[livekit]\nurl = \"ws://127.0.0.1:1\"\napi_key = \"u\"\nroom = \"local\"\n",
"t",
)
.unwrap(),
Outputs::NONE,
)
.unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone_at(p, "pv", 300.0, 0.02).1)
.unwrap();
desk.route_pad("guest", &tone_at(p, "gv", 3000.0, 0.02).1)
.unwrap();
let prog = tap(&desk, Bus::Programme, "prog");
play_for(&desk, Duration::from_millis(3000));
let lift = balance(&prog, 300.0, 3000.0);
println!("presenter over an equal guest after 3 s: {lift:.1} dB");
assert!(
lift > 5.0,
"the presenter's chain lifted them only {lift:.1} dB"
);
assert!(
lift < 5.6 + 6.0 * 0.5 + 1.0,
"the leveller moved faster than 0.5 dB per half second: {lift:.1} dB"
);
}
#[test]
fn a_person_who_leaves_and_comes_back_is_mixed_again() {
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
let (gbin, gpad) = tone_at(p, "guest-voice", 3000.0, 0.3);
desk.route_pad("guest", &gpad).unwrap();
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
run_for(p, Duration::from_millis(600));
// The guest's link drops: their source stops, and the desk lets them go entirely.
gbin.set_state(gst::State::Null).unwrap();
p.remove(&gbin).unwrap();
desk.remove_person("guest");
assert!(
desk.tap(&mm("guest")).is_none(),
"the leaver's mix-minus is still there"
);
let prog_gone = tap(&desk, Bus::Programme, "prog-gone");
run_for(p, Duration::from_millis(600));
// 20 ms blocks: about 30 in 600 ms.
assert!(
prog_gone.buffers() > 22,
"the programme stalled when a person left: {} blocks in 600 ms",
prog_gone.buffers()
);
assert!(
absent(&prog_gone, 3000.0),
"the leaver is still in the programme"
);
// They rejoin: a new source under the same identity.
desk.add_person("guest", Role::Guest).unwrap();
let (gbin2, gpad2) = tone_at(p, "guest-voice-2", 3000.0, 0.3);
desk.route_pad("guest", &gpad2).unwrap();
gbin2.sync_state_with_parent().unwrap();
let prog_back = tap(&desk, Bus::Programme, "prog-back");
let mmg_back = tap(&desk, mm("guest"), "mmg-back");
let mmp_back = tap(&desk, mm("presenter"), "mmp-back");
run_for(p, Duration::from_millis(1000));
p.set_state(gst::State::Null).unwrap();
assert!(
presence(&prog_back, 3000.0) > 60.0,
"the returning guest is not in the programme"
);
assert!(
presence(&mmp_back, 3000.0) > 60.0,
"the presenter does not hear the returning guest"
);
let own = balance(&mmg_back, 3000.0, 1000.0);
assert!(
own < -60.0,
"the returning guest hears themselves: {own:.1} dB"
);
}
fn free_udp_port() -> u16 {
std::net::UdpSocket::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
#[test]
fn the_room_keeps_flowing_while_playout_is_not_connected() {
// The desk's SRT listener with nobody connected: srtsink drops what it is given. Behind
// the leaky queues the mixers must keep running, or every guest stops hearing every
// other guest (spike 01).
let desk = Desk::new(
config(free_udp_port()),
Outputs {
srt: true,
mix_minus_to_room: false,
},
)
.unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
let mmg = tap(&desk, mm("guest"), "mm");
play_for(&desk, Duration::from_millis(3000));
let secs = mmg.seconds();
println!("mix-minus carried {secs:.2} s of audio in 3 s with playout not connected");
assert!(secs > 2.5, "the mix-minus stalled: {secs:.2} s in 3 s");
}
/// Playout's side of the SRT link, as Liquidsoap does it: a caller to the desk's listener
/// that demuxes Matroska and decodes Opus.
fn playout_caller(port: u16, name: &str) -> (gst::Pipeline, Tap) {
let p = gst::Pipeline::with_name(name);
let src = gst::ElementFactory::make("srtsrc")
.property("uri", format!("srt://127.0.0.1:{port}?mode=caller"))
.property("latency", 20i32)
.build()
.unwrap();
let demux = gst::ElementFactory::make("matroskademux").build().unwrap();
let dec = gst::ElementFactory::make("opusdec").build().unwrap();
p.add_many([&src, &demux, &dec]).unwrap();
src.link(&demux).unwrap();
let dec_weak = dec.downgrade();
demux.connect_pad_added(move |_, pad| {
if let Some(dec) = dec_weak.upgrade() {
let _ = pad.link(&dec.static_pad("sink").unwrap());
}
});
let tap = Tap::attach(&p, &dec, name).unwrap();
(p, tap)
}
/// Wait up to `limit` for a tap to hold a second of audio; how long it took.
fn first_second(desk: &gst::Pipeline, tap: &Tap, limit: Duration) -> Option<Duration> {
let t0 = Instant::now();
while t0.elapsed() < limit {
run_for(desk, Duration::from_millis(100));
if tap.seconds() > 1.0 {
return Some(t0.elapsed());
}
}
None
}
#[test]
fn every_playout_that_connects_decodes_the_programme_from_its_first_moment() {
// Playout connects late, goes away, and a new one connects, as when Liquidsoap starts
// after the desk and then restarts. Each connection must start with Matroska's header, or
// the new playout cannot decode: a desk that called out to playout reconnected without
// it (GStreamer's matroskademux: "Invalid header") and sometimes not at all (2026-10-25).
let port = free_udp_port();
let desk = Desk::new(
config(port),
Outputs {
srt: true,
mix_minus_to_room: false,
},
)
.unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
let mmg = tap(&desk, mm("guest"), "mmg");
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
// The desk is up before playout.
run_for(p, Duration::from_secs(2));
// The link the master lamp reads (ADR-0062): down until playout connects.
let never = desk.playout();
let mut links = Vec::new();
let (first, first_tap) = playout_caller(port, "playout-1");
let _stop1 = Stop(first.clone());
first.set_state(gst::State::Playing).unwrap();
let first_after = first_second(p, &first_tap, Duration::from_secs(8));
run_for(p, Duration::from_secs(1));
links.push(desk.playout().connected);
first.set_state(gst::State::Null).unwrap();
// Playout is down for two seconds; the desk keeps mixing.
run_for(p, Duration::from_secs(2));
links.push(desk.playout().connected);
let (second, second_tap) = playout_caller(port, "playout-2");
let _stop2 = Stop(second.clone());
second.set_state(gst::State::Playing).unwrap();
let second_after = first_second(p, &second_tap, Duration::from_secs(8));
run_for(p, Duration::from_secs(1));
links.push(desk.playout().connected);
second.set_state(gst::State::Null).unwrap();
println!("playout link: before {never:?}, then connected {links:?}");
assert!(!never.connected && never.for_ms.is_none());
assert_eq!(
links,
[true, false, true],
"the desk did not see playout come, go and come back"
);
let room_secs = mmg.seconds();
println!(
"late playout decoded a second {first_after:?} after connecting; restarted playout {second_after:?}; the guest's mix-minus carried {room_secs:.1} s"
);
assert!(
first_after.is_some(),
"a playout that connected after the desk started could not decode"
);
assert!(
second_after.is_some(),
"a restarted playout could not decode the programme"
);
for (t, which) in [(&first_tap, "late"), (&second_tap, "restarted")] {
assert!(
presence(t, 1000.0) > 60.0,
"the {which} playout heard no presenter"
);
}
assert!(
room_secs > 6.0,
"the room stalled while playout came and went: {room_secs:.1} s"
);
}
/// The element directly in the desk's pipeline whose name starts with `prefix` and ends with
/// `suffix`.
fn element(desk: &Desk, prefix: &str, suffix: &str) -> gst::Element {
desk.pipeline()
.children()
.into_iter()
.find(|e| e.name().starts_with(prefix) && e.name().ends_with(suffix))
.unwrap_or_else(|| panic!("no element {prefix}…{suffix} in the desk"))
}
/// Post an error from `el`, as a failing element does.
fn fail(el: &gst::Element) {
let msg = gst::message::Error::builder(gst::CoreError::Failed, "forced by the test")
.src(el)
.build();
let _ = el.post_message(msg);
}
/// Handle every error waiting on the desk's bus as the binary does.
fn handle_errors(desk: &Desk) -> Vec<ErrorScope> {
let bus = desk.pipeline().bus().unwrap();
let mut out = Vec::new();
while let Some(msg) = bus.pop_filtered(&[gst::MessageType::Error]) {
out.push(desk.handle_error(&msg));
}
out
}
#[test]
fn a_failure_in_one_persons_voice_or_mix_minus_stays_theirs() {
// The staging run (2026-10-25): one failed subscription among a dozen people joining
// stopped the whole desk, then playout's link and the listeners. Only what is the
// person's own may go: their voice (subscription, input chain, routes) or their mix-minus.
let desk = Desk::new(
config(free_udp_port()),
Outputs {
srt: true,
mix_minus_to_room: false,
},
)
.unwrap();
let p = desk.pipeline();
for (id, role) in [
("presenter", Role::Presenter),
("guest", Role::Guest),
("caller", Role::Caller),
] {
desk.add_person(id, role).unwrap();
}
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
desk.route_pad("guest", &tone(p, "guest-voice", 3000.0))
.unwrap();
desk.route_pad("caller", &tone(p, "caller-voice", 5000.0))
.unwrap();
assert!(desk.set_on_air("caller", true));
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
run_for(p, Duration::from_millis(500));
// The guest's input chain fails: their voice goes, nobody else's.
fail(&element(&desk, "in-guest-", "-convert"));
// The caller's return fails: it is made again, and they stay on air.
fail(&element(&desk, "pub-mm-caller-", ""));
let scopes = handle_errors(&desk);
assert_eq!(
scopes,
[
ErrorScope::Participant("guest".into()),
ErrorScope::Participant("caller".into())
],
"a person's failure was not kept to them"
);
assert!(
desk.person("caller").unwrap().switches.on_air,
"the caller lost their place on air when their mix-minus was made again"
);
assert!(
!p.children()
.iter()
.any(|e| e.name().starts_with("in-guest-")),
"the guest's failed input chain is still in the desk"
);
// The programme and its link are the desk's own: fatal.
let prog = element(&desk, "programme-opus", "");
let (scope, _) = desk.classify_error(
&gst::message::Error::builder(gst::CoreError::Failed, "forced")
.src(&prog)
.build(),
);
assert_eq!(
scope,
ErrorScope::Fatal,
"a programme failure was not fatal"
);
// An error from something the desk has already taken down: nothing to do.
let gone = gst::ElementFactory::make("queue").build().unwrap();
let (scope, _) = desk.classify_error(
&gst::message::Error::builder(gst::CoreError::Failed, "late")
.src(&gone)
.build(),
);
assert_eq!(scope, ErrorScope::Stale);
let prog_after = tap(&desk, Bus::Programme, "prog-after");
let mmp_after = tap(&desk, mm("presenter"), "mmp-after");
let mmc_after = tap(&desk, mm("caller"), "mmc-after");
run_for(p, Duration::from_millis(1000));
p.set_state(gst::State::Null).unwrap();
assert!(
prog_after.seconds() > 0.8,
"the programme stopped: {:.2} s in 1 s",
prog_after.seconds()
);
assert!(
presence(&prog_after, 1000.0) > 60.0 && presence(&prog_after, 5000.0) > 60.0,
"the programme lost a voice that did not fail"
);
assert!(
absent(&prog_after, 3000.0),
"the failed voice is still in the programme"
);
assert!(
presence(&mmp_after, 5000.0) > 60.0,
"the presenter no longer hears the caller"
);
assert!(
presence(&mmc_after, 1000.0) > 60.0 && absent(&mmc_after, 5000.0),
"the caller's remade mix-minus is wrong"
);
// The guest's voice went: their routes too, and their tone with it.
let left: Vec<String> = p
.children()
.iter()
.map(|e| e.name().to_string())
.filter(|n| n.starts_with("in-guest-") || n == "guest-voice")
.collect();
assert!(left.is_empty(), "the guest's voice left {left:?} behind");
}
#[test]
fn a_stuck_publisher_does_not_stall_anyone_else() {
// A mix-minus publisher that stops taking audio must not stop its bus's mixer: each voice
// reaches every bus from one thread, so a mixer that stops taking audio stops every voice
// that feeds it, everywhere, the programme included.
let desk = Desk::new(config(1), Outputs::NONE).unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
desk.route_pad("guest", &tone(p, "guest-voice", 3000.0))
.unwrap();
// The presenter's mix-minus publisher stops taking audio.
let stuck = element(&desk, "pub-mm-presenter-", "")
.static_pad("sink")
.unwrap();
let probe = stuck
.add_probe(gst::PadProbeType::BLOCK_DOWNSTREAM, |_, _| {
gst::PadProbeReturn::Ok
})
.unwrap();
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
// Long enough for any queue in the guest's way to fill.
run_for(p, Duration::from_millis(2500));
let prog = tap(&desk, Bus::Programme, "prog");
let mmg = tap(&desk, mm("guest"), "mmg");
run_for(p, Duration::from_millis(1500));
stuck.remove_probe(probe);
p.set_state(gst::State::Null).unwrap();
println!(
"while the presenter's publisher was stuck: programme guest {:.1} dB, presenter {:.1} dB; the guest hears the presenter at {:.1} dB",
presence(&prog, 3000.0),
presence(&prog, 1000.0),
presence(&mmg, 1000.0)
);
// Both tones are sent at the same level: a guest who was cut off for most of the window
// is tens of dB under the presenter.
assert!(
presence(&prog, 3000.0) > presence(&prog, 1000.0) - 6.0,
"the guest left the programme when the presenter's mix-minus stalled"
);
assert!(
presence(&prog, 1000.0) > 60.0 && presence(&mmg, 1000.0) > 60.0,
"the presenter's voice stalled"
);
}
#[test]
fn a_stuck_publisher_does_not_starve_the_programme() {
// Review 0025, D3: one shared encoder thread, and that one stuck pushing into a mix-minus
// publisher that takes nothing. The programme must still reach playout: it has its own
// encoder thread. Before, it queued behind the stuck return, SRT carried nothing, and no
// error said so.
let port = free_udp_port();
let mut c = config(port);
c.desk.workers = 1;
let desk = Desk::new(
c,
Outputs {
srt: true,
mix_minus_to_room: false,
},
)
.unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
desk.add_person("guest", Role::Guest).unwrap();
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
let stuck = element(&desk, "pub-mm-guest-", "")
.static_pad("sink")
.unwrap();
let probe = stuck
.add_probe(gst::PadProbeType::BLOCK_DOWNSTREAM, |_, _| {
gst::PadProbeReturn::Ok
})
.unwrap();
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
let (playout, heard) = playout_caller(port, "playout-stuck");
let _stop_playout = Stop(playout.clone());
playout.set_state(gst::State::Playing).unwrap();
run_for(p, Duration::from_secs(4));
stuck.remove_probe(probe);
playout.set_state(gst::State::Null).unwrap();
p.set_state(gst::State::Null).unwrap();
println!(
"with the only shared encoder stuck: playout decoded {:.1} s, the presenter at {:.1} dB",
heard.seconds(),
presence(&heard, 1000.0)
);
assert!(
presence(&heard, 1000.0) > 60.0,
"the programme stalled behind a stuck return"
);
}
/// Count the buffers that reach an element's sink pad from now on.
fn count_buffers(el: &gst::Element) -> Arc<AtomicU64> {
let n = Arc::new(AtomicU64::new(0));
let c = n.clone();
el.static_pad("sink")
.unwrap()
.add_probe(gst::PadProbeType::BUFFER, move |_, _| {
c.fetch_add(1, Ordering::Relaxed);
gst::PadProbeReturn::Ok
})
.unwrap();
n
}
#[test]
fn a_stuck_return_drops_its_own_audio_and_holds_up_no_other_return() {
// ADR-0168: each return has an overflow buffer between its encoder and its publisher, so
// a guest whose publisher stops taking audio loses their own and holds no encoder. With
// one shared encoder thread, a stuck publisher used to hold it: every other return, and
// anything else the pool encodes, stopped behind it with no error raised. The programme
// has its own thread (`a_stuck_publisher_does_not_starve_the_programme`).
let port = free_udp_port();
let mut c = config(port);
c.desk.workers = 1;
let desk = Desk::new(
c,
Outputs {
srt: true,
mix_minus_to_room: false,
},
)
.unwrap();
let p = desk.pipeline();
for (id, role) in [
("presenter", Role::Presenter),
("guest", Role::Guest),
("caller", Role::Caller),
] {
desk.add_person(id, role).unwrap();
}
desk.route_pad("presenter", &tone(p, "presenter-voice", 1000.0))
.unwrap();
let guest_pub = element(&desk, "pub-mm-guest-", "");
let presenter_out = count_buffers(&element(&desk, "pub-mm-presenter-", ""));
let caller_out = count_buffers(&element(&desk, "pub-mm-caller-", ""));
let guest_out = count_buffers(&guest_pub);
let stuck = guest_pub.static_pad("sink").unwrap();
let probe = stuck
.add_probe(gst::PadProbeType::BLOCK_DOWNSTREAM, |_, _| {
gst::PadProbeReturn::Ok
})
.unwrap();
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
let (playout, heard) = playout_caller(port, "playout-stuck-return");
let _stop_playout = Stop(playout.clone());
playout.set_state(gst::State::Playing).unwrap();
run_for(p, Duration::from_secs(3));
let (presenter_stuck, caller_stuck) = (
presenter_out.load(Ordering::Relaxed),
caller_out.load(Ordering::Relaxed),
);
let programme_stuck = heard.seconds();
// The guest's publisher takes audio again: their return comes back by itself.
stuck.remove_probe(probe);
let guest_before = guest_out.load(Ordering::Relaxed);
run_for(p, Duration::from_millis(1500));
let guest_after = guest_out.load(Ordering::Relaxed) - guest_before;
let presenter_after = presenter_out.load(Ordering::Relaxed) - presenter_stuck;
playout.set_state(gst::State::Null).unwrap();
p.set_state(gst::State::Null).unwrap();
println!(
"with the only shared encoder and the guest's publisher stuck for 3 s: the presenter's \
return sent {presenter_stuck} frames, the caller's {caller_stuck}, playout decoded \
{programme_stuck:.1} s; once it took audio again the guest's return sent {guest_after} \
frames in 1.5 s (the presenter's {presenter_after})"
);
// 20 ms frames: 150 in 3 s, less the start.
assert!(
presenter_stuck > 100 && caller_stuck > 100,
"a stuck return held up the others: presenter {presenter_stuck}, caller {caller_stuck} frames in 3 s"
);
assert!(
presence(&heard, 1000.0) > 60.0 && programme_stuck > 1.0,
"the programme stalled behind a stuck return"
);
assert!(
guest_after > 40,
"the guest's return did not come back: {guest_after} frames in 1.5 s"
);
assert!(
presenter_after > 50,
"the presenter's return stopped when the guest's came back: {presenter_after} frames"
);
}
#[test]
fn dumped_audio_never_reaches_playout() {
// The profanity delay at 2 s: it builds up through silence at half speed, a marker tone
// goes in, the delay is dumped while the marker is still held, and what follows is a
// second tone. Playout must decode the second tone and never the marker.
let port = free_udp_port();
let mut c = config(port);
c.programme.delay.target_ms = 2_000;
let desk = Desk::new(
c,
Outputs {
srt: true,
mix_minus_to_room: false,
},
)
.unwrap();
let p = desk.pipeline();
desk.add_person("presenter", Role::Presenter).unwrap();
let (bin, pad) = tone_at(p, "presenter-voice", 1000.0, 0.0);
desk.route_pad("presenter", &pad).unwrap();
let src = bin
.children()
.into_iter()
.find(|e| e.factory().is_some_and(|f| f.name() == "audiotestsrc"))
.unwrap();
let sent = tap(&desk, Bus::Programme, "sent");
let delayed = desk.tap_delayed();
let _stop = Stop(p.clone());
p.set_state(gst::State::Playing).unwrap();
let (playout, heard) = playout_caller(port, "playout-dump");
let _stop_playout = Stop(playout.clone());
playout.set_state(gst::State::Playing).unwrap();
// Silence until the delay is full.
let t0 = Instant::now();
while desk.delay().state.rebuilding && t0.elapsed() < Duration::from_secs(10) {
run_for(p, Duration::from_millis(100));
}
let full = desk.delay();
assert!(!full.state.rebuilding, "the delay never filled: {full:?}");
assert!(desk.dump(), "a full delay refused a dump");
// That dump emptied a delay of silence; let it fill again.
let t1 = Instant::now();
while desk.delay().state.rebuilding && t1.elapsed() < Duration::from_secs(10) {
run_for(p, Duration::from_millis(100));
}
assert!(!desk.delay().state.rebuilding);
// The marker: 800 ms of 1 kHz, then silence, and the dump 600 ms after it ends, with the
// whole marker still held (the delay is 2 s).
src.set_property("volume", 0.3f64);
run_for(p, Duration::from_millis(800));
src.set_property("volume", 0.0f64);
run_for(p, Duration::from_millis(600));
let before = desk.delay();
assert!(desk.dump(), "the dump was refused: {before:?}");
let after = desk.delay();
assert!(after.state.rebuilding && after.state.dumps == 2);
assert!(!desk.dump(), "a second dump with nothing held was taken");
// Then the second tone, long enough to come through the rebuilding delay.
let second = Instant::now();
src.set_property("freq", 3000.0f64);
src.set_property("volume", 0.3f64);
run_for(p, Duration::from_millis(4_000));
playout.set_state(gst::State::Null).unwrap();
p.set_state(gst::State::Null).unwrap();
// Before the second tone only the marker or silence can come out: there, nothing at all
// may. After it, the marker must still be absent next to the tone (a window's leakage and
// the rebuild's resampling put a trace of any loud tone everywhere, 60 dB down or more).
let until = |t: &Tap, end: Instant| -> Vec<i16> {
t.chunks()
.into_iter()
.filter(|(at, _)| *at < end)
.flat_map(|(_, s)| s)
.collect()
};
let level = |s: &[i16], f: f64| ratio_db(tone_power(s, f), 1.0);
let marker_sent = presence(&sent, 1000.0);
let marker_out = level(&until(&delayed, second), 1000.0);
let marker_heard = level(&until(&heard, second), 1000.0);
// The rebuild plays the second tone 2 % slow: 2,940 Hz, a bin over 10 Hz wide each side.
let after_heard = presence(&heard, 2940.0).max(presence(&heard, 3000.0));
println!(
"delay before the dump {:?}, after {:?}; marker sent {marker_sent:.0} dB, out of the delay {marker_out:.0} dB, at playout {marker_heard:.0} dB; the tone after the dump at playout {after_heard:.0} dB ({:.1} s decoded)",
before.state,
after.state,
heard.seconds()
);
assert!(marker_sent > 60.0, "the marker never reached the programme");
assert!(
marker_out < 30.0,
"dumped audio left the delay: {marker_out:.1} dB"
);
assert!(
marker_heard < 30.0,
"dumped audio reached playout: {marker_heard:.1} dB"
);
assert!(
after_heard > 60.0,
"the programme after the dump did not reach playout: {after_heard:.1} dB"
);
}