diff --git a/crates/wzp-client/src/cli.rs b/crates/wzp-client/src/cli.rs index 1e0cd1e..5930097 100644 --- a/crates/wzp-client/src/cli.rs +++ b/crates/wzp-client/src/cli.rs @@ -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 { + 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, + record_file: Option, +} + +fn parse_args() -> CliArgs { + let args: Vec = 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 Send a 440Hz test tone for N seconds"); + eprintln!(" --record 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 = 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) -> 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) -> anyhow::R tokio::time::sleep(frame_duration).await; } - info!( - total_source, - total_repair, - total_bytes, - "done — closing" - ); + 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, + send_tone_secs: Option, + record_file: Option, +) -> 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!( + 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 = 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 = 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) -> 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) -> 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) -> 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) -> 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) -> 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"); diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 0000000..89ba6ce --- /dev/null +++ b/scripts/build-linux.sh @@ -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:~/" diff --git a/scripts/cleanup-builder.sh b/scripts/cleanup-builder.sh new file mode 100755 index 0000000..b3dccc1 --- /dev/null +++ b/scripts/cleanup-builder.sh @@ -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."