Compare commits
8 Commits
2263e898e5
...
debug/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073756ed4b | ||
|
|
2fcc2d77cf | ||
|
|
f7ccb67b02 | ||
|
|
4df08eadbd | ||
|
|
6d776097c8 | ||
|
|
9f7962a6cd | ||
|
|
8c9befb15d | ||
|
|
3f869a4cd7 |
@@ -7,6 +7,8 @@ on:
|
||||
- 'feat/*'
|
||||
tags:
|
||||
- 'v*'
|
||||
paths-ignore:
|
||||
- '.gitea/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
43
.gitea/workflows/mirror-github.yml
Normal file
43
.gitea/workflows/mirror-github.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/*'
|
||||
- 'feature/*'
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GH_SSH_KEY: ${{ secrets.GH_SSH_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${GH_SSH_KEY}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
git remote add github git@github.com:manawenuz/wzp.git
|
||||
|
||||
# Push the current branch
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then
|
||||
echo "Pushing tag: ${TAG}"
|
||||
git push github "refs/tags/${TAG}" --force
|
||||
else
|
||||
echo "Pushing branch: ${BRANCH}"
|
||||
git push github "HEAD:refs/heads/${BRANCH}" --force
|
||||
fi
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -7238,6 +7238,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"bytes",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"prometheus",
|
||||
"quinn",
|
||||
|
||||
@@ -18,7 +18,7 @@ const RING_MASK: usize = RING_CAPACITY - 1;
|
||||
|
||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||
pub struct AudioRing {
|
||||
buf: Box<[i16; RING_CAPACITY]>,
|
||||
buf: Box<[i16]>,
|
||||
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||
write_pos: AtomicUsize,
|
||||
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||
@@ -40,7 +40,7 @@ impl AudioRing {
|
||||
pub fn new() -> Self {
|
||||
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||
Self {
|
||||
buf: Box::new([0i16; RING_CAPACITY]),
|
||||
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||
write_pos: AtomicUsize::new(0),
|
||||
read_pos: AtomicUsize::new(0),
|
||||
overflow_count: AtomicU64::new(0),
|
||||
|
||||
@@ -500,6 +500,49 @@ impl CallDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the decoder to match an incoming packet's codec if it differs
|
||||
/// from the current profile. This enables cross-codec interop (e.g. one
|
||||
/// client sends Opus, the other sends Codec2).
|
||||
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
|
||||
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
|
||||
return;
|
||||
}
|
||||
let new_profile = Self::profile_for_codec(incoming_codec);
|
||||
info!(
|
||||
from = ?self.profile.codec,
|
||||
to = ?incoming_codec,
|
||||
"decoder switching codec to match incoming packet"
|
||||
);
|
||||
if let Err(e) = self.audio_dec.set_profile(new_profile) {
|
||||
warn!("failed to switch decoder profile: {e}");
|
||||
return;
|
||||
}
|
||||
self.fec_dec = wzp_fec::create_decoder(&new_profile);
|
||||
self.profile = new_profile;
|
||||
}
|
||||
|
||||
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
|
||||
fn profile_for_codec(codec: CodecId) -> QualityProfile {
|
||||
match codec {
|
||||
CodecId::Opus24k => QualityProfile::GOOD,
|
||||
CodecId::Opus16k => QualityProfile {
|
||||
codec: CodecId::Opus16k,
|
||||
fec_ratio: 0.3,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||
CodecId::Codec2_3200 => QualityProfile {
|
||||
codec: CodecId::Codec2_3200,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||
CodecId::ComfortNoise => QualityProfile::GOOD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the next audio frame from the jitter buffer.
|
||||
///
|
||||
/// Returns PCM samples (48kHz mono) or None if not ready.
|
||||
@@ -514,6 +557,9 @@ impl CallDecoder {
|
||||
return Some(pcm.len());
|
||||
}
|
||||
|
||||
// Auto-switch decoder if incoming codec differs from current.
|
||||
self.switch_decoder_if_needed(pkt.header.codec_id);
|
||||
|
||||
self.last_was_cn = false;
|
||||
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
||||
Ok(n) => Some(n),
|
||||
|
||||
@@ -19,12 +19,18 @@ use tracing::{error, info, warn};
|
||||
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
|
||||
use wzp_proto::MediaTransport;
|
||||
|
||||
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
|
||||
const FRAME_SAMPLES_20MS: usize = 960; // 20ms @ 48kHz
|
||||
const FRAME_SAMPLES_40MS: usize = 1920; // 40ms @ 48kHz
|
||||
|
||||
/// Compute frame samples at 48kHz for a given profile.
|
||||
fn frame_samples_for(profile: &wzp_proto::QualityProfile) -> usize {
|
||||
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
|
||||
}
|
||||
|
||||
/// 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)
|
||||
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64, frame_samples: usize) -> 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
|
||||
@@ -57,6 +63,8 @@ struct CliArgs {
|
||||
os_aec: bool,
|
||||
token: Option<String>,
|
||||
_metrics_file: Option<String>,
|
||||
/// Force a quality profile: "good", "degraded", "catastrophic", "codec2-3200"
|
||||
profile_override: Option<String>,
|
||||
}
|
||||
|
||||
/// Default identity file path: ~/.wzp/identity
|
||||
@@ -112,6 +120,27 @@ impl CliArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a profile name to a QualityProfile.
|
||||
fn resolve_profile(name: &str) -> wzp_proto::QualityProfile {
|
||||
use wzp_proto::{CodecId, QualityProfile};
|
||||
match name.to_lowercase().as_str() {
|
||||
"good" | "opus" | "opus24k" => QualityProfile::GOOD,
|
||||
"degraded" | "opus6k" => QualityProfile::DEGRADED,
|
||||
"catastrophic" | "codec2-1200" | "c2-1200" | "1200" => QualityProfile::CATASTROPHIC,
|
||||
"codec2-3200" | "c2-3200" | "3200" => QualityProfile {
|
||||
codec: CodecId::Codec2_3200,
|
||||
fec_ratio: 0.5,
|
||||
frame_duration_ms: 20,
|
||||
frames_per_block: 5,
|
||||
},
|
||||
other => {
|
||||
eprintln!("unknown profile: {other}");
|
||||
eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> CliArgs {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut live = false;
|
||||
@@ -136,6 +165,7 @@ fn parse_args() -> CliArgs {
|
||||
let mut os_aec = false;
|
||||
let mut token = None;
|
||||
let mut metrics_file = None;
|
||||
let mut profile_override = None;
|
||||
let mut relay_str = None;
|
||||
|
||||
let mut i = 1;
|
||||
@@ -237,6 +267,14 @@ fn parse_args() -> CliArgs {
|
||||
.expect("--drift-test value must be a number"),
|
||||
);
|
||||
}
|
||||
"--profile" | "--codec" => {
|
||||
i += 1;
|
||||
profile_override = Some(
|
||||
args.get(i)
|
||||
.expect("--profile requires a value (good, degraded, catastrophic, codec2-3200)")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"--sweep" => sweep = true,
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||
@@ -248,6 +286,8 @@ fn parse_args() -> CliArgs {
|
||||
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
|
||||
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
||||
eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
|
||||
eprintln!(" --profile <name> Force quality profile: good, degraded, catastrophic, codec2-3200");
|
||||
eprintln!(" --codec <name> Alias for --profile");
|
||||
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
|
||||
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
||||
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
||||
@@ -312,6 +352,7 @@ fn parse_args() -> CliArgs {
|
||||
os_aec,
|
||||
token,
|
||||
_metrics_file: metrics_file,
|
||||
profile_override,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,12 +373,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let seed = cli.resolve_seed();
|
||||
|
||||
// Resolve profile override
|
||||
let profile = cli.profile_override.as_deref().map(resolve_profile);
|
||||
if let Some(ref p) = profile {
|
||||
info!(codec = ?p.codec, frame_ms = p.frame_duration_ms, fec = p.fec_ratio, "forced profile");
|
||||
}
|
||||
|
||||
info!(
|
||||
relay = %cli.relay_addr,
|
||||
live = cli.live,
|
||||
send_tone = ?cli.send_tone_secs,
|
||||
record = ?cli.record_file,
|
||||
room = ?cli.room,
|
||||
profile = ?cli.profile_override,
|
||||
"WarzonePhone client"
|
||||
);
|
||||
|
||||
@@ -400,6 +448,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
direct_playout: cli.direct_playout,
|
||||
aec_delay_ms: cli.aec_delay_ms,
|
||||
os_aec: cli.os_aec,
|
||||
profile_override: profile,
|
||||
};
|
||||
return run_live(transport, audio_opts).await;
|
||||
}
|
||||
@@ -422,19 +471,23 @@ async fn main() -> anyhow::Result<()> {
|
||||
transport.close().await?;
|
||||
Ok(())
|
||||
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
|
||||
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await
|
||||
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file, profile).await
|
||||
} else {
|
||||
run_silence(transport).await
|
||||
run_silence(transport, profile).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Send silence frames (connectivity test).
|
||||
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
|
||||
let config = CallConfig::default();
|
||||
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>, profile: Option<wzp_proto::QualityProfile>) -> anyhow::Result<()> {
|
||||
let config = match profile {
|
||||
Some(p) => CallConfig::from_profile(p),
|
||||
None => CallConfig::default(),
|
||||
};
|
||||
let frame_samples = frame_samples_for(&config.profile);
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
|
||||
let frame_duration = tokio::time::Duration::from_millis(20);
|
||||
let pcm = vec![0i16; FRAME_SAMPLES];
|
||||
let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64);
|
||||
let pcm = vec![0i16; frame_samples];
|
||||
|
||||
let mut total_source = 0u64;
|
||||
let mut total_repair = 0u64;
|
||||
@@ -480,13 +533,20 @@ async fn run_file_mode(
|
||||
send_tone_secs: Option<u32>,
|
||||
send_file: Option<String>,
|
||||
record_file: Option<String>,
|
||||
profile: Option<wzp_proto::QualityProfile>,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = CallConfig::default();
|
||||
let config = match profile {
|
||||
Some(p) => CallConfig::from_profile(p),
|
||||
None => CallConfig::default(),
|
||||
};
|
||||
let frame_samples = frame_samples_for(&config.profile);
|
||||
let frame_duration_ms = config.profile.frame_duration_ms as u64;
|
||||
|
||||
// --- Send task: generate tone or play file ---
|
||||
let send_transport = transport.clone();
|
||||
let send_handle = tokio::spawn(async move {
|
||||
// Load PCM frames from file or generate tone
|
||||
let frames_per_sec = 1000 / frame_duration_ms;
|
||||
let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file {
|
||||
// Read raw PCM file (48kHz mono s16le)
|
||||
let bytes = match std::fs::read(path) {
|
||||
@@ -498,14 +558,14 @@ async fn run_file_mode(
|
||||
.collect();
|
||||
let duration = samples.len() as f64 / 48_000.0;
|
||||
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
|
||||
samples.chunks(FRAME_SAMPLES)
|
||||
.filter(|c| c.len() == FRAME_SAMPLES)
|
||||
samples.chunks(frame_samples)
|
||||
.filter(|c| c.len() == frame_samples)
|
||||
.map(|c| c.to_vec())
|
||||
.collect()
|
||||
} else if let Some(secs) = send_tone_secs {
|
||||
let total = (secs as u64) * 50;
|
||||
info!(seconds = secs, frames = total, "sending 440Hz tone");
|
||||
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect()
|
||||
let total = (secs as u64) * frames_per_sec;
|
||||
info!(seconds = secs, frames = total, frame_samples, frame_ms = frame_duration_ms, "sending 440Hz tone");
|
||||
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i, frame_samples)).collect()
|
||||
} else {
|
||||
// No sending, just wait
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
@@ -514,7 +574,7 @@ async fn run_file_mode(
|
||||
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
let _total_frames = pcm_frames.len() as u64;
|
||||
let frame_duration = tokio::time::Duration::from_millis(20);
|
||||
let frame_duration = tokio::time::Duration::from_millis(frame_duration_ms);
|
||||
|
||||
let mut total_source = 0u64;
|
||||
let mut total_repair = 0u64;
|
||||
@@ -564,8 +624,13 @@ async fn run_file_mode(
|
||||
}
|
||||
};
|
||||
|
||||
let mut decoder = CallDecoder::new(&CallConfig::default());
|
||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let recv_config = match profile {
|
||||
Some(p) => CallConfig::from_profile(p),
|
||||
None => CallConfig::default(),
|
||||
};
|
||||
let recv_frame_samples = frame_samples_for(&recv_config.profile);
|
||||
let mut decoder = CallDecoder::new(&recv_config);
|
||||
let mut pcm_buf = vec![0i16; recv_frame_samples.max(FRAME_SAMPLES_40MS)];
|
||||
let mut all_pcm: Vec<i16> = Vec::new();
|
||||
let mut frames_received = 0u64;
|
||||
|
||||
@@ -704,6 +769,7 @@ struct AudioOpts {
|
||||
direct_playout: bool,
|
||||
aec_delay_ms: Option<u32>,
|
||||
os_aec: bool,
|
||||
profile_override: Option<wzp_proto::QualityProfile>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -788,12 +854,18 @@ async fn run_live(
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let base_config = match opts.profile_override {
|
||||
Some(p) => CallConfig::from_profile(p),
|
||||
None => CallConfig::default(),
|
||||
};
|
||||
let config = CallConfig {
|
||||
noise_suppression: !opts.no_denoise,
|
||||
suppression_enabled: !opts.no_silence,
|
||||
aec_delay_ms: opts.aec_delay_ms.unwrap_or(40),
|
||||
..CallConfig::default()
|
||||
..base_config
|
||||
};
|
||||
let frame_samples = frame_samples_for(&config.profile);
|
||||
info!(codec = ?config.profile.codec, frame_samples, frame_ms = config.profile.frame_duration_ms, "call config");
|
||||
{
|
||||
let mut flags = Vec::new();
|
||||
if opts.no_denoise { flags.push("denoise"); }
|
||||
@@ -819,8 +891,8 @@ async fn run_live(
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
if no_aec { encoder.set_aec_enabled(false); }
|
||||
if no_agc { encoder.set_agc_enabled(false); }
|
||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let mut farend_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let mut capture_buf = vec![0i16; frame_samples];
|
||||
let mut farend_buf = vec![0i16; frame_samples];
|
||||
let mut frames_sent: u64 = 0;
|
||||
let mut frames_dropped: u64 = 0;
|
||||
let mut send_errors: u64 = 0;
|
||||
@@ -834,19 +906,19 @@ async fn run_live(
|
||||
}
|
||||
|
||||
let avail = capture_ring.available();
|
||||
if avail < FRAME_SAMPLES {
|
||||
if avail < frame_samples {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
polls += 1;
|
||||
// Diagnostic every 2 seconds
|
||||
if last_diag.elapsed().as_secs() >= 2 {
|
||||
info!(avail, polls, frames_sent, "send: ring starved (avail < {FRAME_SAMPLES})");
|
||||
info!(avail, polls, frames_sent, frame_samples, "send: ring starved");
|
||||
last_diag = std::time::Instant::now();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let read = capture_ring.read(&mut capture_buf);
|
||||
if read < FRAME_SAMPLES {
|
||||
if read < frame_samples {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -858,7 +930,7 @@ async fn run_live(
|
||||
// Feed AEC far-end reference: what was played through the speaker.
|
||||
// Must be called BEFORE encode_frame processes the mic signal.
|
||||
if !no_aec {
|
||||
while send_farend.available() >= FRAME_SAMPLES {
|
||||
while send_farend.available() >= frame_samples {
|
||||
send_farend.read(&mut farend_buf);
|
||||
encoder.feed_aec_farend(&farend_buf);
|
||||
}
|
||||
@@ -903,6 +975,8 @@ async fn run_live(
|
||||
let recv_running = running.clone();
|
||||
let recv_spk_muted = spk_muted.clone();
|
||||
let direct_playout = opts.direct_playout;
|
||||
let recv_profile = opts.profile_override;
|
||||
let playout_profile = recv_profile; // Copy for playout_task
|
||||
|
||||
// Direct playout: decode on recv, write straight to playout ring (like Android).
|
||||
// Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick.
|
||||
@@ -917,14 +991,15 @@ async fn run_live(
|
||||
let mut packets_received: u64 = 0;
|
||||
let mut recv_errors: u64 = 0;
|
||||
let mut timeouts: u64 = 0;
|
||||
// For direct playout: raw Opus decoder + AGC
|
||||
// For direct playout: raw codec decoder + AGC
|
||||
let direct_profile = recv_profile.unwrap_or(wzp_proto::QualityProfile::GOOD);
|
||||
let mut opus_dec = if direct_playout {
|
||||
Some(wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD))
|
||||
Some(wzp_codec::create_decoder(direct_profile))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut playout_agc = wzp_codec::AutoGainControl::new();
|
||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let mut pcm_buf = vec![0i16; frame_samples.max(FRAME_SAMPLES_40MS)];
|
||||
|
||||
loop {
|
||||
if !recv_running.load(Ordering::Relaxed) {
|
||||
@@ -1019,10 +1094,15 @@ async fn run_live(
|
||||
return;
|
||||
}
|
||||
|
||||
let config = CallConfig::default();
|
||||
let mut decoder = CallDecoder::new(&config);
|
||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
let playout_config = match playout_profile {
|
||||
Some(p) => CallConfig::from_profile(p),
|
||||
None => CallConfig::default(),
|
||||
};
|
||||
let playout_frame_ms = playout_config.profile.frame_duration_ms as u64;
|
||||
let playout_frame_samples = frame_samples_for(&playout_config.profile);
|
||||
let mut decoder = CallDecoder::new(&playout_config);
|
||||
let mut pcm_buf = vec![0i16; playout_frame_samples.max(FRAME_SAMPLES_40MS)];
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(playout_frame_ms));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut telemetry = JitterTelemetry::new(5);
|
||||
loop {
|
||||
|
||||
@@ -28,6 +28,7 @@ prometheus = "0.13"
|
||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
futures-util = "0.3"
|
||||
dirs = "6"
|
||||
|
||||
[[bin]]
|
||||
name = "wzp-relay"
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use wzp_proto::MediaTransport;
|
||||
use wzp_relay::config::RelayConfig;
|
||||
@@ -207,8 +207,39 @@ async fn main() -> anyhow::Result<()> {
|
||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||
}
|
||||
|
||||
// Generate ephemeral relay identity for crypto handshake
|
||||
let relay_seed = wzp_crypto::Seed::generate();
|
||||
// Load or generate relay identity — persisted in ~/.wzp/relay-identity
|
||||
let relay_seed = {
|
||||
let config_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".wzp");
|
||||
let identity_path = config_dir.join("relay-identity");
|
||||
if identity_path.exists() {
|
||||
if let Ok(hex) = std::fs::read_to_string(&identity_path) {
|
||||
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
||||
info!("loaded relay identity from {}", identity_path.display());
|
||||
s
|
||||
} else {
|
||||
warn!("corrupt relay identity file, generating new");
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&identity_path, &hex);
|
||||
s
|
||||
}
|
||||
} else {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&identity_path, &hex);
|
||||
s
|
||||
}
|
||||
} else {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
let _ = std::fs::create_dir_all(&config_dir);
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let _ = std::fs::write(&identity_path, &hex);
|
||||
info!("generated relay identity at {}", identity_path.display());
|
||||
s
|
||||
}
|
||||
};
|
||||
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
||||
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
||||
|
||||
@@ -299,6 +330,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||
|
||||
// Ping connections: client just measures QUIC connect RTT.
|
||||
// No handshake, no streams — client closes immediately after connecting.
|
||||
if room_name == "ping" {
|
||||
info!(%addr, "ping connection (RTT probe)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe connections use SNI "_probe" to identify themselves.
|
||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||
if room_name == "_probe" {
|
||||
|
||||
8
desktop/.vite/deps/_metadata.json
Normal file
8
desktop/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "9046c0bf",
|
||||
"configHash": "ef0fc96f",
|
||||
"lockfileHash": "d66891b1",
|
||||
"browserHash": "8171ed59",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
desktop/.vite/deps/package.json
Normal file
3
desktop/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -55,16 +55,19 @@ async fn ping_relay(relay: String) -> Result<PingResult, String> {
|
||||
let client_cfg = wzp_transport::client_config();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
match tokio::time::timeout(
|
||||
let conn_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
|
||||
// Always close endpoint to prevent resource leaks
|
||||
endpoint.close(0u32.into(), b"done");
|
||||
|
||||
match conn_result {
|
||||
Ok(Ok(conn)) => {
|
||||
let rtt_ms = start.elapsed().as_millis() as u32;
|
||||
|
||||
// Extract server fingerprint from peer certificate
|
||||
let server_fingerprint = conn
|
||||
.peer_identity()
|
||||
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||
@@ -76,7 +79,6 @@ async fn ping_relay(relay: String) -> Result<PingResult, String> {
|
||||
format!("{h:016x}")
|
||||
}))
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback: hash the remote address as identifier
|
||||
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
|
||||
});
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ async function doConnect() {
|
||||
saveSettingsObj(s);
|
||||
}
|
||||
|
||||
if (ls === "offline") { connectError.textContent = "Relay is offline"; return; }
|
||||
// Don't block connect on offline — ping may have failed transiently
|
||||
|
||||
connectError.textContent = "";
|
||||
connectBtn.disabled = true;
|
||||
|
||||
122
scripts/build-linux-notify.sh
Executable file
122
scripts/build-linux-notify.sh
Executable file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build WarzonePhone Linux x86_64 binaries via Hetzner Cloud VPS.
|
||||
# Fire and forget — notifies via ntfy.sh/wzp with rustypaste URL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-linux-notify.sh Full: create VM → build → upload → notify → destroy
|
||||
# ./scripts/build-linux-notify.sh --keep Keep VM after build
|
||||
# ./scripts/build-linux-notify.sh --pull Git pull (for existing VM)
|
||||
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="cx33"
|
||||
IMAGE="debian-12"
|
||||
SERVER_NAME="wzp-linux-builder"
|
||||
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||
LOCAL_OUTPUT="target/linux-x86_64"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 -o ServerAliveInterval=15 -o LogLevel=ERROR"
|
||||
|
||||
KEEP_VM=0
|
||||
DO_PULL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep) KEEP_VM=1 ;;
|
||||
--pull) DO_PULL=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
|
||||
|
||||
get_vm_ip() {
|
||||
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip=$(get_vm_ip)
|
||||
[ -n "$ip" ] || { err "No VM found"; exit 1; }
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip" "$@"
|
||||
}
|
||||
|
||||
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||
|
||||
# --- Create VM if needed ---
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
|
||||
if [ -z "$existing" ]; then
|
||||
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
|
||||
hcloud server create --name "$SERVER_NAME" --type "$SERVER_TYPE" --image "$IMAGE" --ssh-key "$SSH_KEY_NAME" --location fsn1 --quiet
|
||||
|
||||
log "Waiting for SSH..."
|
||||
ip=$(get_vm_ip)
|
||||
for i in $(seq 1 30); do
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip" "echo ok" &>/dev/null && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log "Installing deps..."
|
||||
ssh_cmd "apt-get update -qq && apt-get install -y -qq build-essential cmake pkg-config libasound2-dev libssl-dev curl git > /dev/null 2>&1"
|
||||
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1"
|
||||
fi
|
||||
|
||||
# --- Upload source ---
|
||||
log "Uploading source..."
|
||||
ip=$(get_vm_ip)
|
||||
rsync -az --delete \
|
||||
--exclude='target' --exclude='.git' --exclude='.claude' \
|
||||
--exclude='node_modules' --exclude='dist' --exclude='android/app/build' \
|
||||
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
|
||||
"$PROJECT_DIR/" "root@$ip:/root/wzp-build/"
|
||||
|
||||
# --- Build ---
|
||||
log "Building all binaries..."
|
||||
notify "WZP Linux build started..."
|
||||
|
||||
ssh_cmd "source ~/.cargo/env && cd /root/wzp-build && \
|
||||
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 2>&1 | tail -5 && \
|
||||
echo '--- audio client ---' && \
|
||||
cargo build --release --bin wzp-client --features audio 2>&1 | tail -3 && \
|
||||
cp target/release/wzp-client target/release/wzp-client-audio && \
|
||||
cargo build --release --bin wzp-client 2>&1 | tail -3 && \
|
||||
echo 'BUILD_DONE' && \
|
||||
ls -lh target/release/wzp-relay target/release/wzp-client target/release/wzp-client-audio target/release/wzp-web target/release/wzp-bench"
|
||||
|
||||
# --- Package + upload to rustypaste ---
|
||||
log "Packaging and uploading..."
|
||||
UPLOAD_URL=$(ssh_cmd "cd /root/wzp-build && \
|
||||
tar czf /tmp/wzp-linux-x86_64.tar.gz \
|
||||
-C target/release wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench \
|
||||
-C /root/wzp-build/crates/wzp-web/static index.html audio-processor.js 2>/dev/null && \
|
||||
curl -s -F 'file=@/tmp/wzp-linux-x86_64.tar.gz' \
|
||||
-H 'Authorization: DAxAAGghkn1WKv1+RpPKkg==' \
|
||||
https://paste.dk.manko.yoga")
|
||||
|
||||
if [ -n "$UPLOAD_URL" ]; then
|
||||
notify "WZP Linux binaries ready! $UPLOAD_URL"
|
||||
log "Uploaded: $UPLOAD_URL"
|
||||
else
|
||||
notify "WZP Linux build FAILED"
|
||||
err "Upload failed"
|
||||
fi
|
||||
|
||||
# --- Transfer locally ---
|
||||
log "Downloading binaries..."
|
||||
mkdir -p "$LOCAL_OUTPUT"
|
||||
for bin in wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench; do
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip:/root/wzp-build/target/release/$bin" "$LOCAL_OUTPUT/$bin" 2>/dev/null
|
||||
done
|
||||
ls -lh "$LOCAL_OUTPUT"/wzp-*
|
||||
|
||||
# --- Cleanup ---
|
||||
if [ "$KEEP_VM" = "1" ]; then
|
||||
log "VM kept alive. Destroy: hcloud server delete $SERVER_NAME"
|
||||
else
|
||||
log "Destroying VM..."
|
||||
hcloud server delete "$SERVER_NAME"
|
||||
fi
|
||||
|
||||
log "Done!"
|
||||
echo " Deploy: scp $LOCAL_OUTPUT/wzp-relay user@server:~/wzp/"
|
||||
Reference in New Issue
Block a user