weavesignal/crates/desk/tests/feed.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

243 lines
8.4 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! The video's sound from playout with both real ends (ADR-0172; review 0028, A1): the desk with
//! video on sends its programme to the real playout over SRT, and the real playout sends its
//! output back to the desk's feed port. While nobody is live the video's sound is playout's
//! (its fallback, a 3 kHz file, once the desk has been quiet for its dead-air time); while a
//! DJ's strip is open on air it is the desk's programme (the DJ's 1 kHz); off air again, it is
//! playout's. Ignored unless the stack is up: `just test-feed` starts LiveKit, playout and the
//! listener process with `desk_feed` and the fallback set, and runs this in the desk image.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use gstreamer as gst;
use gstreamer::prelude::*;
use weavesignal_desk::analysis::{Tap, ratio_db, tone_power};
use weavesignal_desk::config::Config;
use weavesignal_desk::hls_sound::Source;
use weavesignal_desk::pipeline::{Desk, HlsSoundStatus, Outputs, RAW_CAPS};
use weavesignal_proto::Role;
fn env(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set; run `just test-feed`"))
}
fn scratch() -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let d = std::env::temp_dir().join(format!("weavesignal-feed-stack-{stamp}"));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A live tone, behind a ghost pad named like LiveKit's audio pad.
fn tone(pipeline: &gst::Pipeline, name: &str, freq: f64) -> gst::Pad {
let bin = gst::Bin::with_name(name);
let src = gst::ElementFactory::make("audiotestsrc")
.property("is-live", true)
.property("freq", freq)
.property("volume", 0.3)
.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();
ghost.upcast()
}
fn run_for(desk: &Desk, d: Duration) {
let t0 = Instant::now();
let bus = desk.pipeline().bus().unwrap();
while t0.elapsed() < d {
while let Some(msg) = bus.pop_filtered(&[gst::MessageType::Error]) {
if let gst::MessageView::Error(e) = msg.view() {
panic!(
"desk error from {:?}: {}",
msg.src().map(|s| s.path_string()),
e.error()
);
}
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_sound(desk: &Desk, limit: Duration, want: impl Fn(&HlsSoundStatus) -> bool) -> bool {
let t0 = Instant::now();
while t0.elapsed() < limit {
if want(&desk.hls_sound()) {
return true;
}
run_for(desk, Duration::from_millis(100));
}
want(&desk.hls_sound())
}
/// A tone's level, in dB over silence, in what a tap recorded after `from`.
fn level_since(t: &Tap, from: Instant, freq: f64) -> f64 {
let s: Vec<i16> = t
.chunks()
.into_iter()
.filter(|(at, _)| *at >= from)
.flat_map(|(_, s)| s)
.collect();
ratio_db(tone_power(&s, freq), 1.0)
}
/// Waits until the video's sound carries `freq` for a second, and not `other` (at least 40 dB
/// below it), measured in one-second windows; returns how long it took and the levels of `freq`
/// and `other` in that second.
fn until_tone(
desk: &Desk,
tap: &Tap,
freq: f64,
other: f64,
limit: Duration,
) -> Option<(f64, f64, f64)> {
let t0 = Instant::now();
while t0.elapsed() < limit {
let from = Instant::now();
run_for(desk, Duration::from_secs(1));
let (want, not) = (level_since(tap, from, freq), level_since(tap, from, other));
eprintln!(
" {:.1} s: {freq} Hz {want:.1} dB, {other} Hz {not:.1} dB ({:?})",
t0.elapsed().as_secs_f64(),
desk.hls_sound().source
);
// The other tone at least 40 dB down: a loud 1 kHz tone puts its own third harmonic at
// 3 kHz about 90 dB below it (60 dB here), so an absolute floor would fail on that.
if want > 50.0 && want - not > 40.0 {
return Some((t0.elapsed().as_secs_f64(), want, not));
}
}
None
}
#[test]
#[ignore = "needs LiveKit, playout and the listener process: just test-feed"]
fn the_video_sound_comes_from_the_real_playout_unless_someone_is_live() {
gst::init().unwrap();
let srt_port = env("WEAVESIGNAL_ROOM_SRT_PORT");
let feed_port = env("WEAVESIGNAL_ROOM_DESK_FEED_PORT");
let dir = scratch();
let mut config = Config::from_toml(
&format!(
r#"
[livekit]
url = "ws://127.0.0.1:1"
api_key = "unused"
room = "unused"
[programme]
srt_port = {srt_port}
[programme.delay]
target_ms = 1000
[inputs.dj]
[video]
enabled = true
lower_third_ms = 0
[video.hls]
dir = "{}"
[video.playout_feed]
port = {feed_port}
crossfade_ms = 100
off_air_hold_ms = 500
"#,
dir.join("hls").display()
),
"feed stack test",
)
.unwrap();
config.video.playout_feed.enabled = true;
let desk = Desk::new(
config,
Outputs {
srt: true,
mix_minus_to_room: false,
},
)
.unwrap();
let hls = desk.tap_hls_sound();
desk.pipeline().set_state(gst::State::Playing).unwrap();
struct Stop(Desk);
impl Drop for Stop {
fn drop(&mut self) {
if let Some(v) = self.0.video() {
v.stop();
}
let _ = self.0.pipeline().set_state(gst::State::Null);
}
}
let _stop = Stop(desk.clone());
desk.add_person("callum", Role::Dj).unwrap();
let pad = tone(desk.pipeline(), "callum-voice", 1000.0);
desk.route_pad("callum", &pad).unwrap();
pad.parent_element()
.unwrap()
.sync_state_with_parent()
.unwrap();
let t0 = Instant::now();
// Playout connects to the desk's SRT listener and to its feed port by itself.
assert!(
wait_sound(&desk, Duration::from_secs(60), |s| s.feed_connected),
"the real playout never connected its feed to the desk: {:?}",
desk.hls_sound()
);
let connected_s = t0.elapsed().as_secs_f64();
// Nobody live: playout's sound, its fallback (3 kHz) once the desk has been quiet.
let fallback = until_tone(&desk, &hls, 3000.0, 1000.0, Duration::from_secs(40));
let first = desk.hls_sound();
// The DJ opens their strip: the desk's programme (1 kHz) once the delay has played out.
assert!(desk.set_on_air("callum", true));
let live = until_tone(&desk, &hls, 1000.0, 3000.0, Duration::from_secs(15));
let on_air = desk.hls_sound();
// Off air again: playout's sound, the fallback again once the desk is quiet.
assert!(desk.set_on_air("callum", false));
let back = until_tone(&desk, &hls, 3000.0, 1000.0, Duration::from_secs(40));
let after = desk.hls_sound();
println!(
"feed connected after {connected_s:.1} s; fallback {fallback:?} ({first:?}); live {live:?} \
({on_air:?}); back on playout {back:?} ({after:?}); {:.1} s of sound in {:.1} s",
hls.seconds(),
t0.elapsed().as_secs_f64()
);
if let Ok(path) = std::env::var("WEAVESIGNAL_ROOM_REPORT") {
let report = serde_json::json!({
"feed_connected_s": connected_s,
"fallback": fallback, "live": live, "back": back,
"feed_connections": after.feed_connections,
});
std::fs::write(path, serde_json::to_string_pretty(&report).unwrap()).unwrap();
}
let (_, f3, f1) = fallback.expect("playout's fallback never reached the video's sound");
assert_eq!(first.source, Source::Playout, "{first:?}");
let (_, l1, l3) = live.expect("the live DJ never reached the video's sound");
assert_eq!(on_air.source, Source::Desk, "{on_air:?}");
back.expect("playout's sound did not come back after the DJ went off air");
assert_eq!(after.source, Source::Playout, "{after:?}");
assert!(f3 > 50.0 && f3 - f1 > 40.0 && l1 > 50.0 && l1 - l3 > 40.0);
}