#![allow(clippy::similar_names)]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
use std::process::Stdio;
use std::time::Duration;
use bytes::Bytes;
use sipx_audio::read_wav;
use sipx_call::{DialOptions, dial};
use sipx_sip::Uri;
use sipx_transport::{Config as TransportConfig, Target, bind};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;
const SAMPLE_RATE: usize = 8000;
fn loopback() -> std::net::IpAddr {
"127.0.0.1".parse().expect("valid")
}
fn tone(milliseconds: usize) -> Vec<i16> {
(0..milliseconds * SAMPLE_RATE / 1000)
.map(|i| {
let t = f64::from(u32::try_from(i).unwrap_or(0)) / SAMPLE_RATE as f64;
let envelope = (t * 4.0).min(1.0);
let value = (t * 440.0 * 2.0 * std::f64::consts::PI).sin() * 12000.0 * envelope;
i16::try_from(value.round() as i32).unwrap_or(0)
})
.collect()
}
struct Heard {
samples: usize,
report: String,
}
async fn record_a_call(case: &str, hang_up_after: u64, after: Duration, clip: usize) -> Heard {
let dir = std::env::temp_dir().join(format!("sipx-cli-{}-{case}", std::process::id()));
std::fs::create_dir_all(&dir).expect("a scratch directory");
let recording = dir.join("heard-by-callee.wav");
let mut config = TransportConfig::new("127.0.0.1:0".parse().expect("valid"));
config.sent_by = loopback().to_string();
let (handle, _incoming) = bind(config).await.expect("binds");
let mut answerer = Command::new(env!("CARGO_BIN_EXE_sipx"))
.args([
"answer",
"--local",
"127.0.0.1:0",
"--json",
"--wait",
"300",
"--duration",
&hang_up_after.to_string(),
"--record",
recording.to_str().expect("a path"),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("spawns");
let stdout = answerer.stdout.take().expect("piped");
let mut stderr = answerer.stderr.take().expect("piped");
let mut lines = BufReader::new(stdout).lines();
let listening = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.expect("no timeout")
.expect("a line")
.expect("the address line");
let address = listening
.split("\"address\":\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("an address")
.to_owned();
let to = Uri::parse(Bytes::from(format!("sip:answer@{address}"))).expect("a SIP URI");
let options = DialOptions::new("<sip:caller@127.0.0.1>", loopback())
.with_timeout(Duration::from_secs(15));
let mut call = dial(
&handle,
Target::udp(address.parse().expect("an address")),
&to,
&options,
)
.await
.expect("the answerer accepts the call");
let media = call.media();
let (_, report) = tokio::join!(
async {
tokio::time::sleep(after).await;
media.play(&tone(clip), 160).await
},
async {
tokio::time::timeout(Duration::from_secs(40), lines.next_line())
.await
.expect("the answerer reports rather than hanging")
.expect("reads the answerer's stdout")
}
);
let _ = call.hang_up().await;
let status = tokio::time::timeout(Duration::from_secs(30), answerer.wait())
.await
.expect("the answerer exits")
.expect("waits");
let mut error = String::new();
stderr
.read_to_string(&mut error)
.await
.expect("reads stderr");
let report = report.unwrap_or_else(|| {
let exit_path = if status.code() == Some(5) {
"the wait-for-call bound expired before an INVITE arrived"
} else if status.success() {
"the answerer exited successfully without emitting its result"
} else if status.code().is_none() {
"the answerer was terminated by a signal"
} else {
"the answerer failed before emitting its result"
};
panic!(
"answerer stdout closed before the result line: {exit_path}; exit={status}; stderr={error:?}"
);
});
assert!(
status.success(),
"the answerer exited with {status}; these tests are about what it recorded, not about it \
crashing: report={report}; stderr={error:?}"
);
let heard = read_wav(std::fs::File::open(&recording).expect("opens")).expect("reads");
let samples = heard.samples.len();
let _ = std::fs::remove_dir_all(&dir);
Heard { samples, report }
}
#[tokio::test]
async fn audio_that_starts_at_once_is_recorded() {
let heard = record_a_call("at-once", 10, Duration::ZERO, 400).await;
assert!(
heard.samples > 0,
"the control case recorded nothing, so nothing else in this file proves anything: {}",
heard.report
);
}
#[tokio::test]
async fn audio_that_starts_late_is_recorded_too() {
let heard = record_a_call("late", 10, Duration::from_millis(1500), 400).await;
assert!(
heard.samples > 0,
"the call carried 400 ms of tone and the answerer recorded none of it: waiting for the \
first frame must not share a window with deciding the stream has ended: {}",
heard.report
);
}
#[tokio::test]
async fn a_recording_cut_short_by_the_cap_is_kept() {
let cap = 2;
let heard = record_a_call("cut-short", cap, Duration::ZERO, 6000).await;
assert!(
heard.samples > SAMPLE_RATE / 2,
"the call's time ran out mid-stream and the answerer kept only {} samples; a recording the \
cap cut short is still the audio the call carried, and must not be replaced by silence: {}",
heard.samples,
heard.report
);
let whole_clip = 6 * SAMPLE_RATE;
assert!(
heard.samples < whole_clip,
"the answerer recorded {} samples of a {whole_clip}-sample clip despite being told to hang \
up after {cap}s, so the cap never fired and this case did not exercise it: {}",
heard.samples,
heard.report
);
}