feat: file-based audio testing + Hetzner build scripts

CLI modes:
- --send-tone <secs>: send 440Hz test tone (no mic needed)
- --record <file.raw>: save received audio to raw PCM file
- --help: usage info
- Combine: --send-tone 10 --record out.raw

Raw PCM format: 48kHz mono s16le
Play with: ffplay -f s16le -ar 48000 -ac 1 out.raw

Build scripts:
- scripts/build-linux.sh: Hetzner VPS build with auto-cleanup
- scripts/cleanup-builder.sh: kill stale builders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 16:11:59 +04:00
parent 85f472d824
commit 708fb268bc
3 changed files with 361 additions and 37 deletions

View File

@@ -1,64 +1,154 @@
//! WarzonePhone CLI test client.
//!
//! Usage: wzp-client [--live] [relay-addr]
//! Usage:
//! wzp-client [relay-addr] Send silence frames (connectivity test)
//! wzp-client --live [relay-addr] Live mic/speaker mode
//! wzp-client --send-tone 10 [relay-addr] Send 10s of 440Hz test tone
//! wzp-client --record out.raw [relay-addr] Record received audio to raw PCM file
//! wzp-client --send-tone 10 --record out.raw [relay-addr] Both at once
//!
//! Without `--live`: sends silence frames for testing.
//! With `--live`: captures microphone audio and plays received audio through speakers.
//! Raw PCM files are 48kHz mono 16-bit signed little-endian.
//! Play with: ffplay -f s16le -ar 48000 -ac 1 out.raw
//! Or convert: ffmpeg -f s16le -ar 48000 -ac 1 -i out.raw out.wav
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{error, info};
use wzp_client::audio_io::{AudioCapture, AudioPlayback, FRAME_SAMPLES};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport;
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
/// Generate a sine wave tone.
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64;
(0..FRAME_SAMPLES)
.map(|i| {
let t = (start_sample + i as u64) as f32 / sample_rate as f32;
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
})
.collect()
}
#[derive(Debug)]
struct CliArgs {
relay_addr: SocketAddr,
live: bool,
send_tone_secs: Option<u32>,
record_file: Option<String>,
}
fn parse_args() -> CliArgs {
let args: Vec<String> = std::env::args().collect();
let mut live = false;
let mut send_tone_secs = None;
let mut record_file = None;
let mut relay_str = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--live" => live = true,
"--send-tone" => {
i += 1;
send_tone_secs = Some(
args.get(i)
.expect("--send-tone requires seconds")
.parse()
.expect("--send-tone value must be a number"),
);
}
"--record" => {
i += 1;
record_file = Some(
args.get(i)
.expect("--record requires a filename")
.to_string(),
);
}
"--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]");
eprintln!();
eprintln!("Options:");
eprintln!(" --live Live mic/speaker mode");
eprintln!(" --send-tone <secs> Send a 440Hz test tone for N seconds");
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ac 1 file.raw)");
eprintln!();
eprintln!("Default relay: 127.0.0.1:4433");
std::process::exit(0);
}
other => {
if relay_str.is_none() && !other.starts_with('-') {
relay_str = Some(other.to_string());
} else {
eprintln!("unknown argument: {other}");
std::process::exit(1);
}
}
}
i += 1;
}
let relay_addr: SocketAddr = relay_str
.unwrap_or_else(|| "127.0.0.1:4433".to_string())
.parse()
.expect("invalid relay address");
CliArgs {
relay_addr,
live,
send_tone_secs,
record_file,
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().init();
let args: Vec<String> = std::env::args().collect();
let live = args.iter().any(|a| a == "--live");
let relay_addr: SocketAddr = args
.iter()
.skip(1)
.find(|a| *a != "--live")
.cloned()
.unwrap_or_else(|| "127.0.0.1:4433".to_string())
.parse()?;
let cli = parse_args();
info!(%relay_addr, live, "WarzonePhone client connecting");
info!(
relay = %cli.relay_addr,
live = cli.live,
send_tone = ?cli.send_tone_secs,
record = ?cli.record_file,
"WarzonePhone client"
);
let client_config = wzp_transport::client_config();
// Use same address family as the relay address to avoid IPv4/IPv6 mismatch.
let bind_addr = if relay_addr.is_ipv6() {
let bind_addr = if cli.relay_addr.is_ipv6() {
"[::]:0".parse()?
} else {
"0.0.0.0:0".parse()?
};
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let connection =
wzp_transport::connect(&endpoint, relay_addr, "localhost", client_config).await?;
wzp_transport::connect(&endpoint, cli.relay_addr, "localhost", client_config).await?;
info!("Connected to relay");
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
if live {
if cli.live {
run_live(transport).await
} else if cli.send_tone_secs.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.record_file).await
} else {
run_silence(transport).await
}
}
/// Original test mode: send silence frames.
/// Send silence frames (connectivity test).
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
let config = CallConfig::default();
let mut encoder = CallEncoder::new(&config);
let frame_duration = tokio::time::Duration::from_millis(20);
let pcm = vec![0i16; FRAME_SAMPLES]; // 20ms @ 48kHz silence
let pcm = vec![0i16; FRAME_SAMPLES];
let mut total_source = 0u64;
let mut total_repair = 0u64;
@@ -90,25 +180,159 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
tokio::time::sleep(frame_duration).await;
}
info!(total_source, total_repair, total_bytes, "done — closing");
transport.close().await?;
Ok(())
}
/// File/tone mode: send a test tone and/or record received audio.
async fn run_file_mode(
transport: Arc<wzp_transport::QuinnTransport>,
send_tone_secs: Option<u32>,
record_file: Option<String>,
) -> anyhow::Result<()> {
let config = CallConfig::default();
// --- Send task: generate tone and send ---
let send_transport = transport.clone();
let send_handle = tokio::spawn(async move {
let secs = match send_tone_secs {
Some(s) => s,
None => {
// No sending, just wait
tokio::signal::ctrl_c().await.ok();
return;
}
};
let mut encoder = CallEncoder::new(&config);
let total_frames = (secs as u64) * 50; // 50 frames/sec at 20ms
let frame_duration = tokio::time::Duration::from_millis(20);
let mut total_source = 0u64;
let mut total_repair = 0u64;
info!(seconds = secs, frames = total_frames, "sending 440Hz tone");
for frame_idx in 0..total_frames {
let pcm = generate_sine_frame(440.0, 48_000, frame_idx);
let packets = match encoder.encode_frame(&pcm) {
Ok(p) => p,
Err(e) => {
error!("encode error: {e}");
continue;
}
};
for pkt in &packets {
if pkt.header.is_repair {
total_repair += 1;
} else {
total_source += 1;
}
if let Err(e) = send_transport.send_media(pkt).await {
error!("send error: {e}");
return;
}
}
if (frame_idx + 1) % 250 == 0 {
info!(
total_source,
total_repair,
total_bytes,
"done — closing"
frame = frame_idx + 1,
source = total_source,
repair = total_repair,
"send progress"
);
}
tokio::time::sleep(frame_duration).await;
}
info!(total_source, total_repair, "tone send complete");
});
// --- Recv task: decode and write to file ---
let recv_transport = transport.clone();
let recv_handle = tokio::spawn(async move {
let record_path = match record_file {
Some(p) => p,
None => {
// No recording, just wait
tokio::signal::ctrl_c().await.ok();
return;
}
};
let mut decoder = CallDecoder::new(&CallConfig::default());
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
let mut all_pcm: Vec<i16> = Vec::new();
let mut frames_received = 0u64;
info!(file = %record_path, "recording received audio");
loop {
match recv_transport.recv_media().await {
Ok(Some(pkt)) => {
decoder.ingest(pkt);
while let Some(n) = decoder.decode_next(&mut pcm_buf) {
all_pcm.extend_from_slice(&pcm_buf[..n]);
frames_received += 1;
if frames_received % 250 == 0 {
info!(
frames = frames_received,
samples = all_pcm.len(),
"recv progress"
);
}
}
}
Ok(None) => {
info!("connection closed by remote");
break;
}
Err(e) => {
error!("recv error: {e}");
break;
}
}
}
// Write raw PCM to file
if !all_pcm.is_empty() {
let bytes: Vec<u8> = all_pcm
.iter()
.flat_map(|s| s.to_le_bytes())
.collect();
if let Err(e) = std::fs::write(&record_path, &bytes) {
error!(file = %record_path, "write error: {e}");
} else {
let duration_secs = all_pcm.len() as f64 / 48_000.0;
info!(
file = %record_path,
frames = frames_received,
samples = all_pcm.len(),
duration_secs = format!("{:.1}", duration_secs),
bytes = bytes.len(),
"recording saved"
);
info!("play with: ffplay -f s16le -ar 48000 -ac 1 {record_path}");
}
} else {
info!("no audio received, nothing to write");
}
});
// Wait for both tasks
let _ = tokio::join!(send_handle, recv_handle);
transport.close().await?;
Ok(())
}
/// Live mode: capture from mic, encode, send; receive, decode, play.
async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?;
info!("Audio I/O started — press Ctrl+C to stop");
// --- Send task: mic -> encode -> transport ---
// AudioCapture::read_frame() is blocking, so we run this on a dedicated
// OS thread. We use the tokio Handle to call the async send_media.
let send_transport = transport.clone();
let rt_handle = tokio::runtime::Handle::current();
let send_handle = std::thread::Builder::new()
@@ -119,7 +343,7 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
loop {
let frame = match capture.read_frame() {
Some(f) => f,
None => break, // channel closed / stopped
None => break,
};
let packets = match encoder.encode_frame(&frame) {
Ok(p) => p,
@@ -137,7 +361,6 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
}
})?;
// --- Recv task: transport -> decode -> speaker ---
let recv_transport = transport.clone();
let recv_handle = tokio::spawn(async move {
let config = CallConfig::default();
@@ -152,7 +375,6 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
}
}
Ok(None) => {
// No packet available right now, yield briefly.
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
}
Err(e) => {
@@ -163,14 +385,10 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
}
});
// Wait for Ctrl+C
tokio::signal::ctrl_c()
.await
.expect("failed to listen for Ctrl+C");
tokio::signal::ctrl_c().await?;
info!("Shutting down...");
recv_handle.abort();
// The send thread will exit once capture is dropped / stopped.
drop(send_handle);
transport.close().await?;
info!("done");

95
scripts/build-linux.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
# Build WarzonePhone Linux x86_64 release binaries using a Hetzner Cloud VPS.
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
#
# Usage: ./scripts/build-linux.sh
#
# Outputs: target/linux-x86_64/wzp-relay, wzp-client, wzp-bench
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_NAME="wzp-builder-$(date +%s)"
SERVER_TYPE="cx23"
IMAGE="ubuntu-24.04"
REMOTE_USER="root"
OUTPUT_DIR="target/linux-x86_64"
echo "=== WarzonePhone Linux Build ==="
# Ensure server gets deleted on any exit (success or failure)
cleanup() {
if [ -n "${SERVER_NAME:-}" ]; then
echo " Cleaning up server $SERVER_NAME..."
hcloud server delete "$SERVER_NAME" 2>/dev/null || true
fi
rm -f /tmp/wzp-src.tar.gz
}
trap cleanup EXIT
# 1. Create the build server
echo "[1/7] Creating Hetzner server..."
hcloud server create \
--name "$SERVER_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location fsn1 \
--quiet
SERVER_IP=$(hcloud server ip "$SERVER_NAME")
echo " Server: $SERVER_NAME @ $SERVER_IP"
# SSH options: skip host key check, use our key
SSH="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -i $SSH_KEY_PATH $REMOTE_USER@$SERVER_IP"
SCP="scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i $SSH_KEY_PATH"
# 2. Wait for SSH to come up
echo "[2/7] Waiting for SSH..."
for i in $(seq 1 30); do
if $SSH "echo ok" &>/dev/null; then
break
fi
sleep 2
done
# 3. Install build dependencies
echo "[3/7] Installing build dependencies..."
$SSH "apt-get update -qq && apt-get install -y -qq build-essential cmake pkg-config libasound2-dev curl git > /dev/null 2>&1"
# 4. Install Rust
echo "[4/7] Installing Rust..."
$SSH "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1"
# 5. Upload source code
echo "[5/7] Uploading source code..."
# Create a tarball excluding target/ and .git/
tar czf /tmp/wzp-src.tar.gz \
--exclude='target' \
--exclude='.git' \
--exclude='.claude' \
-C /Users/manwe/CascadeProjects/warzonePhone .
$SCP /tmp/wzp-src.tar.gz "$REMOTE_USER@$SERVER_IP:/root/wzp-src.tar.gz"
$SSH "mkdir -p /root/warzonePhone && tar xzf /root/wzp-src.tar.gz -C /root/warzonePhone"
# 6. Build release binaries
echo "[6/7] Building release binaries (this takes a few minutes)..."
$SSH "source ~/.cargo/env && cd /root/warzonePhone && cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-bench 2>&1" | tail -5
# 7. Download binaries
echo "[7/7] Downloading binaries..."
mkdir -p "$OUTPUT_DIR"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-relay" "$OUTPUT_DIR/wzp-relay"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-client" "$OUTPUT_DIR/wzp-client"
$SCP "$REMOTE_USER@$SERVER_IP:/root/warzonePhone/target/release/wzp-bench" "$OUTPUT_DIR/wzp-bench"
# Show results (server is deleted by EXIT trap)
echo ""
echo "=== Build Complete ==="
ls -lh "$OUTPUT_DIR"/wzp-*
echo ""
echo "Deploy with:"
echo " scp $OUTPUT_DIR/wzp-relay $OUTPUT_DIR/wzp-bench user@relay-server:~/"
echo " scp $OUTPUT_DIR/wzp-client $OUTPUT_DIR/wzp-bench user@destination:~/"

11
scripts/cleanup-builder.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Clean up any wzp-builder servers left running
echo "Looking for wzp-builder servers..."
hcloud server list -o noheader | grep wzp-builder | while read -r line; do
id=$(echo "$line" | awk '{print $1}')
name=$(echo "$line" | awk '{print $2}')
echo " Deleting $name (id=$id)..."
hcloud server delete "$id"
done
echo "Done."