Compare commits
9 Commits
9ab57ba037
...
debug/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073756ed4b | ||
|
|
2fcc2d77cf | ||
|
|
f7ccb67b02 | ||
|
|
4df08eadbd | ||
|
|
6d776097c8 | ||
|
|
9f7962a6cd | ||
|
|
8c9befb15d | ||
|
|
3f869a4cd7 | ||
|
|
2263e898e5 |
@@ -7,6 +7,8 @@ on:
|
|||||||
- 'feat/*'
|
- 'feat/*'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
paths-ignore:
|
||||||
|
- '.gitea/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
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",
|
"async-trait",
|
||||||
"axum 0.7.9",
|
"axum 0.7.9",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"dirs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"quinn",
|
"quinn",
|
||||||
|
|||||||
@@ -1,89 +1,122 @@
|
|||||||
//! Lock-free SPSC ring buffer for audio PCM transfer between
|
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
||||||
//! CPAL audio callbacks and the Rust engine.
|
|
||||||
//!
|
//!
|
||||||
//! Identical design to wzp-android's audio_ring: the producer writes and
|
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
||||||
//! advances a write cursor, the consumer reads and advances a read cursor.
|
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
||||||
//! Both cursors are atomic — no mutex, no blocking on the audio thread.
|
//!
|
||||||
|
//! On overflow (writer laps the reader), the writer simply overwrites
|
||||||
|
//! old buffer data. The reader detects the lap via `available() >
|
||||||
|
//! RING_CAPACITY` and snaps its own `read_pos` forward.
|
||||||
|
//!
|
||||||
|
//! Capacity is a power of 2 for bitmask indexing (no modulo).
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
|
|
||||||
/// Ring buffer capacity in i16 samples.
|
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
||||||
/// 960 samples * 10 frames = ~200ms of audio at 48kHz mono.
|
/// 16384 samples = 341.3ms at 48kHz mono.
|
||||||
const RING_CAPACITY: usize = 960 * 10;
|
const RING_CAPACITY: usize = 16384; // 2^14
|
||||||
|
const RING_MASK: usize = RING_CAPACITY - 1;
|
||||||
|
|
||||||
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
/// Lock-free single-producer single-consumer ring buffer for i16 PCM samples.
|
||||||
pub struct AudioRing {
|
pub struct AudioRing {
|
||||||
buf: Box<[i16; RING_CAPACITY]>,
|
buf: Box<[i16]>,
|
||||||
|
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||||
write_pos: AtomicUsize,
|
write_pos: AtomicUsize,
|
||||||
|
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||||
read_pos: AtomicUsize,
|
read_pos: AtomicUsize,
|
||||||
|
/// Incremented by reader when it detects it was lapped (overflow).
|
||||||
|
overflow_count: AtomicU64,
|
||||||
|
/// Incremented by reader when ring is empty (underrun).
|
||||||
|
underrun_count: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: AudioRing is designed for SPSC — one thread writes, one reads.
|
// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
|
||||||
// The atomics ensure visibility. The buffer itself is never accessed
|
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||||
// from the same index by both threads simultaneously because the
|
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||||
// producer only writes to positions between write_pos and read_pos,
|
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||||
// and the consumer only reads from positions between read_pos and write_pos.
|
|
||||||
unsafe impl Send for AudioRing {}
|
unsafe impl Send for AudioRing {}
|
||||||
unsafe impl Sync for AudioRing {}
|
unsafe impl Sync for AudioRing {}
|
||||||
|
|
||||||
impl AudioRing {
|
impl AudioRing {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||||
Self {
|
Self {
|
||||||
buf: Box::new([0i16; RING_CAPACITY]),
|
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||||
write_pos: AtomicUsize::new(0),
|
write_pos: AtomicUsize::new(0),
|
||||||
read_pos: AtomicUsize::new(0),
|
read_pos: AtomicUsize::new(0),
|
||||||
|
overflow_count: AtomicU64::new(0),
|
||||||
|
underrun_count: AtomicU64::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of samples available to read.
|
/// Number of samples available to read (clamped to capacity).
|
||||||
pub fn available(&self) -> usize {
|
pub fn available(&self) -> usize {
|
||||||
let w = self.write_pos.load(Ordering::Acquire);
|
let w = self.write_pos.load(Ordering::Acquire);
|
||||||
let r = self.read_pos.load(Ordering::Acquire);
|
let r = self.read_pos.load(Ordering::Relaxed);
|
||||||
w.wrapping_sub(r)
|
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write samples into the ring. Returns number of samples written.
|
/// Write samples into the ring. Returns number of samples written.
|
||||||
/// Drops oldest samples if the ring is full.
|
///
|
||||||
|
/// If the ring is full, old data is silently overwritten. The reader
|
||||||
|
/// will detect the lap and self-correct. The writer NEVER touches
|
||||||
|
/// `read_pos`.
|
||||||
pub fn write(&self, samples: &[i16]) -> usize {
|
pub fn write(&self, samples: &[i16]) -> usize {
|
||||||
let w = self.write_pos.load(Ordering::Relaxed);
|
|
||||||
let count = samples.len().min(RING_CAPACITY);
|
let count = samples.len().min(RING_CAPACITY);
|
||||||
|
let w = self.write_pos.load(Ordering::Relaxed);
|
||||||
|
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let idx = (w + i) % RING_CAPACITY;
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let ptr = self.buf.as_ptr() as *mut i16;
|
let ptr = self.buf.as_ptr() as *mut i16;
|
||||||
*ptr.add(idx) = samples[i];
|
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.write_pos
|
self.write_pos
|
||||||
.store(w.wrapping_add(count), Ordering::Release);
|
.store(w.wrapping_add(count), Ordering::Release);
|
||||||
|
|
||||||
// If we overwrote unread data, advance read_pos
|
|
||||||
if self.available() > RING_CAPACITY {
|
|
||||||
let new_read = self
|
|
||||||
.write_pos
|
|
||||||
.load(Ordering::Relaxed)
|
|
||||||
.wrapping_sub(RING_CAPACITY);
|
|
||||||
self.read_pos.store(new_read, Ordering::Release);
|
|
||||||
}
|
|
||||||
|
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read samples from the ring into `out`. Returns number of samples read.
|
/// Read samples from the ring into `out`. Returns number of samples read.
|
||||||
|
///
|
||||||
|
/// If the writer has lapped the reader (overflow), `read_pos` is snapped
|
||||||
|
/// forward to the oldest valid data.
|
||||||
pub fn read(&self, out: &mut [i16]) -> usize {
|
pub fn read(&self, out: &mut [i16]) -> usize {
|
||||||
let avail = self.available();
|
let w = self.write_pos.load(Ordering::Acquire);
|
||||||
let count = out.len().min(avail);
|
let mut r = self.read_pos.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
let mut avail = w.wrapping_sub(r);
|
||||||
|
|
||||||
|
// Lap detection: writer has overwritten our unread data.
|
||||||
|
if avail > RING_CAPACITY {
|
||||||
|
r = w.wrapping_sub(RING_CAPACITY);
|
||||||
|
avail = RING_CAPACITY;
|
||||||
|
self.overflow_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = out.len().min(avail);
|
||||||
|
if count == 0 {
|
||||||
|
if w == r {
|
||||||
|
self.underrun_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
let r = self.read_pos.load(Ordering::Relaxed);
|
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let idx = (r + i) % RING_CAPACITY;
|
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||||
out[i] = unsafe { *self.buf.as_ptr().add(idx) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.read_pos
|
self.read_pos
|
||||||
.store(r.wrapping_add(count), Ordering::Release);
|
.store(r.wrapping_add(count), Ordering::Release);
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of overflow events (reader was lapped by writer).
|
||||||
|
pub fn overflow_count(&self) -> u64 {
|
||||||
|
self.overflow_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of underrun events (reader found empty buffer).
|
||||||
|
pub fn underrun_count(&self) -> u64 {
|
||||||
|
self.underrun_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// Decode the next audio frame from the jitter buffer.
|
||||||
///
|
///
|
||||||
/// Returns PCM samples (48kHz mono) or None if not ready.
|
/// Returns PCM samples (48kHz mono) or None if not ready.
|
||||||
@@ -514,6 +557,9 @@ impl CallDecoder {
|
|||||||
return Some(pcm.len());
|
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;
|
self.last_was_cn = false;
|
||||||
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
||||||
Ok(n) => Some(n),
|
Ok(n) => Some(n),
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ use tracing::{error, info, warn};
|
|||||||
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
|
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
|
||||||
use wzp_proto::MediaTransport;
|
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.
|
/// Generate a sine wave tone.
|
||||||
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec<i16> {
|
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;
|
let start_sample = frame_offset * frame_samples as u64;
|
||||||
(0..FRAME_SAMPLES)
|
(0..frame_samples)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let t = (start_sample + i as u64) as f32 / sample_rate as f32;
|
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
|
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
|
||||||
@@ -57,6 +63,8 @@ struct CliArgs {
|
|||||||
os_aec: bool,
|
os_aec: bool,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
_metrics_file: 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
|
/// 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 {
|
fn parse_args() -> CliArgs {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let mut live = false;
|
let mut live = false;
|
||||||
@@ -136,6 +165,7 @@ fn parse_args() -> CliArgs {
|
|||||||
let mut os_aec = false;
|
let mut os_aec = false;
|
||||||
let mut token = None;
|
let mut token = None;
|
||||||
let mut metrics_file = None;
|
let mut metrics_file = None;
|
||||||
|
let mut profile_override = None;
|
||||||
let mut relay_str = None;
|
let mut relay_str = None;
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
@@ -237,6 +267,14 @@ fn parse_args() -> CliArgs {
|
|||||||
.expect("--drift-test value must be a number"),
|
.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,
|
"--sweep" => sweep = true,
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
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!(" --record <file.raw> Record received audio to raw PCM file");
|
||||||
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
||||||
eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
|
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!(" --sweep Run jitter buffer parameter sweep (local, no network)");
|
||||||
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
||||||
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
||||||
@@ -312,6 +352,7 @@ fn parse_args() -> CliArgs {
|
|||||||
os_aec,
|
os_aec,
|
||||||
token,
|
token,
|
||||||
_metrics_file: metrics_file,
|
_metrics_file: metrics_file,
|
||||||
|
profile_override,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,12 +373,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let seed = cli.resolve_seed();
|
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!(
|
info!(
|
||||||
relay = %cli.relay_addr,
|
relay = %cli.relay_addr,
|
||||||
live = cli.live,
|
live = cli.live,
|
||||||
send_tone = ?cli.send_tone_secs,
|
send_tone = ?cli.send_tone_secs,
|
||||||
record = ?cli.record_file,
|
record = ?cli.record_file,
|
||||||
room = ?cli.room,
|
room = ?cli.room,
|
||||||
|
profile = ?cli.profile_override,
|
||||||
"WarzonePhone client"
|
"WarzonePhone client"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -400,6 +448,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
direct_playout: cli.direct_playout,
|
direct_playout: cli.direct_playout,
|
||||||
aec_delay_ms: cli.aec_delay_ms,
|
aec_delay_ms: cli.aec_delay_ms,
|
||||||
os_aec: cli.os_aec,
|
os_aec: cli.os_aec,
|
||||||
|
profile_override: profile,
|
||||||
};
|
};
|
||||||
return run_live(transport, audio_opts).await;
|
return run_live(transport, audio_opts).await;
|
||||||
}
|
}
|
||||||
@@ -422,19 +471,23 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
transport.close().await?;
|
transport.close().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
|
} 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 {
|
} else {
|
||||||
run_silence(transport).await
|
run_silence(transport, profile).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send silence frames (connectivity test).
|
/// Send silence frames (connectivity test).
|
||||||
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
|
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>, 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 mut encoder = CallEncoder::new(&config);
|
let mut encoder = CallEncoder::new(&config);
|
||||||
|
|
||||||
let frame_duration = tokio::time::Duration::from_millis(20);
|
let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64);
|
||||||
let pcm = vec![0i16; FRAME_SAMPLES];
|
let pcm = vec![0i16; frame_samples];
|
||||||
|
|
||||||
let mut total_source = 0u64;
|
let mut total_source = 0u64;
|
||||||
let mut total_repair = 0u64;
|
let mut total_repair = 0u64;
|
||||||
@@ -480,13 +533,20 @@ async fn run_file_mode(
|
|||||||
send_tone_secs: Option<u32>,
|
send_tone_secs: Option<u32>,
|
||||||
send_file: Option<String>,
|
send_file: Option<String>,
|
||||||
record_file: Option<String>,
|
record_file: Option<String>,
|
||||||
|
profile: Option<wzp_proto::QualityProfile>,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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 ---
|
// --- Send task: generate tone or play file ---
|
||||||
let send_transport = transport.clone();
|
let send_transport = transport.clone();
|
||||||
let send_handle = tokio::spawn(async move {
|
let send_handle = tokio::spawn(async move {
|
||||||
// Load PCM frames from file or generate tone
|
// 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 {
|
let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file {
|
||||||
// Read raw PCM file (48kHz mono s16le)
|
// Read raw PCM file (48kHz mono s16le)
|
||||||
let bytes = match std::fs::read(path) {
|
let bytes = match std::fs::read(path) {
|
||||||
@@ -498,14 +558,14 @@ async fn run_file_mode(
|
|||||||
.collect();
|
.collect();
|
||||||
let duration = samples.len() as f64 / 48_000.0;
|
let duration = samples.len() as f64 / 48_000.0;
|
||||||
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
|
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
|
||||||
samples.chunks(FRAME_SAMPLES)
|
samples.chunks(frame_samples)
|
||||||
.filter(|c| c.len() == FRAME_SAMPLES)
|
.filter(|c| c.len() == frame_samples)
|
||||||
.map(|c| c.to_vec())
|
.map(|c| c.to_vec())
|
||||||
.collect()
|
.collect()
|
||||||
} else if let Some(secs) = send_tone_secs {
|
} else if let Some(secs) = send_tone_secs {
|
||||||
let total = (secs as u64) * 50;
|
let total = (secs as u64) * frames_per_sec;
|
||||||
info!(seconds = secs, frames = total, "sending 440Hz tone");
|
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)).collect()
|
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i, frame_samples)).collect()
|
||||||
} else {
|
} else {
|
||||||
// No sending, just wait
|
// No sending, just wait
|
||||||
tokio::signal::ctrl_c().await.ok();
|
tokio::signal::ctrl_c().await.ok();
|
||||||
@@ -514,7 +574,7 @@ async fn run_file_mode(
|
|||||||
|
|
||||||
let mut encoder = CallEncoder::new(&config);
|
let mut encoder = CallEncoder::new(&config);
|
||||||
let _total_frames = pcm_frames.len() as u64;
|
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_source = 0u64;
|
||||||
let mut total_repair = 0u64;
|
let mut total_repair = 0u64;
|
||||||
@@ -564,8 +624,13 @@ async fn run_file_mode(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut decoder = CallDecoder::new(&CallConfig::default());
|
let recv_config = match profile {
|
||||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
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 all_pcm: Vec<i16> = Vec::new();
|
||||||
let mut frames_received = 0u64;
|
let mut frames_received = 0u64;
|
||||||
|
|
||||||
@@ -704,6 +769,7 @@ struct AudioOpts {
|
|||||||
direct_playout: bool,
|
direct_playout: bool,
|
||||||
aec_delay_ms: Option<u32>,
|
aec_delay_ms: Option<u32>,
|
||||||
os_aec: bool,
|
os_aec: bool,
|
||||||
|
profile_override: Option<wzp_proto::QualityProfile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
@@ -788,12 +854,18 @@ async fn run_live(
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let base_config = match opts.profile_override {
|
||||||
|
Some(p) => CallConfig::from_profile(p),
|
||||||
|
None => CallConfig::default(),
|
||||||
|
};
|
||||||
let config = CallConfig {
|
let config = CallConfig {
|
||||||
noise_suppression: !opts.no_denoise,
|
noise_suppression: !opts.no_denoise,
|
||||||
suppression_enabled: !opts.no_silence,
|
suppression_enabled: !opts.no_silence,
|
||||||
aec_delay_ms: opts.aec_delay_ms.unwrap_or(40),
|
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();
|
let mut flags = Vec::new();
|
||||||
if opts.no_denoise { flags.push("denoise"); }
|
if opts.no_denoise { flags.push("denoise"); }
|
||||||
@@ -819,8 +891,8 @@ async fn run_live(
|
|||||||
let mut encoder = CallEncoder::new(&config);
|
let mut encoder = CallEncoder::new(&config);
|
||||||
if no_aec { encoder.set_aec_enabled(false); }
|
if no_aec { encoder.set_aec_enabled(false); }
|
||||||
if no_agc { encoder.set_agc_enabled(false); }
|
if no_agc { encoder.set_agc_enabled(false); }
|
||||||
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
let mut capture_buf = vec![0i16; frame_samples];
|
||||||
let mut farend_buf = vec![0i16; FRAME_SAMPLES];
|
let mut farend_buf = vec![0i16; frame_samples];
|
||||||
let mut frames_sent: u64 = 0;
|
let mut frames_sent: u64 = 0;
|
||||||
let mut frames_dropped: u64 = 0;
|
let mut frames_dropped: u64 = 0;
|
||||||
let mut send_errors: u64 = 0;
|
let mut send_errors: u64 = 0;
|
||||||
@@ -834,19 +906,19 @@ async fn run_live(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let avail = capture_ring.available();
|
let avail = capture_ring.available();
|
||||||
if avail < FRAME_SAMPLES {
|
if avail < frame_samples {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
polls += 1;
|
polls += 1;
|
||||||
// Diagnostic every 2 seconds
|
// Diagnostic every 2 seconds
|
||||||
if last_diag.elapsed().as_secs() >= 2 {
|
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();
|
last_diag = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let read = capture_ring.read(&mut capture_buf);
|
let read = capture_ring.read(&mut capture_buf);
|
||||||
if read < FRAME_SAMPLES {
|
if read < frame_samples {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,7 +930,7 @@ async fn run_live(
|
|||||||
// Feed AEC far-end reference: what was played through the speaker.
|
// Feed AEC far-end reference: what was played through the speaker.
|
||||||
// Must be called BEFORE encode_frame processes the mic signal.
|
// Must be called BEFORE encode_frame processes the mic signal.
|
||||||
if !no_aec {
|
if !no_aec {
|
||||||
while send_farend.available() >= FRAME_SAMPLES {
|
while send_farend.available() >= frame_samples {
|
||||||
send_farend.read(&mut farend_buf);
|
send_farend.read(&mut farend_buf);
|
||||||
encoder.feed_aec_farend(&farend_buf);
|
encoder.feed_aec_farend(&farend_buf);
|
||||||
}
|
}
|
||||||
@@ -903,6 +975,8 @@ async fn run_live(
|
|||||||
let recv_running = running.clone();
|
let recv_running = running.clone();
|
||||||
let recv_spk_muted = spk_muted.clone();
|
let recv_spk_muted = spk_muted.clone();
|
||||||
let direct_playout = opts.direct_playout;
|
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).
|
// Direct playout: decode on recv, write straight to playout ring (like Android).
|
||||||
// Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick.
|
// 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 packets_received: u64 = 0;
|
||||||
let mut recv_errors: u64 = 0;
|
let mut recv_errors: u64 = 0;
|
||||||
let mut timeouts: 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 {
|
let mut opus_dec = if direct_playout {
|
||||||
Some(wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD))
|
Some(wzp_codec::create_decoder(direct_profile))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let mut playout_agc = wzp_codec::AutoGainControl::new();
|
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 {
|
loop {
|
||||||
if !recv_running.load(Ordering::Relaxed) {
|
if !recv_running.load(Ordering::Relaxed) {
|
||||||
@@ -1019,10 +1094,15 @@ async fn run_live(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = CallConfig::default();
|
let playout_config = match playout_profile {
|
||||||
let mut decoder = CallDecoder::new(&config);
|
Some(p) => CallConfig::from_profile(p),
|
||||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
None => CallConfig::default(),
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
};
|
||||||
|
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);
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
let mut telemetry = JitterTelemetry::new(5);
|
let mut telemetry = JitterTelemetry::new(5);
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ prometheus = "0.13"
|
|||||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||||
tower-http = { version = "0.6", features = ["fs"] }
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
dirs = "6"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-relay"
|
name = "wzp-relay"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use wzp_proto::MediaTransport;
|
use wzp_proto::MediaTransport;
|
||||||
use wzp_relay::config::RelayConfig;
|
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));
|
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ephemeral relay identity for crypto handshake
|
// Load or generate relay identity — persisted in ~/.wzp/relay-identity
|
||||||
let relay_seed = wzp_crypto::Seed::generate();
|
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;
|
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
||||||
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
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));
|
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.
|
// Probe connections use SNI "_probe" to identify themselves.
|
||||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||||
if room_name == "_probe" {
|
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 client_cfg = wzp_transport::client_config();
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
match tokio::time::timeout(
|
let conn_result = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(3),
|
std::time::Duration::from_secs(3),
|
||||||
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
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)) => {
|
Ok(Ok(conn)) => {
|
||||||
let rtt_ms = start.elapsed().as_millis() as u32;
|
let rtt_ms = start.elapsed().as_millis() as u32;
|
||||||
|
|
||||||
// Extract server fingerprint from peer certificate
|
|
||||||
let server_fingerprint = conn
|
let server_fingerprint = conn
|
||||||
.peer_identity()
|
.peer_identity()
|
||||||
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
.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}")
|
format!("{h:016x}")
|
||||||
}))
|
}))
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
// Fallback: hash the remote address as identifier
|
|
||||||
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
|
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ async function doConnect() {
|
|||||||
saveSettingsObj(s);
|
saveSettingsObj(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ls === "offline") { connectError.textContent = "Relay is offline"; return; }
|
// Don't block connect on offline — ping may have failed transiently
|
||||||
|
|
||||||
connectError.textContent = "";
|
connectError.textContent = "";
|
||||||
connectBtn.disabled = true;
|
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