Compare commits
28 Commits
android-re
...
debug/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073756ed4b | ||
|
|
2fcc2d77cf | ||
|
|
f7ccb67b02 | ||
|
|
4df08eadbd | ||
|
|
6d776097c8 | ||
|
|
9f7962a6cd | ||
|
|
8c9befb15d | ||
|
|
3f869a4cd7 | ||
|
|
2263e898e5 | ||
|
|
9ab57ba037 | ||
|
|
7806d4ec04 | ||
|
|
d31b81a21d | ||
|
|
c268ce419a | ||
|
|
61b6e67610 | ||
|
|
dddf5d2e2d | ||
|
|
ed272d29f8 | ||
|
|
21f5b24cbf | ||
|
|
9b733010ab | ||
|
|
80d5bd7628 | ||
|
|
4a195a923a | ||
|
|
f726f8cfa4 | ||
|
|
e468454464 | ||
|
|
d1c96cd71f | ||
|
|
1b00b5e2a4 | ||
|
|
cfb48df1ef | ||
|
|
ba29d8354f | ||
|
|
0908507a7a | ||
|
|
860c90394d |
@@ -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
|
||||||
3284
Cargo.lock
generated
3284
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
22
Cargo.toml
@@ -10,6 +10,7 @@ members = [
|
|||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-android",
|
"crates/wzp-android",
|
||||||
|
"desktop/src-tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -53,3 +54,24 @@ wzp-fec = { path = "crates/wzp-fec" }
|
|||||||
wzp-crypto = { path = "crates/wzp-crypto" }
|
wzp-crypto = { path = "crates/wzp-crypto" }
|
||||||
wzp-transport = { path = "crates/wzp-transport" }
|
wzp-transport = { path = "crates/wzp-transport" }
|
||||||
wzp-client = { path = "crates/wzp-client" }
|
wzp-client = { path = "crates/wzp-client" }
|
||||||
|
|
||||||
|
# Fast dev profile: optimized but with debug info and incremental compilation.
|
||||||
|
# Use with: cargo run --profile dev-fast
|
||||||
|
[profile.dev-fast]
|
||||||
|
inherits = "dev"
|
||||||
|
opt-level = 2
|
||||||
|
|
||||||
|
# Optimize heavy compute deps even in debug builds —
|
||||||
|
# real-time audio needs < 20ms per frame, impossible unoptimized.
|
||||||
|
[profile.dev.package.nnnoiseless]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.audiopus_sys]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.audiopus]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.raptorq]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.wzp-codec]
|
||||||
|
opt-level = 3
|
||||||
|
[profile.dev.package.wzp-fec]
|
||||||
|
opt-level = 3
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ serde_json = "1"
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
cpal = { version = "0.15", optional = true }
|
cpal = { version = "0.15", optional = true }
|
||||||
|
coreaudio-rs = { version = "0.11", optional = true }
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
audio = ["cpal"]
|
audio = ["cpal"]
|
||||||
|
vpio = ["coreaudio-rs"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-client"
|
name = "wzp-client"
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
||||||
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
||||||
//!
|
//!
|
||||||
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS
|
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
||||||
//! thread that owns the stream. The public API exposes only `Send + Sync`
|
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
||||||
//! channel handles.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
@@ -16,6 +14,8 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::audio_ring::AudioRing;
|
||||||
|
|
||||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
@@ -23,22 +23,24 @@ pub const FRAME_SAMPLES: usize = 960;
|
|||||||
// AudioCapture
|
// AudioCapture
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Captures microphone input and yields 960-sample PCM frames.
|
/// Captures microphone input via CPAL and writes PCM into a lock-free ring buffer.
|
||||||
///
|
///
|
||||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioCapture {
|
pub struct AudioCapture {
|
||||||
rx: mpsc::Receiver<Vec<i16>>,
|
ring: Arc<AudioRing>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioCapture {
|
impl AudioCapture {
|
||||||
/// Create and start capturing from the default input device at 48 kHz mono.
|
/// Create and start capturing from the default input device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
let ring = Arc::new(AudioRing::new());
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let running_clone = running.clone();
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
|
let ring_cb = ring.clone();
|
||||||
|
let running_clone = running.clone();
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-capture".into())
|
.name("wzp-audio-capture".into())
|
||||||
@@ -59,53 +61,51 @@ impl AudioCapture {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_input(&device)?;
|
let use_f32 = !supports_i16_input(&device)?;
|
||||||
|
|
||||||
let buf = Arc::new(std::sync::Mutex::new(
|
|
||||||
Vec::<i16>::with_capacity(FRAME_SAMPLES),
|
|
||||||
));
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("input stream error: {e}");
|
warn!("input stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let logged_cb_size = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let buf = buf.clone();
|
let ring = ring_cb.clone();
|
||||||
let tx = tx.clone();
|
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
|
let logged = logged_cb_size.clone();
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut lock = buf.lock().unwrap();
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
for &s in data {
|
eprintln!("[audio] capture callback: {} f32 samples", data.len());
|
||||||
lock.push(f32_to_i16(s));
|
}
|
||||||
if lock.len() == FRAME_SAMPLES {
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
let frame = lock.drain(..).collect();
|
for chunk in data.chunks(FRAME_SAMPLES) {
|
||||||
let _ = tx.try_send(frame);
|
let n = chunk.len();
|
||||||
|
for i in 0..n {
|
||||||
|
tmp[i] = f32_to_i16(chunk[i]);
|
||||||
}
|
}
|
||||||
|
ring.write(&tmp[..n]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let buf = buf.clone();
|
let ring = ring_cb.clone();
|
||||||
let tx = tx.clone();
|
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
|
let logged = logged_cb_size.clone();
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut lock = buf.lock().unwrap();
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
for &s in data {
|
eprintln!("[audio] capture callback: {} i16 samples", data.len());
|
||||||
lock.push(s);
|
|
||||||
if lock.len() == FRAME_SAMPLES {
|
|
||||||
let frame = lock.drain(..).collect();
|
|
||||||
let _ = tx.try_send(frame);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ring.write(data);
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
@@ -114,7 +114,6 @@ impl AudioCapture {
|
|||||||
|
|
||||||
stream.play().context("failed to start input stream")?;
|
stream.play().context("failed to start input stream")?;
|
||||||
|
|
||||||
// Signal success to the caller before parking.
|
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -135,15 +134,12 @@ impl AudioCapture {
|
|||||||
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { rx, running })
|
Ok(Self { ring, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the next frame of 960 PCM samples (blocking until available).
|
/// Get a reference to the capture ring buffer for direct polling.
|
||||||
///
|
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||||
/// Returns `None` when the stream has been stopped or the channel is
|
&self.ring
|
||||||
/// disconnected.
|
|
||||||
pub fn read_frame(&self) -> Option<Vec<i16>> {
|
|
||||||
self.rx.recv().ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop capturing.
|
/// Stop capturing.
|
||||||
@@ -152,26 +148,34 @@ impl AudioCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioCapture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AudioPlayback
|
// AudioPlayback
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Plays PCM frames through the default output device at 48 kHz mono.
|
/// Plays PCM through the default output device, reading from a lock-free ring buffer.
|
||||||
///
|
///
|
||||||
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioPlayback {
|
pub struct AudioPlayback {
|
||||||
tx: mpsc::SyncSender<Vec<i16>>,
|
ring: Arc<AudioRing>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayback {
|
impl AudioPlayback {
|
||||||
/// Create and start playback on the default output device at 48 kHz mono.
|
/// Create and start playback on the default output device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
let ring = Arc::new(AudioRing::new());
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let running_clone = running.clone();
|
|
||||||
|
|
||||||
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
|
let ring_cb = ring.clone();
|
||||||
|
let running_clone = running.clone();
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-playback".into())
|
.name("wzp-audio-playback".into())
|
||||||
@@ -192,62 +196,40 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_output(&device)?;
|
let use_f32 = !supports_i16_output(&device)?;
|
||||||
|
|
||||||
// Shared ring of samples the cpal callback drains from.
|
|
||||||
let ring = Arc::new(std::sync::Mutex::new(
|
|
||||||
std::collections::VecDeque::<i16>::with_capacity(FRAME_SAMPLES * 8),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Background drainer: moves frames from the mpsc channel into the ring.
|
|
||||||
{
|
|
||||||
let ring = ring.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-playback-drain".into())
|
|
||||||
.spawn(move || {
|
|
||||||
while running.load(Ordering::Relaxed) {
|
|
||||||
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
|
||||||
Ok(frame) => {
|
|
||||||
let mut lock = ring.lock().unwrap();
|
|
||||||
lock.extend(frame);
|
|
||||||
while lock.len() > FRAME_SAMPLES * 16 {
|
|
||||||
lock.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("output stream error: {e}");
|
warn!("output stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let ring = ring.clone();
|
let ring = ring_cb.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
let mut lock = ring.lock().unwrap();
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
for sample in data.iter_mut() {
|
for chunk in data.chunks_mut(FRAME_SAMPLES) {
|
||||||
*sample = match lock.pop_front() {
|
let n = chunk.len();
|
||||||
Some(s) => i16_to_f32(s),
|
let read = ring.read(&mut tmp[..n]);
|
||||||
None => 0.0,
|
for i in 0..read {
|
||||||
};
|
chunk[i] = i16_to_f32(tmp[i]);
|
||||||
|
}
|
||||||
|
// Fill remainder with silence if ring underran
|
||||||
|
for i in read..n {
|
||||||
|
chunk[i] = 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let ring = ring.clone();
|
let ring = ring_cb.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||||
let mut lock = ring.lock().unwrap();
|
let read = ring.read(data);
|
||||||
for sample in data.iter_mut() {
|
// Fill remainder with silence if ring underran
|
||||||
*sample = lock.pop_front().unwrap_or(0);
|
for sample in &mut data[read..] {
|
||||||
|
*sample = 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
@@ -257,7 +239,6 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
stream.play().context("failed to start output stream")?;
|
stream.play().context("failed to start output stream")?;
|
||||||
|
|
||||||
// Signal success to the caller before parking.
|
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -278,12 +259,12 @@ impl AudioPlayback {
|
|||||||
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { tx, running })
|
Ok(Self { ring, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a frame of PCM samples for playback.
|
/// Get a reference to the playout ring buffer for direct writing.
|
||||||
pub fn write_frame(&self, pcm: &[i16]) {
|
pub fn ring(&self) -> &Arc<AudioRing> {
|
||||||
let _ = self.tx.try_send(pcm.to_vec());
|
&self.ring
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop playback.
|
/// Stop playback.
|
||||||
@@ -292,11 +273,16 @@ impl AudioPlayback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioPlayback {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Check if the input device supports i16 at 48 kHz mono.
|
|
||||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_input_configs()
|
.supported_input_configs()
|
||||||
@@ -313,7 +299,6 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the output device supports i16 at 48 kHz mono.
|
|
||||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_output_configs()
|
.supported_output_configs()
|
||||||
|
|||||||
122
crates/wzp-client/src/audio_ring.rs
Normal file
122
crates/wzp-client/src/audio_ring.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
|
||||||
|
//!
|
||||||
|
//! SPSC invariant: the producer ONLY writes `write_pos`, the consumer
|
||||||
|
//! ONLY writes `read_pos`. Neither thread touches the other's cursor.
|
||||||
|
//!
|
||||||
|
//! 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::{AtomicU64, AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// Ring buffer capacity — power of 2 for bitmask indexing.
|
||||||
|
/// 16384 samples = 341.3ms at 48kHz mono.
|
||||||
|
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.
|
||||||
|
pub struct AudioRing {
|
||||||
|
buf: Box<[i16]>,
|
||||||
|
/// Monotonically increasing write cursor. ONLY written by producer.
|
||||||
|
write_pos: AtomicUsize,
|
||||||
|
/// Monotonically increasing read cursor. ONLY written by consumer.
|
||||||
|
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 SPSC — one thread writes (producer), one reads (consumer).
|
||||||
|
// The producer only writes write_pos. The consumer only writes read_pos.
|
||||||
|
// Neither thread writes the other's cursor. Buffer indices are derived from
|
||||||
|
// the owning thread's cursor, ensuring no concurrent access to the same index.
|
||||||
|
unsafe impl Send for AudioRing {}
|
||||||
|
unsafe impl Sync for AudioRing {}
|
||||||
|
|
||||||
|
impl AudioRing {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
debug_assert!(RING_CAPACITY.is_power_of_two());
|
||||||
|
Self {
|
||||||
|
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
|
||||||
|
write_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 (clamped to capacity).
|
||||||
|
pub fn available(&self) -> usize {
|
||||||
|
let w = self.write_pos.load(Ordering::Acquire);
|
||||||
|
let r = self.read_pos.load(Ordering::Relaxed);
|
||||||
|
w.wrapping_sub(r).min(RING_CAPACITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write samples into the ring. Returns number of samples written.
|
||||||
|
///
|
||||||
|
/// 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 {
|
||||||
|
let count = samples.len().min(RING_CAPACITY);
|
||||||
|
let w = self.write_pos.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
for i in 0..count {
|
||||||
|
unsafe {
|
||||||
|
let ptr = self.buf.as_ptr() as *mut i16;
|
||||||
|
*ptr.add((w + i) & RING_MASK) = samples[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_pos
|
||||||
|
.store(w.wrapping_add(count), Ordering::Release);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let w = self.write_pos.load(Ordering::Acquire);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..count {
|
||||||
|
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
|
||||||
|
}
|
||||||
|
|
||||||
|
self.read_pos
|
||||||
|
.store(r.wrapping_add(count), Ordering::Release);
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
crates/wzp-client/src/audio_vpio.rs
Normal file
179
crates/wzp-client/src/audio_vpio.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! macOS Voice Processing I/O — uses Apple's VoiceProcessingIO audio unit
|
||||||
|
//! for hardware-accelerated echo cancellation, AGC, and noise suppression.
|
||||||
|
//!
|
||||||
|
//! VoiceProcessingIO is a combined input+output unit that knows what's going
|
||||||
|
//! to the speaker, so it can cancel the echo from the mic signal internally.
|
||||||
|
//! This is the same engine FaceTime and other Apple apps use.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
||||||
|
use coreaudio::audio_unit::render_callback::{self, data};
|
||||||
|
use coreaudio::audio_unit::{AudioUnit, Element, IOType, SampleFormat, Scope, StreamFormat};
|
||||||
|
use coreaudio::sys;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::audio_ring::AudioRing;
|
||||||
|
|
||||||
|
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||||
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
|
/// Combined capture + playback via macOS VoiceProcessingIO.
|
||||||
|
///
|
||||||
|
/// The OS handles AEC internally — no manual far-end feeding needed.
|
||||||
|
pub struct VpioAudio {
|
||||||
|
capture_ring: Arc<AudioRing>,
|
||||||
|
playout_ring: Arc<AudioRing>,
|
||||||
|
_audio_unit: AudioUnit,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VpioAudio {
|
||||||
|
/// Start VoiceProcessingIO with AEC enabled.
|
||||||
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
|
let capture_ring = Arc::new(AudioRing::new());
|
||||||
|
let playout_ring = Arc::new(AudioRing::new());
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let mut au = AudioUnit::new(IOType::VoiceProcessingIO)
|
||||||
|
.context("failed to create VoiceProcessingIO audio unit")?;
|
||||||
|
|
||||||
|
// Must uninitialize before configuring properties.
|
||||||
|
au.uninitialize()
|
||||||
|
.context("failed to uninitialize VPIO for configuration")?;
|
||||||
|
|
||||||
|
// Enable input (mic) on Element::Input (bus 1).
|
||||||
|
let enable: u32 = 1;
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioOutputUnitProperty_EnableIO,
|
||||||
|
Scope::Input,
|
||||||
|
Element::Input,
|
||||||
|
Some(&enable),
|
||||||
|
)
|
||||||
|
.context("failed to enable VPIO input")?;
|
||||||
|
|
||||||
|
// Output (speaker) is enabled by default on VPIO, but be explicit.
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioOutputUnitProperty_EnableIO,
|
||||||
|
Scope::Output,
|
||||||
|
Element::Output,
|
||||||
|
Some(&enable),
|
||||||
|
)
|
||||||
|
.context("failed to enable VPIO output")?;
|
||||||
|
|
||||||
|
// Configure stream format: 48kHz mono f32 non-interleaved
|
||||||
|
let stream_format = StreamFormat {
|
||||||
|
sample_rate: 48_000.0,
|
||||||
|
sample_format: SampleFormat::F32,
|
||||||
|
flags: LinearPcmFlags::IS_FLOAT
|
||||||
|
| LinearPcmFlags::IS_PACKED
|
||||||
|
| LinearPcmFlags::IS_NON_INTERLEAVED,
|
||||||
|
channels: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let asbd = stream_format.to_asbd();
|
||||||
|
|
||||||
|
// Input: set format on Output scope of Input element
|
||||||
|
// (= the format the AU delivers to us from the mic)
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioUnitProperty_StreamFormat,
|
||||||
|
Scope::Output,
|
||||||
|
Element::Input,
|
||||||
|
Some(&asbd),
|
||||||
|
)
|
||||||
|
.context("failed to set input stream format")?;
|
||||||
|
|
||||||
|
// Output: set format on Input scope of Output element
|
||||||
|
// (= the format we feed to the AU for the speaker)
|
||||||
|
au.set_property(
|
||||||
|
sys::kAudioUnitProperty_StreamFormat,
|
||||||
|
Scope::Input,
|
||||||
|
Element::Output,
|
||||||
|
Some(&asbd),
|
||||||
|
)
|
||||||
|
.context("failed to set output stream format")?;
|
||||||
|
|
||||||
|
// Set up input callback (mic capture with AEC applied)
|
||||||
|
let cap_ring = capture_ring.clone();
|
||||||
|
let cap_running = running.clone();
|
||||||
|
let logged = Arc::new(AtomicBool::new(false));
|
||||||
|
au.set_input_callback(
|
||||||
|
move |args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||||
|
if !cap_running.load(Ordering::Relaxed) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut buffers = args.data.channels();
|
||||||
|
if let Some(ch) = buffers.next() {
|
||||||
|
if !logged.swap(true, Ordering::Relaxed) {
|
||||||
|
eprintln!("[vpio] capture callback: {} f32 samples", ch.len());
|
||||||
|
}
|
||||||
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
|
for chunk in ch.chunks(FRAME_SAMPLES) {
|
||||||
|
let n = chunk.len();
|
||||||
|
for i in 0..n {
|
||||||
|
tmp[i] = (chunk[i].clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
|
}
|
||||||
|
cap_ring.write(&tmp[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("failed to set input callback")?;
|
||||||
|
|
||||||
|
// Set up output callback (speaker playback — AEC uses this as reference)
|
||||||
|
let play_ring = playout_ring.clone();
|
||||||
|
au.set_render_callback(
|
||||||
|
move |mut args: render_callback::Args<data::NonInterleaved<f32>>| {
|
||||||
|
let mut buffers = args.data.channels_mut();
|
||||||
|
if let Some(ch) = buffers.next() {
|
||||||
|
let mut tmp = [0i16; FRAME_SAMPLES];
|
||||||
|
for chunk in ch.chunks_mut(FRAME_SAMPLES) {
|
||||||
|
let n = chunk.len();
|
||||||
|
let read = play_ring.read(&mut tmp[..n]);
|
||||||
|
for i in 0..read {
|
||||||
|
chunk[i] = tmp[i] as f32 / i16::MAX as f32;
|
||||||
|
}
|
||||||
|
for i in read..n {
|
||||||
|
chunk[i] = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("failed to set render callback")?;
|
||||||
|
|
||||||
|
au.initialize().context("failed to initialize VoiceProcessingIO")?;
|
||||||
|
au.start().context("failed to start VoiceProcessingIO")?;
|
||||||
|
|
||||||
|
info!("VoiceProcessingIO started (OS-level AEC enabled)");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
capture_ring,
|
||||||
|
playout_ring,
|
||||||
|
_audio_unit: au,
|
||||||
|
running,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture_ring(&self) -> &Arc<AudioRing> {
|
||||||
|
&self.capture_ring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn playout_ring(&self) -> &Arc<AudioRing> {
|
||||||
|
&self.playout_ring
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for VpioAudio {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ pub struct CallConfig {
|
|||||||
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
||||||
/// intermediate frames use a compact 4-byte MiniHeader.
|
/// intermediate frames use a compact 4-byte MiniHeader.
|
||||||
pub mini_frames_enabled: bool,
|
pub mini_frames_enabled: bool,
|
||||||
|
/// AEC far-end delay compensation in milliseconds (default: 40).
|
||||||
|
/// Compensates for the round-trip audio latency from playout to mic capture.
|
||||||
|
pub aec_delay_ms: u32,
|
||||||
/// Enable adaptive jitter buffer (default: true).
|
/// Enable adaptive jitter buffer (default: true).
|
||||||
///
|
///
|
||||||
/// When true, the jitter buffer target depth is automatically adjusted
|
/// When true, the jitter buffer target depth is automatically adjusted
|
||||||
@@ -63,6 +66,7 @@ impl Default for CallConfig {
|
|||||||
noise_suppression: true,
|
noise_suppression: true,
|
||||||
mini_frames_enabled: true,
|
mini_frames_enabled: true,
|
||||||
adaptive_jitter: true,
|
adaptive_jitter: true,
|
||||||
|
aec_delay_ms: 40,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +245,7 @@ impl CallEncoder {
|
|||||||
block_id: 0,
|
block_id: 0,
|
||||||
frame_in_block: 0,
|
frame_in_block: 0,
|
||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
aec: EchoCanceller::with_delay(48000, 60, config.aec_delay_ms),
|
||||||
agc: AutoGainControl::new(),
|
agc: AutoGainControl::new(),
|
||||||
silence_detector: SilenceDetector::new(
|
silence_detector: SilenceDetector::new(
|
||||||
config.silence_threshold_rms,
|
config.silence_threshold_rms,
|
||||||
@@ -496,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.
|
||||||
@@ -510,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),
|
||||||
|
|||||||
@@ -14,17 +14,23 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tracing::{error, info};
|
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
|
||||||
@@ -45,12 +51,32 @@ struct CliArgs {
|
|||||||
seed_hex: Option<String>,
|
seed_hex: Option<String>,
|
||||||
mnemonic: Option<String>,
|
mnemonic: Option<String>,
|
||||||
room: Option<String>,
|
room: Option<String>,
|
||||||
|
raw_room: bool,
|
||||||
|
alias: Option<String>,
|
||||||
|
no_denoise: bool,
|
||||||
|
no_aec: bool,
|
||||||
|
no_agc: bool,
|
||||||
|
no_fec: bool,
|
||||||
|
no_silence: bool,
|
||||||
|
direct_playout: bool,
|
||||||
|
aec_delay_ms: Option<u32>,
|
||||||
|
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
|
||||||
|
fn default_identity_path() -> std::path::PathBuf {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||||
|
std::path::PathBuf::from(home).join(".wzp").join("identity")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliArgs {
|
impl CliArgs {
|
||||||
/// Resolve the identity seed from --seed, --mnemonic, or generate a new one.
|
/// Resolve the identity seed from --seed, --mnemonic, or persistent file.
|
||||||
|
///
|
||||||
|
/// Priority: --seed > --mnemonic > ~/.wzp/identity > generate + save.
|
||||||
pub fn resolve_seed(&self) -> wzp_crypto::Seed {
|
pub fn resolve_seed(&self) -> wzp_crypto::Seed {
|
||||||
if let Some(ref hex_str) = self.seed_hex {
|
if let Some(ref hex_str) = self.seed_hex {
|
||||||
let seed = wzp_crypto::Seed::from_hex(hex_str).expect("invalid --seed hex");
|
let seed = wzp_crypto::Seed::from_hex(hex_str).expect("invalid --seed hex");
|
||||||
@@ -65,15 +91,56 @@ impl CliArgs {
|
|||||||
info!(fingerprint = %fp, "identity from --mnemonic");
|
info!(fingerprint = %fp, "identity from --mnemonic");
|
||||||
seed
|
seed
|
||||||
} else {
|
} else {
|
||||||
|
let path = default_identity_path();
|
||||||
|
// Try loading existing identity
|
||||||
|
if path.exists() {
|
||||||
|
if let Ok(hex_str) = std::fs::read_to_string(&path) {
|
||||||
|
let hex_str = hex_str.trim();
|
||||||
|
if let Ok(seed) = wzp_crypto::Seed::from_hex(hex_str) {
|
||||||
|
let id = seed.derive_identity();
|
||||||
|
let fp = id.public_identity().fingerprint;
|
||||||
|
info!(fingerprint = %fp, path = %path.display(), "loaded persistent identity");
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Generate new and save
|
||||||
let seed = wzp_crypto::Seed::generate();
|
let seed = wzp_crypto::Seed::generate();
|
||||||
let id = seed.derive_identity();
|
let id = seed.derive_identity();
|
||||||
let fp = id.public_identity().fingerprint;
|
let fp = id.public_identity().fingerprint;
|
||||||
info!(fingerprint = %fp, "generated ephemeral identity");
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
// Encode seed as hex manually (avoid dep on `hex` crate in binary)
|
||||||
|
let hex_str: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
std::fs::write(&path, hex_str).ok();
|
||||||
|
info!(fingerprint = %fp, path = %path.display(), "generated and saved new identity");
|
||||||
seed
|
seed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
@@ -86,8 +153,19 @@ fn parse_args() -> CliArgs {
|
|||||||
let mut seed_hex = None;
|
let mut seed_hex = None;
|
||||||
let mut mnemonic = None;
|
let mut mnemonic = None;
|
||||||
let mut room = None;
|
let mut room = None;
|
||||||
|
let mut raw_room = false;
|
||||||
|
let mut alias = None;
|
||||||
|
let mut no_denoise = false;
|
||||||
|
let mut no_aec = false;
|
||||||
|
let mut no_agc = false;
|
||||||
|
let mut no_fec = false;
|
||||||
|
let mut no_silence = false;
|
||||||
|
let mut direct_playout = false;
|
||||||
|
let mut aec_delay_ms = None;
|
||||||
|
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;
|
||||||
@@ -130,6 +208,27 @@ fn parse_args() -> CliArgs {
|
|||||||
i += 1;
|
i += 1;
|
||||||
room = Some(args.get(i).expect("--room requires a name").to_string());
|
room = Some(args.get(i).expect("--room requires a name").to_string());
|
||||||
}
|
}
|
||||||
|
"--raw-room" => raw_room = true,
|
||||||
|
"--no-denoise" => no_denoise = true,
|
||||||
|
"--no-aec" => no_aec = true,
|
||||||
|
"--no-agc" => no_agc = true,
|
||||||
|
"--no-fec" => no_fec = true,
|
||||||
|
"--no-silence" => no_silence = true,
|
||||||
|
"--direct-playout" | "--android" => direct_playout = true,
|
||||||
|
"--os-aec" => os_aec = true,
|
||||||
|
"--aec-delay" => {
|
||||||
|
i += 1;
|
||||||
|
aec_delay_ms = Some(
|
||||||
|
args.get(i)
|
||||||
|
.expect("--aec-delay requires milliseconds")
|
||||||
|
.parse()
|
||||||
|
.expect("--aec-delay value must be a number"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--alias" => {
|
||||||
|
i += 1;
|
||||||
|
alias = Some(args.get(i).expect("--alias requires a name").to_string());
|
||||||
|
}
|
||||||
"--token" => {
|
"--token" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
token = Some(args.get(i).expect("--token requires a value").to_string());
|
token = Some(args.get(i).expect("--token requires a value").to_string());
|
||||||
@@ -168,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]");
|
||||||
@@ -179,14 +286,28 @@ 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)");
|
||||||
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
|
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
|
||||||
|
eprintln!(" --raw-room Send room name as-is (no hash, for Android compat)");
|
||||||
|
eprintln!(" --alias <name> Display name shown to other participants");
|
||||||
|
eprintln!(" --no-denoise Disable RNNoise noise suppression");
|
||||||
|
eprintln!(" --no-aec Disable acoustic echo cancellation");
|
||||||
|
eprintln!(" --no-agc Disable automatic gain control");
|
||||||
|
eprintln!(" --no-fec Disable forward error correction");
|
||||||
|
eprintln!(" --no-silence Disable silence suppression");
|
||||||
|
eprintln!(" --direct-playout Bypass jitter buffer (decode on recv, like Android)");
|
||||||
|
eprintln!(" --aec-delay <ms> AEC far-end delay compensation (default: 40ms)");
|
||||||
|
eprintln!(" --os-aec Use macOS VoiceProcessingIO for hardware AEC (requires --vpio feature)");
|
||||||
|
eprintln!(" --android Alias for --no-denoise --no-silence --direct-playout");
|
||||||
eprintln!(" --token <token> featherChat bearer token for relay auth");
|
eprintln!(" --token <token> featherChat bearer token for relay auth");
|
||||||
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
|
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
|
||||||
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
eprintln!("Identity is auto-saved to ~/.wzp/identity on first run.");
|
||||||
eprintln!("Default relay: 127.0.0.1:4433");
|
eprintln!("Default relay: 127.0.0.1:4433");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
@@ -219,8 +340,19 @@ fn parse_args() -> CliArgs {
|
|||||||
seed_hex,
|
seed_hex,
|
||||||
mnemonic,
|
mnemonic,
|
||||||
room,
|
room,
|
||||||
|
raw_room,
|
||||||
|
alias,
|
||||||
|
no_denoise,
|
||||||
|
no_aec,
|
||||||
|
no_agc,
|
||||||
|
no_fec,
|
||||||
|
no_silence,
|
||||||
|
direct_playout,
|
||||||
|
aec_delay_ms,
|
||||||
|
os_aec,
|
||||||
token,
|
token,
|
||||||
_metrics_file: metrics_file,
|
_metrics_file: metrics_file,
|
||||||
|
profile_override,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,17 +373,30 @@ 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"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hash room name for SNI privacy (or "default" if none specified)
|
// Compute SNI from room name.
|
||||||
|
// --raw-room sends the name as-is (for Android compat — Android doesn't hash).
|
||||||
|
// Default behaviour hashes for privacy.
|
||||||
let sni = match &cli.room {
|
let sni = match &cli.room {
|
||||||
|
Some(name) if cli.raw_room => {
|
||||||
|
info!(room = %name, "using raw room name as SNI (no hash)");
|
||||||
|
name.clone()
|
||||||
|
}
|
||||||
Some(name) => {
|
Some(name) => {
|
||||||
let hashed = wzp_crypto::hash_room_name(name);
|
let hashed = wzp_crypto::hash_room_name(name);
|
||||||
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
||||||
@@ -287,14 +432,25 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let _crypto_session = wzp_client::handshake::perform_handshake(
|
let _crypto_session = wzp_client::handshake::perform_handshake(
|
||||||
&*transport,
|
&*transport,
|
||||||
&seed.0,
|
&seed.0,
|
||||||
None, // alias — desktop client doesn't set one yet
|
cli.alias.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
info!("crypto handshake complete");
|
info!("crypto handshake complete");
|
||||||
|
|
||||||
if cli.live {
|
if cli.live {
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
{
|
{
|
||||||
return run_live(transport).await;
|
let audio_opts = AudioOpts {
|
||||||
|
no_denoise: cli.no_denoise || cli.direct_playout,
|
||||||
|
no_aec: cli.no_aec,
|
||||||
|
no_agc: cli.no_agc,
|
||||||
|
no_fec: cli.no_fec,
|
||||||
|
no_silence: cli.no_silence || cli.direct_playout,
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "audio"))]
|
#[cfg(not(feature = "audio"))]
|
||||||
{
|
{
|
||||||
@@ -315,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;
|
||||||
@@ -343,8 +503,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
|
|||||||
}
|
}
|
||||||
total_bytes += pkt.payload.len() as u64;
|
total_bytes += pkt.payload.len() as u64;
|
||||||
if let Err(e) = transport.send_media(pkt).await {
|
if let Err(e) = transport.send_media(pkt).await {
|
||||||
error!("send error: {e}");
|
warn!("send_media error (dropping packet): {e}");
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (i + 1) % 50 == 0 {
|
if (i + 1) % 50 == 0 {
|
||||||
@@ -374,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) {
|
||||||
@@ -392,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();
|
||||||
@@ -408,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;
|
||||||
@@ -429,8 +595,7 @@ async fn run_file_mode(
|
|||||||
total_source += 1;
|
total_source += 1;
|
||||||
}
|
}
|
||||||
if let Err(e) = send_transport.send_media(pkt).await {
|
if let Err(e) = send_transport.send_media(pkt).await {
|
||||||
error!("send error: {e}");
|
warn!("send_media error (dropping packet): {e}");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (frame_idx + 1) % 250 == 0 {
|
if (frame_idx + 1) % 250 == 0 {
|
||||||
@@ -459,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;
|
||||||
|
|
||||||
@@ -549,78 +719,534 @@ async fn run_file_mode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Live mode: capture from mic, encode, send; receive, decode, play.
|
/// Live mode: capture from mic, encode, send; receive, decode, play.
|
||||||
|
///
|
||||||
|
/// Architecture (mirrors wzp-android/engine.rs):
|
||||||
|
/// CPAL capture callback → AudioRing → send task (5ms poll) → QUIC
|
||||||
|
/// QUIC → recv task → jitter buffer → decode tick (20ms) → AudioRing → CPAL playback callback
|
||||||
|
///
|
||||||
|
/// All lock-free: CPAL callbacks use atomic ring buffers, no Mutex on the audio path.
|
||||||
|
/// RAII guard for terminal raw mode. Restores on drop.
|
||||||
|
struct RawModeGuard {
|
||||||
|
orig: libc::termios,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawModeGuard {
|
||||||
|
fn enter() -> Option<Self> {
|
||||||
|
unsafe {
|
||||||
|
let mut orig: libc::termios = std::mem::zeroed();
|
||||||
|
if libc::tcgetattr(libc::STDIN_FILENO, &mut orig) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut raw = orig;
|
||||||
|
// ICANON: character-at-a-time input
|
||||||
|
// ECHO: don't echo typed characters
|
||||||
|
// ISIG: let us handle Ctrl+C as a byte
|
||||||
|
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
|
||||||
|
// IXON: disable Ctrl+S/Ctrl+Q flow control so we receive them
|
||||||
|
raw.c_iflag &= !libc::IXON;
|
||||||
|
raw.c_cc[libc::VMIN] = 1;
|
||||||
|
raw.c_cc[libc::VTIME] = 0;
|
||||||
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw);
|
||||||
|
Some(Self { orig })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RawModeGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe {
|
||||||
|
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.orig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AudioOpts {
|
||||||
|
no_denoise: bool,
|
||||||
|
no_aec: bool,
|
||||||
|
no_agc: bool,
|
||||||
|
no_fec: bool,
|
||||||
|
no_silence: bool,
|
||||||
|
direct_playout: bool,
|
||||||
|
aec_delay_ms: Option<u32>,
|
||||||
|
os_aec: bool,
|
||||||
|
profile_override: Option<wzp_proto::QualityProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
|
async fn run_live(
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
opts: AudioOpts,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
use std::sync::Arc as StdArc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
|
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
|
||||||
|
use wzp_client::audio_ring::AudioRing;
|
||||||
|
use wzp_client::call::JitterTelemetry;
|
||||||
|
|
||||||
let capture = AudioCapture::start()?;
|
// Audio I/O: either VPIO (OS-level AEC) or separate CPAL streams.
|
||||||
let playback = AudioPlayback::start()?;
|
#[cfg(all(target_os = "macos", feature = "vpio"))]
|
||||||
info!("Audio I/O started — press Ctrl+C to stop");
|
let vpio;
|
||||||
|
let (capture_ring, playout_ring) = if opts.os_aec {
|
||||||
|
#[cfg(all(target_os = "macos", feature = "vpio"))]
|
||||||
|
{
|
||||||
|
vpio = wzp_client::audio_vpio::VpioAudio::start()?;
|
||||||
|
(vpio.capture_ring().clone(), vpio.playout_ring().clone())
|
||||||
|
}
|
||||||
|
#[cfg(all(target_os = "macos", not(feature = "vpio")))]
|
||||||
|
{
|
||||||
|
anyhow::bail!("--os-aec requires the 'vpio' feature (build with: cargo build --features audio,vpio)");
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
warn!("--os-aec on Windows is experimental and not yet tested");
|
||||||
|
warn!("Windows Voice Capture DSP (MFT) AEC is not yet implemented");
|
||||||
|
warn!("falling back to CPAL without AEC — please report issues");
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
std::mem::forget(capture);
|
||||||
|
std::mem::forget(playback);
|
||||||
|
(cr, pr)
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
warn!("--os-aec on Linux is experimental and not yet tested");
|
||||||
|
warn!("PipeWire/PulseAudio echo-cancel module AEC is not yet implemented");
|
||||||
|
warn!("falling back to CPAL without AEC — please report issues");
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
std::mem::forget(capture);
|
||||||
|
std::mem::forget(playback);
|
||||||
|
(cr, pr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
// Keep handles alive (streams stop when dropped)
|
||||||
|
std::mem::forget(capture);
|
||||||
|
std::mem::forget(playback);
|
||||||
|
(cr, pr)
|
||||||
|
};
|
||||||
|
info!(os_aec = opts.os_aec, "audio I/O started — press Ctrl+C to stop");
|
||||||
|
|
||||||
|
// Far-end reference ring (only used when NOT using OS AEC).
|
||||||
|
let farend_ring = StdArc::new(AudioRing::new());
|
||||||
|
|
||||||
|
let running = StdArc::new(AtomicBool::new(true));
|
||||||
|
let mic_muted = StdArc::new(AtomicBool::new(false));
|
||||||
|
let spk_muted = StdArc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
// --- Signal handler: set running=false on first Ctrl+C, force-quit on second ---
|
||||||
|
let signal_running = running.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.ok();
|
||||||
|
eprintln!(); // newline after ^C
|
||||||
|
info!("Ctrl+C received, shutting down...");
|
||||||
|
signal_running.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
tokio::signal::ctrl_c().await.ok();
|
||||||
|
eprintln!("\nForce quit");
|
||||||
|
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),
|
||||||
|
..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"); }
|
||||||
|
if opts.no_aec { flags.push("aec"); }
|
||||||
|
if opts.no_agc { flags.push("agc"); }
|
||||||
|
if opts.no_fec { flags.push("fec"); }
|
||||||
|
if opts.no_silence { flags.push("silence"); }
|
||||||
|
if opts.direct_playout { flags.push("jitter-buffer (direct playout)"); }
|
||||||
|
if !flags.is_empty() {
|
||||||
|
info!(disabled = %flags.join(", "), "audio processing overrides");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Send task: poll capture ring → encode → send via async ---
|
||||||
let send_transport = transport.clone();
|
let send_transport = transport.clone();
|
||||||
let rt_handle = tokio::runtime::Handle::current();
|
let send_running = running.clone();
|
||||||
let send_handle = std::thread::Builder::new()
|
let send_mic_muted = mic_muted.clone();
|
||||||
.name("wzp-send-loop".into())
|
let no_aec = opts.no_aec || opts.os_aec; // OS AEC replaces software AEC
|
||||||
.spawn(move || {
|
let no_agc = opts.no_agc;
|
||||||
let config = CallConfig::default();
|
let _no_fec = opts.no_fec;
|
||||||
let mut encoder = CallEncoder::new(&config);
|
let send_farend = farend_ring.clone();
|
||||||
loop {
|
let send_task = async move {
|
||||||
let frame = match capture.read_frame() {
|
let mut encoder = CallEncoder::new(&config);
|
||||||
Some(f) => f,
|
if no_aec { encoder.set_aec_enabled(false); }
|
||||||
None => break,
|
if no_agc { encoder.set_agc_enabled(false); }
|
||||||
};
|
let mut capture_buf = vec![0i16; frame_samples];
|
||||||
let packets = match encoder.encode_frame(&frame) {
|
let mut farend_buf = vec![0i16; frame_samples];
|
||||||
Ok(p) => p,
|
let mut frames_sent: u64 = 0;
|
||||||
Err(e) => {
|
let mut frames_dropped: u64 = 0;
|
||||||
error!("encode error: {e}");
|
let mut send_errors: u64 = 0;
|
||||||
continue;
|
let mut last_send_err = std::time::Instant::now();
|
||||||
}
|
let mut polls: u64 = 0;
|
||||||
};
|
let mut last_diag = std::time::Instant::now();
|
||||||
for pkt in &packets {
|
|
||||||
if let Err(e) = rt_handle.block_on(send_transport.send_media(pkt)) {
|
loop {
|
||||||
error!("send error: {e}");
|
if !send_running.load(Ordering::Relaxed) {
|
||||||
return;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let avail = capture_ring.available();
|
||||||
|
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, frame_samples, "send: ring starved");
|
||||||
|
last_diag = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let read = capture_ring.read(&mut capture_buf);
|
||||||
|
if read < frame_samples {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mic mute: zero out capture buffer (still encode + send silence to keep stream alive)
|
||||||
|
if send_mic_muted.load(Ordering::Relaxed) {
|
||||||
|
capture_buf.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
send_farend.read(&mut farend_buf);
|
||||||
|
encoder.feed_aec_farend(&farend_buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let packets = match encoder.encode_frame(&capture_buf) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
error!("encode error: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let encode_us = t0.elapsed().as_micros();
|
||||||
|
|
||||||
|
let mut dropped = false;
|
||||||
|
for pkt in &packets {
|
||||||
|
if let Err(e) = send_transport.send_media(pkt).await {
|
||||||
|
send_errors += 1;
|
||||||
|
frames_dropped += 1;
|
||||||
|
dropped = true;
|
||||||
|
if send_errors <= 3 || last_send_err.elapsed().as_secs() >= 1 {
|
||||||
|
warn!(send_errors, frames_dropped,
|
||||||
|
"send_media error (dropping packet): {e}");
|
||||||
|
last_send_err = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})?;
|
|
||||||
|
|
||||||
|
if !dropped {
|
||||||
|
send_errors = 0; // reset on success
|
||||||
|
}
|
||||||
|
frames_sent += 1;
|
||||||
|
if frames_sent <= 5 || frames_sent % 500 == 0 {
|
||||||
|
info!(frames_sent, encode_us, pkts = packets.len(), "send progress");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Recv + playout ---
|
||||||
let recv_transport = transport.clone();
|
let recv_transport = transport.clone();
|
||||||
let recv_handle = tokio::spawn(async move {
|
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.
|
||||||
|
let recv_task = {
|
||||||
|
let playout_ring = playout_ring.clone();
|
||||||
|
let farend_ring = farend_ring.clone();
|
||||||
let config = CallConfig::default();
|
let config = CallConfig::default();
|
||||||
let mut decoder = CallDecoder::new(&config);
|
let decoder = StdArc::new(tokio::sync::Mutex::new(CallDecoder::new(&config)));
|
||||||
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
|
let decoder_recv = decoder.clone();
|
||||||
loop {
|
|
||||||
match recv_transport.recv_media().await {
|
async move {
|
||||||
Ok(Some(pkt)) => {
|
let mut packets_received: u64 = 0;
|
||||||
let is_repair = pkt.header.is_repair;
|
let mut recv_errors: u64 = 0;
|
||||||
decoder.ingest(pkt);
|
let mut timeouts: u64 = 0;
|
||||||
// Only decode for source packets (1 source = 1 audio frame).
|
// For direct playout: raw codec decoder + AGC
|
||||||
// Repair packets feed the FEC decoder but don't produce audio.
|
let direct_profile = recv_profile.unwrap_or(wzp_proto::QualityProfile::GOOD);
|
||||||
if !is_repair {
|
let mut opus_dec = if direct_playout {
|
||||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
Some(wzp_codec::create_decoder(direct_profile))
|
||||||
playback.write_frame(&pcm_buf);
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut playout_agc = wzp_codec::AutoGainControl::new();
|
||||||
|
let mut pcm_buf = vec![0i16; frame_samples.max(FRAME_SAMPLES_40MS)];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !recv_running.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
recv_transport.recv_media(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Some(pkt))) => {
|
||||||
|
packets_received += 1;
|
||||||
|
|
||||||
|
if direct_playout {
|
||||||
|
// Android path: decode immediately, AGC, write to ring
|
||||||
|
if !pkt.header.is_repair {
|
||||||
|
if let Some(ref mut dec) = opus_dec {
|
||||||
|
match dec.decode(&pkt.payload, &mut pcm_buf) {
|
||||||
|
Ok(n) => {
|
||||||
|
if !no_agc {
|
||||||
|
playout_agc.process_frame(&mut pcm_buf[..n]);
|
||||||
|
}
|
||||||
|
// Always feed AEC (even when speaker muted)
|
||||||
|
farend_ring.write(&pcm_buf[..n]);
|
||||||
|
// Speaker mute: don't write to playout ring
|
||||||
|
if !recv_spk_muted.load(Ordering::Relaxed) {
|
||||||
|
playout_ring.write(&pcm_buf[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Ok(n) = dec.decode_lost(&mut pcm_buf) {
|
||||||
|
if !recv_spk_muted.load(Ordering::Relaxed) {
|
||||||
|
playout_ring.write(&pcm_buf[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packets_received < 10 {
|
||||||
|
warn!("decode error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Jitter buffer path
|
||||||
|
let mut dec = decoder_recv.lock().await;
|
||||||
|
dec.ingest(pkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if packets_received == 1 || packets_received % 500 == 0 {
|
||||||
|
info!(packets_received, direct_playout, "recv progress");
|
||||||
|
}
|
||||||
|
timeouts = 0;
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => {
|
||||||
|
info!("connection closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("closed") || msg.contains("reset") {
|
||||||
|
error!("recv fatal: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
recv_errors += 1;
|
||||||
|
if recv_errors <= 3 {
|
||||||
|
warn!("recv error (continuing): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
timeouts += 1;
|
||||||
|
if timeouts == 50 {
|
||||||
|
info!("recv: no media packets received in 5s");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
}
|
||||||
info!("connection closed");
|
}
|
||||||
break;
|
};
|
||||||
}
|
|
||||||
Err(e) => {
|
// Playout tick — only used when NOT in direct playout mode
|
||||||
error!("recv error: {e}");
|
let playout_running = running.clone();
|
||||||
|
let playout_task = async move {
|
||||||
|
if direct_playout {
|
||||||
|
// Direct playout handles everything in recv_task — just park here
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
if !playout_running.load(Ordering::Relaxed) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
tokio::signal::ctrl_c().await?;
|
let playout_config = match playout_profile {
|
||||||
info!("Shutting down...");
|
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 {
|
||||||
|
interval.tick().await;
|
||||||
|
if !playout_running.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
recv_handle.abort();
|
let mut decoded_this_tick = 0;
|
||||||
drop(send_handle);
|
while let Some(n) = decoder.decode_next(&mut pcm_buf) {
|
||||||
transport.close().await?;
|
playout_ring.write(&pcm_buf[..n]);
|
||||||
info!("done");
|
decoded_this_tick += 1;
|
||||||
|
if decoded_this_tick >= 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry.maybe_log(decoder.stats());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Signal task: listen for RoomUpdate and display presence ---
|
||||||
|
let signal_transport = transport.clone();
|
||||||
|
let signal_running = running.clone();
|
||||||
|
let signal_task = async move {
|
||||||
|
loop {
|
||||||
|
if !signal_running.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
signal_transport.recv_signal(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate { participants, .. }))) => {
|
||||||
|
// Dedup by (fingerprint, alias) — same peer may appear multiple times
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let unique: Vec<_> = participants
|
||||||
|
.iter()
|
||||||
|
.filter(|p| seen.insert((&p.fingerprint, &p.alias)))
|
||||||
|
.collect();
|
||||||
|
info!(count = unique.len(), "room update");
|
||||||
|
for p in &unique {
|
||||||
|
let name = p
|
||||||
|
.alias
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("(no alias)");
|
||||||
|
let fp = if p.fingerprint.is_empty() {
|
||||||
|
"(no fingerprint)"
|
||||||
|
} else {
|
||||||
|
&p.fingerprint
|
||||||
|
};
|
||||||
|
info!(" participant: {name} [{fp}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Ok(Some(msg))) => {
|
||||||
|
info!("signal: {:?}", std::mem::discriminant(&msg));
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => {
|
||||||
|
info!("signal stream closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
error!("signal recv error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => {} // timeout — loop and check running flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Keyboard task: Ctrl+M = toggle mic mute, Ctrl+S = toggle speaker mute ---
|
||||||
|
let kb_running = running.clone();
|
||||||
|
let kb_mic = mic_muted.clone();
|
||||||
|
let kb_spk = spk_muted.clone();
|
||||||
|
let keyboard_task = async move {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
// Put terminal in raw mode so we get individual keypresses
|
||||||
|
let _raw_guard = RawModeGuard::enter();
|
||||||
|
|
||||||
|
let mut stdin = tokio::io::stdin();
|
||||||
|
let mut buf = [0u8; 1];
|
||||||
|
loop {
|
||||||
|
if !kb_running.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
stdin.read(&mut buf),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(1)) => match buf[0] {
|
||||||
|
b'm' | b'M' | 0x0D => {
|
||||||
|
// 'm' or Ctrl+M
|
||||||
|
let was = kb_mic.fetch_xor(true, Ordering::SeqCst);
|
||||||
|
let state = if !was { "MUTED" } else { "unmuted" };
|
||||||
|
eprintln!("\r[mic {state}]");
|
||||||
|
}
|
||||||
|
b's' | b'S' | 0x13 => {
|
||||||
|
// 's' or Ctrl+S
|
||||||
|
let was = kb_spk.fetch_xor(true, Ordering::SeqCst);
|
||||||
|
let state = if !was { "MUTED" } else { "unmuted" };
|
||||||
|
eprintln!("\r[speaker {state}]");
|
||||||
|
}
|
||||||
|
0x03 => {
|
||||||
|
// Ctrl+C
|
||||||
|
eprintln!();
|
||||||
|
info!("Ctrl+C received, shutting down...");
|
||||||
|
kb_running.store(false, Ordering::SeqCst);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
b'q' | b'Q' => {
|
||||||
|
eprintln!("\r[quit]");
|
||||||
|
kb_running.store(false, Ordering::SeqCst);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) => break,
|
||||||
|
Err(_) => {} // timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Run all tasks, exit when any finishes (or running flag cleared by Ctrl+C) ---
|
||||||
|
tokio::select! {
|
||||||
|
_ = send_task => info!("send task ended"),
|
||||||
|
_ = recv_task => info!("recv task ended"),
|
||||||
|
_ = playout_task => info!("playout task ended"),
|
||||||
|
_ = signal_task => info!("signal task ended"),
|
||||||
|
_ = keyboard_task => info!("keyboard task ended"),
|
||||||
|
}
|
||||||
|
|
||||||
|
running.store(false, Ordering::SeqCst);
|
||||||
|
// Audio streams stop when their handles are dropped (via mem::forget above or VPIO drop).
|
||||||
|
|
||||||
|
// Give transport 2s to close gracefully, then bail
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(2), transport.close()).await {
|
||||||
|
Ok(Ok(())) => info!("done"),
|
||||||
|
Ok(Err(e)) => info!("close error (non-fatal): {e}"),
|
||||||
|
Err(_) => info!("close timed out, exiting anyway"),
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
|
SignalMessage::SetAlias { .. } => CallSignalType::Offer, // reuse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
pub mod audio_io;
|
pub mod audio_io;
|
||||||
|
#[cfg(feature = "audio")]
|
||||||
|
pub mod audio_ring;
|
||||||
|
#[cfg(feature = "vpio")]
|
||||||
|
pub mod audio_vpio;
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
pub mod call;
|
pub mod call;
|
||||||
pub mod drift_test;
|
pub mod drift_test;
|
||||||
|
|||||||
@@ -1,53 +1,127 @@
|
|||||||
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
//! Acoustic Echo Cancellation — delay-compensated leaky NLMS with
|
||||||
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
//! Geigel double-talk detection.
|
||||||
|
//!
|
||||||
|
//! Key insight: on a laptop, the round-trip audio latency (playout → speaker
|
||||||
|
//! → air → mic → capture) is 30–50ms. The far-end reference must be delayed
|
||||||
|
//! by this amount so the adaptive filter models the *echo path*, not the
|
||||||
|
//! *system delay + echo path*.
|
||||||
|
//!
|
||||||
|
//! The leaky coefficient decay prevents the filter from diverging when the
|
||||||
|
//! echo path changes (e.g. hand near laptop) or when the delay estimate
|
||||||
|
//! is slightly off.
|
||||||
|
|
||||||
/// NLMS (Normalized Least Mean Squares) adaptive filter echo canceller.
|
/// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
|
||||||
///
|
|
||||||
/// Removes acoustic echo by modelling the echo path between the far-end
|
|
||||||
/// (speaker) signal and the near-end (microphone) signal, then subtracting
|
|
||||||
/// the estimated echo from the near-end in real time.
|
|
||||||
pub struct EchoCanceller {
|
pub struct EchoCanceller {
|
||||||
filter_coeffs: Vec<f32>,
|
// --- Adaptive filter ---
|
||||||
|
filter: Vec<f32>,
|
||||||
filter_len: usize,
|
filter_len: usize,
|
||||||
far_end_buf: Vec<f32>,
|
/// Circular buffer of far-end reference samples (after delay).
|
||||||
far_end_pos: usize,
|
far_buf: Vec<f32>,
|
||||||
|
far_pos: usize,
|
||||||
|
/// NLMS step size.
|
||||||
mu: f32,
|
mu: f32,
|
||||||
|
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
|
||||||
|
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
|
||||||
|
leak: f32,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
|
||||||
|
// --- Delay buffer ---
|
||||||
|
/// Raw far-end samples before delay compensation.
|
||||||
|
delay_ring: Vec<f32>,
|
||||||
|
delay_write: usize,
|
||||||
|
delay_read: usize,
|
||||||
|
/// Delay in samples (e.g. 1920 = 40ms at 48kHz).
|
||||||
|
delay_samples: usize,
|
||||||
|
/// Capacity of the delay ring.
|
||||||
|
delay_cap: usize,
|
||||||
|
|
||||||
|
// --- Double-talk detection (Geigel) ---
|
||||||
|
/// Peak far-end level over the last filter_len samples.
|
||||||
|
far_peak: f32,
|
||||||
|
/// Geigel threshold: if |near| > threshold * far_peak, assume double-talk.
|
||||||
|
geigel_threshold: f32,
|
||||||
|
/// Holdover counter: keep DTD active for a few frames after detection.
|
||||||
|
dtd_holdover: u32,
|
||||||
|
dtd_hold_frames: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EchoCanceller {
|
impl EchoCanceller {
|
||||||
/// Create a new echo canceller.
|
/// Create a new echo canceller.
|
||||||
///
|
///
|
||||||
/// * `sample_rate` — typically 48000
|
/// * `sample_rate` — typically 48000
|
||||||
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
|
/// * `filter_ms` — echo-tail length in milliseconds (60ms recommended)
|
||||||
|
/// * `delay_ms` — far-end delay compensation in milliseconds (40ms for laptops)
|
||||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
||||||
|
Self::with_delay(sample_rate, filter_ms, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_delay(sample_rate: u32, filter_ms: u32, delay_ms: u32) -> Self {
|
||||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
||||||
|
let delay_samples = (sample_rate as usize) * (delay_ms as usize) / 1000;
|
||||||
|
// Delay ring must hold at least delay_samples + one frame (960) of headroom.
|
||||||
|
let delay_cap = delay_samples + (sample_rate as usize / 10); // +100ms headroom
|
||||||
Self {
|
Self {
|
||||||
filter_coeffs: vec![0.0f32; filter_len],
|
filter: vec![0.0; filter_len],
|
||||||
filter_len,
|
filter_len,
|
||||||
far_end_buf: vec![0.0f32; filter_len],
|
far_buf: vec![0.0; filter_len],
|
||||||
far_end_pos: 0,
|
far_pos: 0,
|
||||||
mu: 0.01,
|
mu: 0.01,
|
||||||
|
leak: 0.0001,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
||||||
|
delay_ring: vec![0.0; delay_cap],
|
||||||
|
delay_write: 0,
|
||||||
|
delay_read: 0,
|
||||||
|
delay_samples,
|
||||||
|
delay_cap,
|
||||||
|
|
||||||
|
far_peak: 0.0,
|
||||||
|
geigel_threshold: 0.7,
|
||||||
|
dtd_holdover: 0,
|
||||||
|
dtd_hold_frames: 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed far-end (speaker/playback) samples into the circular buffer.
|
/// Feed far-end (speaker) samples. These go into the delay buffer first;
|
||||||
///
|
/// once enough samples have accumulated, they are released to the filter's
|
||||||
/// Must be called with the audio that was played out through the speaker
|
/// circular buffer with the correct delay offset.
|
||||||
/// *before* the corresponding near-end frame is processed.
|
|
||||||
pub fn feed_farend(&mut self, farend: &[i16]) {
|
pub fn feed_farend(&mut self, farend: &[i16]) {
|
||||||
|
// Write raw samples into the delay ring.
|
||||||
for &s in farend {
|
for &s in farend {
|
||||||
self.far_end_buf[self.far_end_pos] = s as f32;
|
self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
|
||||||
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
self.delay_write += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release delayed samples to the filter's far-end buffer.
|
||||||
|
while self.delay_available() >= 1 {
|
||||||
|
let sample = self.delay_ring[self.delay_read % self.delay_cap];
|
||||||
|
self.delay_read += 1;
|
||||||
|
|
||||||
|
self.far_buf[self.far_pos] = sample;
|
||||||
|
self.far_pos = (self.far_pos + 1) % self.filter_len;
|
||||||
|
|
||||||
|
// Track peak far-end level for Geigel DTD.
|
||||||
|
let abs_s = sample.abs();
|
||||||
|
if abs_s > self.far_peak {
|
||||||
|
self.far_peak = abs_s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decay far_peak slowly (avoids stale peak from a loud burst long ago).
|
||||||
|
self.far_peak *= 0.9995;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of delayed samples available to release.
|
||||||
|
fn delay_available(&self) -> usize {
|
||||||
|
let buffered = self.delay_write - self.delay_read;
|
||||||
|
if buffered > self.delay_samples {
|
||||||
|
buffered - self.delay_samples
|
||||||
|
} else {
|
||||||
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
/// Process a near-end (microphone) frame, removing the estimated echo.
|
||||||
///
|
|
||||||
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
|
|
||||||
/// the original near-end divided by the RMS of the residual. Values > 1.0
|
|
||||||
/// mean echo was reduced.
|
|
||||||
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
@@ -56,85 +130,96 @@ impl EchoCanceller {
|
|||||||
let n = nearend.len();
|
let n = nearend.len();
|
||||||
let fl = self.filter_len;
|
let fl = self.filter_len;
|
||||||
|
|
||||||
|
// --- Geigel double-talk detection ---
|
||||||
|
// If any near-end sample exceeds threshold * far_peak, assume
|
||||||
|
// the local speaker is active and freeze adaptation.
|
||||||
|
let mut is_doubletalk = self.dtd_holdover > 0;
|
||||||
|
if !is_doubletalk {
|
||||||
|
let threshold_level = self.geigel_threshold * self.far_peak;
|
||||||
|
for &s in nearend.iter() {
|
||||||
|
if (s as f32).abs() > threshold_level && self.far_peak > 100.0 {
|
||||||
|
is_doubletalk = true;
|
||||||
|
self.dtd_holdover = self.dtd_hold_frames;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.dtd_holdover > 0 {
|
||||||
|
self.dtd_holdover -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if far-end is active (otherwise nothing to cancel).
|
||||||
|
let far_active = self.far_peak > 100.0;
|
||||||
|
|
||||||
|
// --- Leaky coefficient decay ---
|
||||||
|
// Applied once per frame for efficiency.
|
||||||
|
let decay = 1.0 - self.leak;
|
||||||
|
for c in self.filter.iter_mut() {
|
||||||
|
*c *= decay;
|
||||||
|
}
|
||||||
|
|
||||||
let mut sum_near_sq: f64 = 0.0;
|
let mut sum_near_sq: f64 = 0.0;
|
||||||
let mut sum_err_sq: f64 = 0.0;
|
let mut sum_err_sq: f64 = 0.0;
|
||||||
|
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let near_f = nearend[i] as f32;
|
let near_f = nearend[i] as f32;
|
||||||
|
|
||||||
// --- estimate echo as dot(coeffs, farend_window) ---
|
// Position of far-end "now" for this near-end sample.
|
||||||
// The far-end window for this sample starts at
|
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||||
// (far_end_pos - 1 - i) mod filter_len (most recent)
|
|
||||||
// and goes back filter_len samples.
|
// --- Echo estimation: dot(filter, far_end_window) ---
|
||||||
let mut echo_est: f32 = 0.0;
|
let mut echo_est: f32 = 0.0;
|
||||||
let mut power: f32 = 0.0;
|
let mut power: f32 = 0.0;
|
||||||
|
|
||||||
// Position of the most-recent far-end sample for this near-end sample.
|
|
||||||
// far_end_pos points to the *next write* position, so the most-recent
|
|
||||||
// sample written is at far_end_pos - 1. We have already called
|
|
||||||
// feed_farend for this block, so the relevant samples are the last
|
|
||||||
// filter_len entries ending just before the current write position,
|
|
||||||
// offset by how far we are into this near-end frame.
|
|
||||||
//
|
|
||||||
// For sample i of the near-end frame, the corresponding far-end
|
|
||||||
// "now" is far_end_pos - n + i (wrapping).
|
|
||||||
// far_end_pos points to next-write, so most recent sample is at
|
|
||||||
// far_end_pos - 1. For the i-th near-end sample we want the
|
|
||||||
// far-end "now" to be at (far_end_pos - n + i). We add fl
|
|
||||||
// repeatedly to avoid underflow on the usize subtraction.
|
|
||||||
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
|
||||||
|
|
||||||
for k in 0..fl {
|
for k in 0..fl {
|
||||||
let fe_idx = (base + fl - k) % fl;
|
let fe_idx = (base + fl - k) % fl;
|
||||||
let fe = self.far_end_buf[fe_idx];
|
let fe = self.far_buf[fe_idx];
|
||||||
echo_est += self.filter_coeffs[k] * fe;
|
echo_est += self.filter[k] * fe;
|
||||||
power += fe * fe;
|
power += fe * fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
let error = near_f - echo_est;
|
let error = near_f - echo_est;
|
||||||
|
|
||||||
// --- NLMS coefficient update ---
|
// --- NLMS adaptation (only when far-end active & no double-talk) ---
|
||||||
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
|
if far_active && !is_doubletalk && power > 10.0 {
|
||||||
let step = self.mu * error / norm;
|
let step = self.mu * error / (power + 1.0);
|
||||||
|
for k in 0..fl {
|
||||||
for k in 0..fl {
|
let fe_idx = (base + fl - k) % fl;
|
||||||
let fe_idx = (base + fl - k) % fl;
|
self.filter[k] += step * self.far_buf[fe_idx];
|
||||||
let fe = self.far_end_buf[fe_idx];
|
}
|
||||||
self.filter_coeffs[k] += step * fe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp output
|
let out = error.clamp(-32768.0, 32767.0);
|
||||||
let out = error.max(-32768.0).min(32767.0);
|
|
||||||
nearend[i] = out as i16;
|
nearend[i] = out as i16;
|
||||||
|
|
||||||
sum_near_sq += (near_f as f64) * (near_f as f64);
|
sum_near_sq += (near_f as f64).powi(2);
|
||||||
sum_err_sq += (out as f64) * (out as f64);
|
sum_err_sq += (out as f64).powi(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ERLE ratio
|
|
||||||
if sum_err_sq < 1.0 {
|
if sum_err_sq < 1.0 {
|
||||||
return 100.0; // near-perfect cancellation
|
100.0
|
||||||
|
} else {
|
||||||
|
(sum_near_sq / sum_err_sq).sqrt() as f32
|
||||||
}
|
}
|
||||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable echo cancellation.
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
self.enabled = enabled;
|
self.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether echo cancellation is currently enabled.
|
|
||||||
pub fn is_enabled(&self) -> bool {
|
pub fn is_enabled(&self) -> bool {
|
||||||
self.enabled
|
self.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the adaptive filter to its initial state.
|
|
||||||
///
|
|
||||||
/// Zeroes out all filter coefficients and the far-end circular buffer.
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
|
self.filter.iter_mut().for_each(|c| *c = 0.0);
|
||||||
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
|
self.far_buf.iter_mut().for_each(|s| *s = 0.0);
|
||||||
self.far_end_pos = 0;
|
self.far_pos = 0;
|
||||||
|
self.far_peak = 0.0;
|
||||||
|
self.delay_ring.iter_mut().for_each(|s| *s = 0.0);
|
||||||
|
self.delay_write = 0;
|
||||||
|
self.delay_read = 0;
|
||||||
|
self.dtd_holdover = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,50 +228,40 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_creates_with_correct_filter_len() {
|
fn creates_with_correct_sizes() {
|
||||||
let aec = EchoCanceller::new(48000, 100);
|
let aec = EchoCanceller::with_delay(48000, 60, 40);
|
||||||
assert_eq!(aec.filter_len, 4800);
|
assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
|
||||||
assert_eq!(aec.filter_coeffs.len(), 4800);
|
assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
|
||||||
assert_eq!(aec.far_end_buf.len(), 4800);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_passthrough_when_disabled() {
|
fn passthrough_when_disabled() {
|
||||||
let mut aec = EchoCanceller::new(48000, 100);
|
let mut aec = EchoCanceller::new(48000, 60);
|
||||||
aec.set_enabled(false);
|
aec.set_enabled(false);
|
||||||
assert!(!aec.is_enabled());
|
|
||||||
|
|
||||||
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
|
let original: Vec<i16> = (0..960).map(|i| (i * 10) as i16).collect();
|
||||||
let mut frame = original.clone();
|
let mut frame = original.clone();
|
||||||
let erle = aec.process_frame(&mut frame);
|
aec.process_frame(&mut frame);
|
||||||
assert_eq!(erle, 1.0);
|
|
||||||
assert_eq!(frame, original);
|
assert_eq!(frame, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_reset_zeroes_state() {
|
fn silence_passthrough() {
|
||||||
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
|
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||||
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
|
aec.feed_farend(&vec![0i16; 960]);
|
||||||
aec.feed_farend(&farend);
|
let mut frame = vec![0i16; 960];
|
||||||
|
aec.process_frame(&mut frame);
|
||||||
aec.reset();
|
assert!(frame.iter().all(|&s| s == 0));
|
||||||
|
|
||||||
assert!(aec.filter_coeffs.iter().all(|&c| c == 0.0));
|
|
||||||
assert!(aec.far_end_buf.iter().all(|&s| s == 0.0));
|
|
||||||
assert_eq!(aec.far_end_pos, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_reduces_echo_of_known_signal() {
|
fn reduces_echo_with_no_delay() {
|
||||||
// Use a small filter for speed. Feed a known far-end signal, then
|
// Simulate: far-end plays, echo arrives at mic attenuated by ~50%
|
||||||
// present the *same* signal as near-end (perfect echo, no room).
|
// (realistic — speaker to mic on laptop loses volume).
|
||||||
// After adaptation the output energy should drop.
|
let mut aec = EchoCanceller::with_delay(48000, 10, 0);
|
||||||
let filter_ms = 5; // 240 taps at 48 kHz
|
|
||||||
let mut aec = EchoCanceller::new(48000, filter_ms);
|
|
||||||
|
|
||||||
// Generate a simple repeating pattern.
|
let frame_len = 480;
|
||||||
let frame_len = 480usize;
|
let make_tone = |offset: usize| -> Vec<i16> {
|
||||||
let make_frame = |offset: usize| -> Vec<i16> {
|
|
||||||
(0..frame_len)
|
(0..frame_len)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let t = (offset + i) as f64 / 48000.0;
|
let t = (offset + i) as f64 / 48000.0;
|
||||||
@@ -195,18 +270,16 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Warm up the adaptive filter with several frames.
|
|
||||||
let mut last_erle = 1.0f32;
|
let mut last_erle = 1.0f32;
|
||||||
for frame_idx in 0..40 {
|
for frame_idx in 0..100 {
|
||||||
let farend = make_frame(frame_idx * frame_len);
|
let farend = make_tone(frame_idx * frame_len);
|
||||||
aec.feed_farend(&farend);
|
aec.feed_farend(&farend);
|
||||||
|
|
||||||
// Near-end = exact copy of far-end (pure echo).
|
// Near-end = attenuated copy of far-end (echo at ~50% volume).
|
||||||
let mut nearend = farend.clone();
|
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
|
||||||
last_erle = aec.process_frame(&mut nearend);
|
last_erle = aec.process_frame(&mut nearend);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After 40 frames the ERLE should be meaningfully > 1.
|
|
||||||
assert!(
|
assert!(
|
||||||
last_erle > 1.0,
|
last_erle > 1.0,
|
||||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
||||||
@@ -214,15 +287,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_silence_passthrough() {
|
fn preserves_nearend_during_doubletalk() {
|
||||||
let mut aec = EchoCanceller::new(48000, 10);
|
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
||||||
// Feed silence far-end
|
|
||||||
aec.feed_farend(&vec![0i16; 480]);
|
let frame_len = 960;
|
||||||
// Near-end is silence too
|
let nearend: Vec<i16> = (0..frame_len)
|
||||||
let mut frame = vec![0i16; 480];
|
.map(|i| {
|
||||||
let erle = aec.process_frame(&mut frame);
|
let t = i as f64 / 48000.0;
|
||||||
assert!(erle >= 1.0);
|
(10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
||||||
// Output should still be silence
|
})
|
||||||
assert!(frame.iter().all(|&s| s == 0));
|
.collect();
|
||||||
|
|
||||||
|
// Feed silence as far-end (no echo source).
|
||||||
|
aec.feed_farend(&vec![0i16; frame_len]);
|
||||||
|
|
||||||
|
let mut frame = nearend.clone();
|
||||||
|
aec.process_frame(&mut frame);
|
||||||
|
|
||||||
|
let input_energy: f64 = nearend.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||||
|
let output_energy: f64 = frame.iter().map(|&s| (s as f64).powi(2)).sum();
|
||||||
|
let ratio = output_energy / input_energy;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ratio > 0.8,
|
||||||
|
"near-end speech should be preserved, energy ratio = {ratio:.3}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delay_buffer_holds_samples() {
|
||||||
|
let mut aec = EchoCanceller::with_delay(48000, 10, 20);
|
||||||
|
// 20ms delay = 960 samples @ 48kHz.
|
||||||
|
// After feeding, feed_farend auto-drains available samples to far_buf.
|
||||||
|
// So delay_available() is always 0 after feed_farend returns.
|
||||||
|
// Instead, verify far_pos advances only after the delay is filled.
|
||||||
|
|
||||||
|
// Feed 960 samples (= delay amount). No samples released yet.
|
||||||
|
aec.feed_farend(&vec![1i16; 960]);
|
||||||
|
// far_buf should still be all zeros (nothing released).
|
||||||
|
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet");
|
||||||
|
|
||||||
|
// Feed 480 more. 480 should be released to far_buf.
|
||||||
|
aec.feed_farend(&vec![2i16; 480]);
|
||||||
|
let non_zero = aec.far_buf.iter().filter(|&&s| s != 0.0).count();
|
||||||
|
assert!(non_zero > 0, "samples should have been released to far_buf");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -656,6 +656,11 @@ pub enum SignalMessage {
|
|||||||
/// List of participants currently in the room.
|
/// List of participants currently in the room.
|
||||||
participants: Vec<RoomParticipant>,
|
participants: Vec<RoomParticipant>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Set or update the client's display name.
|
||||||
|
/// Sent by client after joining; relay updates the participant entry and
|
||||||
|
/// re-broadcasts a RoomUpdate to all participants.
|
||||||
|
SetAlias { alias: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A participant entry in a RoomUpdate message.
|
/// A participant entry in a RoomUpdate message.
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -141,6 +141,17 @@ impl Room {
|
|||||||
self.participants.iter().map(|p| p.sender.clone()).collect()
|
self.participants.iter().map(|p| p.sender.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update a participant's alias. Returns true if the participant was found.
|
||||||
|
fn set_alias(&mut self, id: ParticipantId, alias: String) -> bool {
|
||||||
|
if let Some(p) = self.participants.iter_mut().find(|p| p.id == id) {
|
||||||
|
info!(participant = id, %alias, "alias updated");
|
||||||
|
p.alias = Some(alias);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_empty(&self) -> bool {
|
fn is_empty(&self) -> bool {
|
||||||
self.participants.is_empty()
|
self.participants.is_empty()
|
||||||
}
|
}
|
||||||
@@ -255,6 +266,26 @@ impl RoomManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update a participant's alias and return a RoomUpdate + senders for broadcasting.
|
||||||
|
pub fn set_alias(
|
||||||
|
&mut self,
|
||||||
|
room_name: &str,
|
||||||
|
participant_id: ParticipantId,
|
||||||
|
alias: String,
|
||||||
|
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||||
|
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||||
|
if room.set_alias(participant_id, alias) {
|
||||||
|
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||||
|
count: room.len() as u32,
|
||||||
|
participants: room.participant_list(),
|
||||||
|
};
|
||||||
|
let senders = room.all_senders();
|
||||||
|
return Some((update, senders));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Get senders for all OTHER participants in a room.
|
/// Get senders for all OTHER participants in a room.
|
||||||
pub fn others(
|
pub fn others(
|
||||||
&self,
|
&self,
|
||||||
@@ -374,141 +405,166 @@ async fn run_participant_plain(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
) {
|
) {
|
||||||
let addr = transport.connection().remote_address();
|
let addr = transport.connection().remote_address();
|
||||||
let mut packets_forwarded = 0u64;
|
|
||||||
let mut last_recv_instant = std::time::Instant::now();
|
|
||||||
let mut max_recv_gap_ms = 0u64;
|
|
||||||
let mut max_forward_ms = 0u64;
|
|
||||||
let mut send_errors = 0u64;
|
|
||||||
let mut last_log_instant = std::time::Instant::now();
|
|
||||||
|
|
||||||
info!(
|
// Media forwarding task (with debug logging from Android fixes)
|
||||||
room = %room_name,
|
let media_room_mgr = room_mgr.clone();
|
||||||
participant = participant_id,
|
let media_room_name = room_name.clone();
|
||||||
%addr,
|
let media_transport = transport.clone();
|
||||||
session = session_id,
|
let media_metrics = metrics.clone();
|
||||||
"forwarding loop started (plain)"
|
let media_session_id = session_id.to_string();
|
||||||
);
|
let media_task = async move {
|
||||||
|
let mut packets_forwarded = 0u64;
|
||||||
|
let mut last_recv_instant = std::time::Instant::now();
|
||||||
|
let mut max_recv_gap_ms = 0u64;
|
||||||
|
let mut max_forward_ms = 0u64;
|
||||||
|
let mut send_errors = 0u64;
|
||||||
|
let mut last_log_instant = std::time::Instant::now();
|
||||||
|
|
||||||
loop {
|
info!(
|
||||||
let recv_start = std::time::Instant::now();
|
room = %media_room_name,
|
||||||
let pkt = match transport.recv_media().await {
|
participant = participant_id,
|
||||||
Ok(Some(pkt)) => pkt,
|
%addr,
|
||||||
Ok(None) => {
|
session = %media_session_id,
|
||||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
"forwarding loop started (plain)"
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
Err(e) => {
|
loop {
|
||||||
let msg = e.to_string();
|
let pkt = match media_transport.recv_media().await {
|
||||||
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
Ok(Some(pkt)) => pkt,
|
||||||
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
|
Ok(None) => {
|
||||||
} else {
|
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
|
||||||
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
break;
|
||||||
}
|
}
|
||||||
break;
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
|
||||||
|
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
|
||||||
|
} else {
|
||||||
|
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
||||||
|
last_recv_instant = std::time::Instant::now();
|
||||||
|
if recv_gap_ms > max_recv_gap_ms {
|
||||||
|
max_recv_gap_ms = recv_gap_ms;
|
||||||
|
}
|
||||||
|
if recv_gap_ms > 200 {
|
||||||
|
warn!(
|
||||||
|
room = %media_room_name,
|
||||||
|
participant = participant_id,
|
||||||
|
recv_gap_ms,
|
||||||
|
seq = pkt.header.seq,
|
||||||
|
"large recv gap"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
|
if let Some(ref report) = pkt.quality_report {
|
||||||
last_recv_instant = std::time::Instant::now();
|
media_metrics.update_session_quality(&media_session_id, report);
|
||||||
if recv_gap_ms > max_recv_gap_ms {
|
}
|
||||||
max_recv_gap_ms = recv_gap_ms;
|
|
||||||
}
|
|
||||||
// Log if recv gap is suspiciously large (>200ms = missed ~10 packets)
|
|
||||||
if recv_gap_ms > 200 {
|
|
||||||
warn!(
|
|
||||||
room = %room_name,
|
|
||||||
participant = participant_id,
|
|
||||||
recv_gap_ms,
|
|
||||||
seq = pkt.header.seq,
|
|
||||||
"large recv gap"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update per-session quality metrics if a quality report is present
|
let lock_start = std::time::Instant::now();
|
||||||
if let Some(ref report) = pkt.quality_report {
|
let others = {
|
||||||
metrics.update_session_quality(session_id, report);
|
let mgr = media_room_mgr.lock().await;
|
||||||
}
|
mgr.others(&media_room_name, participant_id)
|
||||||
|
};
|
||||||
|
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
||||||
|
if lock_ms > 10 {
|
||||||
|
warn!(room = %media_room_name, participant = participant_id, lock_ms, "slow room_mgr lock");
|
||||||
|
}
|
||||||
|
|
||||||
// Get current list of other participants
|
let fwd_start = std::time::Instant::now();
|
||||||
let lock_start = std::time::Instant::now();
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
let others = {
|
for other in &others {
|
||||||
let mgr = room_mgr.lock().await;
|
match other {
|
||||||
mgr.others(&room_name, participant_id)
|
ParticipantSender::Quic(t) => {
|
||||||
};
|
if let Err(e) = t.send_media(&pkt).await {
|
||||||
let lock_ms = lock_start.elapsed().as_millis() as u64;
|
send_errors += 1;
|
||||||
if lock_ms > 10 {
|
if send_errors <= 5 || send_errors % 100 == 0 {
|
||||||
warn!(
|
warn!(
|
||||||
room = %room_name,
|
room = %media_room_name,
|
||||||
participant = participant_id,
|
participant = participant_id,
|
||||||
lock_ms,
|
peer = %t.connection().remote_address(),
|
||||||
"slow room_mgr lock"
|
total_send_errors = send_errors,
|
||||||
);
|
"send_media error: {e}"
|
||||||
}
|
);
|
||||||
|
}
|
||||||
// Forward to all others
|
|
||||||
let fwd_start = std::time::Instant::now();
|
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
|
||||||
for other in &others {
|
|
||||||
match other {
|
|
||||||
ParticipantSender::Quic(t) => {
|
|
||||||
if let Err(e) = t.send_media(&pkt).await {
|
|
||||||
send_errors += 1;
|
|
||||||
if send_errors <= 5 || send_errors % 100 == 0 {
|
|
||||||
warn!(
|
|
||||||
room = %room_name,
|
|
||||||
participant = participant_id,
|
|
||||||
peer = %t.connection().remote_address(),
|
|
||||||
total_send_errors = send_errors,
|
|
||||||
"send_media error: {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ParticipantSender::WebSocket(_) => {
|
||||||
|
let _ = other.send_raw(&pkt.payload).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ParticipantSender::WebSocket(_) => {
|
}
|
||||||
let _ = other.send_raw(&pkt.payload).await;
|
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||||
|
if fwd_ms > max_forward_ms { max_forward_ms = fwd_ms; }
|
||||||
|
if fwd_ms > 50 {
|
||||||
|
warn!(room = %media_room_name, participant = participant_id, fwd_ms, fan_out = others.len(), "slow forward");
|
||||||
|
}
|
||||||
|
|
||||||
|
let fan_out = others.len() as u64;
|
||||||
|
media_metrics.packets_forwarded.inc_by(fan_out);
|
||||||
|
media_metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
||||||
|
packets_forwarded += 1;
|
||||||
|
|
||||||
|
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
||||||
|
let room_size = {
|
||||||
|
let mgr = media_room_mgr.lock().await;
|
||||||
|
mgr.room_size(&media_room_name)
|
||||||
|
};
|
||||||
|
info!(
|
||||||
|
room = %media_room_name,
|
||||||
|
participant = participant_id,
|
||||||
|
forwarded = packets_forwarded,
|
||||||
|
room_size, fan_out, max_recv_gap_ms, max_forward_ms, send_errors,
|
||||||
|
"participant stats"
|
||||||
|
);
|
||||||
|
max_recv_gap_ms = 0;
|
||||||
|
max_forward_ms = 0;
|
||||||
|
last_log_instant = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Signal handling task — processes SetAlias and other in-call signals
|
||||||
|
let signal_room_mgr = room_mgr.clone();
|
||||||
|
let signal_room_name = room_name.clone();
|
||||||
|
let signal_transport = transport.clone();
|
||||||
|
let signal_task = async move {
|
||||||
|
loop {
|
||||||
|
match signal_transport.recv_signal().await {
|
||||||
|
Ok(Some(wzp_proto::SignalMessage::SetAlias { alias })) => {
|
||||||
|
info!(%addr, participant = participant_id, %alias, "SetAlias received");
|
||||||
|
let mut mgr = signal_room_mgr.lock().await;
|
||||||
|
if let Some((update, senders)) =
|
||||||
|
mgr.set_alias(&signal_room_name, participant_id, alias)
|
||||||
|
{
|
||||||
|
drop(mgr);
|
||||||
|
broadcast_signal(&senders, &update).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(wzp_proto::SignalMessage::Hangup { .. })) => {
|
||||||
|
info!(%addr, participant = participant_id, "hangup received");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Some(msg)) => {
|
||||||
|
info!(%addr, participant = participant_id, "signal: {:?}", std::mem::discriminant(&msg));
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%addr, participant = participant_id, "signal recv error: {e}");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
};
|
||||||
if fwd_ms > max_forward_ms {
|
|
||||||
max_forward_ms = fwd_ms;
|
|
||||||
}
|
|
||||||
if fwd_ms > 50 {
|
|
||||||
warn!(
|
|
||||||
room = %room_name,
|
|
||||||
participant = participant_id,
|
|
||||||
fwd_ms,
|
|
||||||
fan_out = others.len(),
|
|
||||||
"slow forward"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fan_out = others.len() as u64;
|
// Run both in parallel — exit when either finishes (disconnection)
|
||||||
metrics.packets_forwarded.inc_by(fan_out);
|
tokio::select! {
|
||||||
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
|
_ = media_task => {}
|
||||||
packets_forwarded += 1;
|
_ = signal_task => {}
|
||||||
|
|
||||||
// Periodic stats log every 5 seconds
|
|
||||||
if last_log_instant.elapsed() >= Duration::from_secs(5) {
|
|
||||||
let room_size = {
|
|
||||||
let mgr = room_mgr.lock().await;
|
|
||||||
mgr.room_size(&room_name)
|
|
||||||
};
|
|
||||||
info!(
|
|
||||||
room = %room_name,
|
|
||||||
participant = participant_id,
|
|
||||||
forwarded = packets_forwarded,
|
|
||||||
room_size,
|
|
||||||
fan_out,
|
|
||||||
max_recv_gap_ms,
|
|
||||||
max_forward_ms,
|
|
||||||
send_errors,
|
|
||||||
"participant stats"
|
|
||||||
);
|
|
||||||
max_recv_gap_ms = 0;
|
|
||||||
max_forward_ms = 0;
|
|
||||||
last_log_instant = std::time::Instant::now();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up — leave room and broadcast update to remaining participants
|
// Clean up — leave room and broadcast update to remaining participants
|
||||||
|
|||||||
16
crates/wzp-web/static/wasm/package.json
Normal file
16
crates/wzp-web/static/wasm/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "wzp-wasm",
|
||||||
|
"type": "module",
|
||||||
|
"description": "WarzonePhone WASM bindings — FEC (RaptorQ) + crypto (ChaCha20-Poly1305, X25519)",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"wzp_wasm_bg.wasm",
|
||||||
|
"wzp_wasm.js",
|
||||||
|
"wzp_wasm.d.ts"
|
||||||
|
],
|
||||||
|
"main": "wzp_wasm.js",
|
||||||
|
"types": "wzp_wasm.d.ts",
|
||||||
|
"sideEffects": [
|
||||||
|
"./snippets/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
169
crates/wzp-web/static/wasm/wzp_wasm.d.ts
vendored
Normal file
169
crates/wzp-web/static/wasm/wzp_wasm.d.ts
vendored
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetric encryption session using ChaCha20-Poly1305.
|
||||||
|
*
|
||||||
|
* Mirrors `wzp-crypto::session::ChaChaSession` for WASM. Nonce derivation
|
||||||
|
* and key setup are identical so WASM and native peers interoperate.
|
||||||
|
*/
|
||||||
|
export class WzpCryptoSession {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Decrypt a media payload with AAD.
|
||||||
|
*
|
||||||
|
* Returns plaintext on success, or throws on auth failure.
|
||||||
|
*/
|
||||||
|
decrypt(header_aad: Uint8Array, ciphertext: Uint8Array): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Encrypt a media payload with AAD (typically the 12-byte MediaHeader).
|
||||||
|
*
|
||||||
|
* Returns `ciphertext || poly1305_tag` (plaintext.len() + 16 bytes).
|
||||||
|
*/
|
||||||
|
encrypt(header_aad: Uint8Array, plaintext: Uint8Array): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Create from a 32-byte shared secret (output of `WzpKeyExchange.derive_shared_secret`).
|
||||||
|
*/
|
||||||
|
constructor(shared_secret: Uint8Array);
|
||||||
|
/**
|
||||||
|
* Current receive sequence number (for diagnostics / UI stats).
|
||||||
|
*/
|
||||||
|
recv_seq(): number;
|
||||||
|
/**
|
||||||
|
* Current send sequence number (for diagnostics / UI stats).
|
||||||
|
*/
|
||||||
|
send_seq(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WzpFecDecoder {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Feed a received symbol.
|
||||||
|
*
|
||||||
|
* Returns the decoded block (concatenated original frames, unpadded) if
|
||||||
|
* enough symbols have been received to recover the block, or `undefined`.
|
||||||
|
*/
|
||||||
|
add_symbol(block_id: number, symbol_idx: number, _is_repair: boolean, data: Uint8Array): Uint8Array | undefined;
|
||||||
|
/**
|
||||||
|
* Create a new FEC decoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — expected number of source symbols per block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (must match encoder).
|
||||||
|
*/
|
||||||
|
constructor(block_size: number, symbol_size: number);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WzpFecEncoder {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Add a source symbol (audio frame).
|
||||||
|
*
|
||||||
|
* Returns encoded packets (all source + repair) when the block is complete,
|
||||||
|
* or `undefined` if the block is still accumulating.
|
||||||
|
*
|
||||||
|
* Each returned packet carries the 3-byte header:
|
||||||
|
* `[block_id][symbol_idx][is_repair]` followed by `symbol_size` bytes.
|
||||||
|
*/
|
||||||
|
add_symbol(data: Uint8Array): Uint8Array | undefined;
|
||||||
|
/**
|
||||||
|
* Force-flush the current (possibly partial) block.
|
||||||
|
*
|
||||||
|
* Returns all source + repair symbols with headers, or empty vec if no
|
||||||
|
* symbols have been accumulated.
|
||||||
|
*/
|
||||||
|
flush(): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Create a new FEC encoder.
|
||||||
|
*
|
||||||
|
* * `block_size` — number of source symbols (audio frames) per FEC block.
|
||||||
|
* * `symbol_size` — padded byte size of each symbol (default 256).
|
||||||
|
*/
|
||||||
|
constructor(block_size: number, symbol_size: number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X25519 key exchange: generate ephemeral keypair and derive shared secret.
|
||||||
|
*
|
||||||
|
* Usage from JS:
|
||||||
|
* ```js
|
||||||
|
* const kx = new WzpKeyExchange();
|
||||||
|
* const ourPub = kx.public_key(); // Uint8Array(32)
|
||||||
|
* // ... send ourPub to peer, receive peerPub ...
|
||||||
|
* const secret = kx.derive_shared_secret(peerPub); // Uint8Array(32)
|
||||||
|
* const session = new WzpCryptoSession(secret);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class WzpKeyExchange {
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte session key from the peer's public key.
|
||||||
|
*
|
||||||
|
* Raw DH output is expanded via HKDF-SHA256 with info="warzone-session-key",
|
||||||
|
* matching `wzp-crypto::handshake::WarzoneKeyExchange::derive_session`.
|
||||||
|
*/
|
||||||
|
derive_shared_secret(peer_public: Uint8Array): Uint8Array;
|
||||||
|
/**
|
||||||
|
* Generate a new random X25519 keypair.
|
||||||
|
*/
|
||||||
|
constructor();
|
||||||
|
/**
|
||||||
|
* Our public key (32 bytes).
|
||||||
|
*/
|
||||||
|
public_key(): Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
export interface InitOutput {
|
||||||
|
readonly memory: WebAssembly.Memory;
|
||||||
|
readonly __wbg_wzpcryptosession_free: (a: number, b: number) => void;
|
||||||
|
readonly __wbg_wzpfecdecoder_free: (a: number, b: number) => void;
|
||||||
|
readonly __wbg_wzpfecencoder_free: (a: number, b: number) => void;
|
||||||
|
readonly __wbg_wzpkeyexchange_free: (a: number, b: number) => void;
|
||||||
|
readonly wzpcryptosession_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
readonly wzpcryptosession_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
readonly wzpcryptosession_new: (a: number, b: number) => [number, number, number];
|
||||||
|
readonly wzpcryptosession_recv_seq: (a: number) => number;
|
||||||
|
readonly wzpcryptosession_send_seq: (a: number) => number;
|
||||||
|
readonly wzpfecdecoder_add_symbol: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
||||||
|
readonly wzpfecdecoder_new: (a: number, b: number) => number;
|
||||||
|
readonly wzpfecencoder_add_symbol: (a: number, b: number, c: number) => [number, number];
|
||||||
|
readonly wzpfecencoder_flush: (a: number) => [number, number];
|
||||||
|
readonly wzpfecencoder_new: (a: number, b: number) => number;
|
||||||
|
readonly wzpkeyexchange_derive_shared_secret: (a: number, b: number, c: number) => [number, number, number, number];
|
||||||
|
readonly wzpkeyexchange_new: () => number;
|
||||||
|
readonly wzpkeyexchange_public_key: (a: number) => [number, number];
|
||||||
|
readonly __wbindgen_exn_store: (a: number) => void;
|
||||||
|
readonly __externref_table_alloc: () => number;
|
||||||
|
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||||
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
|
readonly __externref_table_dealloc: (a: number) => void;
|
||||||
|
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
|
readonly __wbindgen_start: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates the given `module`, which can either be bytes or
|
||||||
|
* a precompiled `WebAssembly.Module`.
|
||||||
|
*
|
||||||
|
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {InitOutput}
|
||||||
|
*/
|
||||||
|
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||||
|
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||||
|
*
|
||||||
|
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {Promise<InitOutput>}
|
||||||
|
*/
|
||||||
|
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||||
27
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm.d.ts
vendored
Normal file
27
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const memory: WebAssembly.Memory;
|
||||||
|
export const __wbg_wzpcryptosession_free: (a: number, b: number) => void;
|
||||||
|
export const __wbg_wzpfecdecoder_free: (a: number, b: number) => void;
|
||||||
|
export const __wbg_wzpfecencoder_free: (a: number, b: number) => void;
|
||||||
|
export const __wbg_wzpkeyexchange_free: (a: number, b: number) => void;
|
||||||
|
export const wzpcryptosession_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
export const wzpcryptosession_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
|
||||||
|
export const wzpcryptosession_new: (a: number, b: number) => [number, number, number];
|
||||||
|
export const wzpcryptosession_recv_seq: (a: number) => number;
|
||||||
|
export const wzpcryptosession_send_seq: (a: number) => number;
|
||||||
|
export const wzpfecdecoder_add_symbol: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
|
||||||
|
export const wzpfecdecoder_new: (a: number, b: number) => number;
|
||||||
|
export const wzpfecencoder_add_symbol: (a: number, b: number, c: number) => [number, number];
|
||||||
|
export const wzpfecencoder_flush: (a: number) => [number, number];
|
||||||
|
export const wzpfecencoder_new: (a: number, b: number) => number;
|
||||||
|
export const wzpkeyexchange_derive_shared_secret: (a: number, b: number, c: number) => [number, number, number, number];
|
||||||
|
export const wzpkeyexchange_new: () => number;
|
||||||
|
export const wzpkeyexchange_public_key: (a: number) => [number, number];
|
||||||
|
export const __wbindgen_exn_store: (a: number) => void;
|
||||||
|
export const __externref_table_alloc: () => number;
|
||||||
|
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||||
|
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
|
export const __externref_table_dealloc: (a: number) => void;
|
||||||
|
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
|
export const __wbindgen_start: () => void;
|
||||||
2
desktop/.gitignore
vendored
Normal file
2
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
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"
|
||||||
|
}
|
||||||
143
desktop/index.html
Normal file
143
desktop/index.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WarzonePhone</title>
|
||||||
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Connect screen -->
|
||||||
|
<div id="connect-screen">
|
||||||
|
<h1>WarzonePhone</h1>
|
||||||
|
<p class="subtitle">Encrypted Voice</p>
|
||||||
|
<div class="form">
|
||||||
|
<label>Relay
|
||||||
|
<button id="relay-selected" class="relay-selected" type="button">
|
||||||
|
<span id="relay-dot" class="dot"></span>
|
||||||
|
<span id="relay-label">Select relay...</span>
|
||||||
|
<span class="arrow">⚙</span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<label>Room
|
||||||
|
<input id="room" type="text" value="android" />
|
||||||
|
</label>
|
||||||
|
<label>Alias
|
||||||
|
<input id="alias" type="text" placeholder="your name" />
|
||||||
|
</label>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="os-aec" type="checkbox" checked />
|
||||||
|
OS Echo Cancel
|
||||||
|
</label>
|
||||||
|
<button id="settings-btn-home" class="icon-btn" title="Settings (Cmd+,)">⚙</button>
|
||||||
|
</div>
|
||||||
|
<button id="connect-btn" class="primary">Connect</button>
|
||||||
|
<p id="connect-error" class="error"></p>
|
||||||
|
</div>
|
||||||
|
<div class="identity-info">
|
||||||
|
<span id="my-identicon"></span>
|
||||||
|
<span id="my-fingerprint" class="fp-display"></span>
|
||||||
|
</div>
|
||||||
|
<div class="recent-rooms" id="recent-rooms"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- In-call screen -->
|
||||||
|
<div id="call-screen" class="hidden">
|
||||||
|
<div class="call-header">
|
||||||
|
<div class="call-header-row">
|
||||||
|
<div id="room-name" class="room-name"></div>
|
||||||
|
<button id="settings-btn-call" class="icon-btn small" title="Settings (Cmd+,)">⚙</button>
|
||||||
|
</div>
|
||||||
|
<div class="call-meta">
|
||||||
|
<span id="call-status" class="status-dot"></span>
|
||||||
|
<span id="call-timer" class="call-timer">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-meter">
|
||||||
|
<div id="level-bar" class="level-bar-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div id="participants" class="participants"></div>
|
||||||
|
<div class="controls">
|
||||||
|
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
|
||||||
|
<span class="icon" id="mic-icon">Mic</span>
|
||||||
|
</button>
|
||||||
|
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
|
||||||
|
<span class="icon">End</span>
|
||||||
|
</button>
|
||||||
|
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
|
||||||
|
<span class="icon" id="spk-icon">Spk</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="stats" class="stats"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings panel -->
|
||||||
|
<div id="settings-panel" class="hidden">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button id="settings-close" class="icon-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Connection</h3>
|
||||||
|
<label>Default Room
|
||||||
|
<input id="s-room" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>Alias
|
||||||
|
<input id="s-alias" type="text" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Audio</h3>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="s-os-aec" type="checkbox" />
|
||||||
|
OS Echo Cancellation (macOS VoiceProcessingIO)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="s-agc" type="checkbox" checked />
|
||||||
|
Automatic Gain Control
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Identity</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Fingerprint</span>
|
||||||
|
<span id="s-fingerprint" class="fp-display-large"></span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Identity file</span>
|
||||||
|
<span class="fp-display">~/.wzp/identity</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Recent Rooms</h3>
|
||||||
|
<div id="s-recent-rooms" class="recent-rooms-list"></div>
|
||||||
|
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
|
||||||
|
</div>
|
||||||
|
<button id="settings-save" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Relays dialog -->
|
||||||
|
<div id="relay-dialog" class="hidden">
|
||||||
|
<div class="settings-card relay-dialog-card">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>Manage Relays</h2>
|
||||||
|
<button id="relay-dialog-close" class="icon-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="relay-dialog-list" class="relay-dialog-list"></div>
|
||||||
|
<div class="relay-add-row">
|
||||||
|
<div class="relay-add-inputs">
|
||||||
|
<input id="relay-add-name" type="text" placeholder="Name" />
|
||||||
|
<input id="relay-add-addr" type="text" placeholder="host:port" />
|
||||||
|
</div>
|
||||||
|
<button id="relay-add-btn" class="primary">Add Relay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1350
desktop/package-lock.json
generated
Normal file
1350
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
desktop/package.json
Normal file
19
desktop/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "wzp-desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"vite": "^6",
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
desktop/src-tauri/Cargo.toml
Normal file
36
desktop/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "wzp-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "WarzonePhone Desktop — encrypted VoIP client"
|
||||||
|
default-run = "wzp-desktop"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
anyhow = "1"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
|
|
||||||
|
# WarzonePhone crates
|
||||||
|
wzp-proto = { path = "../../crates/wzp-proto" }
|
||||||
|
wzp-codec = { path = "../../crates/wzp-codec" }
|
||||||
|
wzp-fec = { path = "../../crates/wzp-fec" }
|
||||||
|
wzp-crypto = { path = "../../crates/wzp-crypto" }
|
||||||
|
wzp-transport = { path = "../../crates/wzp-transport" }
|
||||||
|
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "vpio"] }
|
||||||
|
|
||||||
|
# Platform-specific
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
coreaudio-rs = "0.11"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["custom-protocol"]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
3
desktop/src-tauri/build.rs
Normal file
3
desktop/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
1
desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
desktop/src-tauri/gen/schemas/capabilities.json
Normal file
1
desktop/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
2564
desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
2564
desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2564
desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
2564
desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
desktop/src-tauri/icons/icon.png
Normal file
BIN
desktop/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 B |
365
desktop/src-tauri/src/engine.rs
Normal file
365
desktop/src-tauri/src/engine.rs
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
//! Call engine for the desktop app — wraps wzp-client audio + transport
|
||||||
|
//! into a clean async interface for Tauri commands.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
|
||||||
|
use wzp_client::call::{CallConfig, CallEncoder};
|
||||||
|
use wzp_proto::MediaTransport;
|
||||||
|
|
||||||
|
const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
|
/// Wrapper to make non-Sync audio handles safe to store in shared state.
|
||||||
|
/// The audio handle is only accessed from the thread that created it (drop),
|
||||||
|
/// never shared across threads — Sync is safe.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct SyncWrapper(Box<dyn std::any::Any + Send>);
|
||||||
|
unsafe impl Sync for SyncWrapper {}
|
||||||
|
|
||||||
|
pub struct ParticipantInfo {
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub alias: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EngineStatus {
|
||||||
|
pub mic_muted: bool,
|
||||||
|
pub spk_muted: bool,
|
||||||
|
pub participants: Vec<ParticipantInfo>,
|
||||||
|
pub frames_sent: u64,
|
||||||
|
pub frames_received: u64,
|
||||||
|
pub audio_level: u32,
|
||||||
|
pub call_duration_secs: f64,
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CallEngine {
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
mic_muted: Arc<AtomicBool>,
|
||||||
|
spk_muted: Arc<AtomicBool>,
|
||||||
|
participants: Arc<Mutex<Vec<ParticipantInfo>>>,
|
||||||
|
frames_sent: Arc<AtomicU64>,
|
||||||
|
frames_received: Arc<AtomicU64>,
|
||||||
|
audio_level: Arc<AtomicU32>,
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
start_time: Instant,
|
||||||
|
fingerprint: String,
|
||||||
|
/// Keep audio handles alive for the duration of the call.
|
||||||
|
/// Wrapped in SyncWrapper because AudioUnit isn't Sync.
|
||||||
|
_audio_handle: SyncWrapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallEngine {
|
||||||
|
pub async fn start<F>(
|
||||||
|
relay: String,
|
||||||
|
room: String,
|
||||||
|
alias: String,
|
||||||
|
_os_aec: bool,
|
||||||
|
event_cb: F,
|
||||||
|
) -> Result<Self, anyhow::Error>
|
||||||
|
where
|
||||||
|
F: Fn(&str, &str) + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let relay_addr: SocketAddr = relay.parse()?;
|
||||||
|
|
||||||
|
// Load or generate identity
|
||||||
|
let seed = {
|
||||||
|
let path = {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||||
|
std::path::PathBuf::from(home).join(".wzp").join("identity")
|
||||||
|
};
|
||||||
|
if path.exists() {
|
||||||
|
if let Ok(hex) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
wzp_crypto::Seed::generate()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wzp_crypto::Seed::generate()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let s = wzp_crypto::Seed::generate();
|
||||||
|
if let Some(p) = path.parent() {
|
||||||
|
std::fs::create_dir_all(p).ok();
|
||||||
|
}
|
||||||
|
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
std::fs::write(&path, hex).ok();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let fp = seed.derive_identity().public_identity().fingerprint;
|
||||||
|
let fingerprint = fp.to_string();
|
||||||
|
info!(%fp, "identity loaded");
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||||
|
let client_config = wzp_transport::client_config();
|
||||||
|
let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config).await?;
|
||||||
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
// Handshake
|
||||||
|
let _session = wzp_client::handshake::perform_handshake(
|
||||||
|
&*transport,
|
||||||
|
&seed.0,
|
||||||
|
Some(&alias),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("connected to relay, handshake complete");
|
||||||
|
event_cb("connected", &format!("joined room {room}"));
|
||||||
|
|
||||||
|
// Audio I/O — VPIO (OS AEC) on macOS, plain CPAL otherwise.
|
||||||
|
// The audio handle must be stored in CallEngine to keep streams alive.
|
||||||
|
let (capture_ring, playout_ring, audio_handle): (_, _, Box<dyn std::any::Any + Send>) =
|
||||||
|
if _os_aec {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
match wzp_client::audio_vpio::VpioAudio::start() {
|
||||||
|
Ok(v) => {
|
||||||
|
let cr = v.capture_ring().clone();
|
||||||
|
let pr = v.playout_ring().clone();
|
||||||
|
info!("using VoiceProcessingIO (OS AEC)");
|
||||||
|
(cr, pr, Box::new(v))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!("VPIO failed ({e}), falling back to CPAL");
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
(cr, pr, Box::new((capture, playback)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
info!("OS AEC not available on this platform, using CPAL");
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
(cr, pr, Box::new((capture, playback)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let capture = AudioCapture::start()?;
|
||||||
|
let playback = AudioPlayback::start()?;
|
||||||
|
let cr = capture.ring().clone();
|
||||||
|
let pr = playback.ring().clone();
|
||||||
|
(cr, pr, Box::new((capture, playback)))
|
||||||
|
};
|
||||||
|
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let mic_muted = Arc::new(AtomicBool::new(false));
|
||||||
|
let spk_muted = Arc::new(AtomicBool::new(false));
|
||||||
|
let participants: Arc<Mutex<Vec<ParticipantInfo>>> = Arc::new(Mutex::new(vec![]));
|
||||||
|
let frames_sent = Arc::new(AtomicU64::new(0));
|
||||||
|
let frames_received = Arc::new(AtomicU64::new(0));
|
||||||
|
let audio_level = Arc::new(AtomicU32::new(0));
|
||||||
|
|
||||||
|
// Send task
|
||||||
|
let send_t = transport.clone();
|
||||||
|
let send_r = running.clone();
|
||||||
|
let send_mic = mic_muted.clone();
|
||||||
|
let send_fs = frames_sent.clone();
|
||||||
|
let send_level = audio_level.clone();
|
||||||
|
let send_drops = Arc::new(AtomicU64::new(0));
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let config = CallConfig {
|
||||||
|
noise_suppression: false,
|
||||||
|
suppression_enabled: false,
|
||||||
|
..CallConfig::default()
|
||||||
|
};
|
||||||
|
let mut encoder = CallEncoder::new(&config);
|
||||||
|
encoder.set_aec_enabled(false); // OS AEC or none
|
||||||
|
let mut buf = vec![0i16; FRAME_SAMPLES];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !send_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if capture_ring.available() < FRAME_SAMPLES {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
capture_ring.read(&mut buf);
|
||||||
|
|
||||||
|
// Compute RMS audio level for UI meter
|
||||||
|
if !buf.is_empty() {
|
||||||
|
let sum_sq: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
||||||
|
let rms = (sum_sq / buf.len() as f64).sqrt() as u32;
|
||||||
|
send_level.store(rms, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if send_mic.load(Ordering::Relaxed) {
|
||||||
|
buf.fill(0);
|
||||||
|
}
|
||||||
|
match encoder.encode_frame(&buf) {
|
||||||
|
Ok(pkts) => {
|
||||||
|
for pkt in &pkts {
|
||||||
|
if let Err(e) = send_t.send_media(pkt).await {
|
||||||
|
// Transient congestion (Blocked) — drop packet, keep going
|
||||||
|
send_drops.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if send_drops.load(Ordering::Relaxed) <= 3 {
|
||||||
|
tracing::warn!("send_media error (dropping packet): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send_fs.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(e) => error!("encode: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recv task (direct playout)
|
||||||
|
let recv_t = transport.clone();
|
||||||
|
let recv_r = running.clone();
|
||||||
|
let recv_spk = spk_muted.clone();
|
||||||
|
let recv_fr = frames_received.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut opus_dec = wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD);
|
||||||
|
let mut agc = wzp_codec::AutoGainControl::new();
|
||||||
|
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !recv_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
recv_t.recv_media(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(Some(pkt))) => {
|
||||||
|
if !pkt.header.is_repair {
|
||||||
|
if let Ok(n) = opus_dec.decode(&pkt.payload, &mut pcm) {
|
||||||
|
agc.process_frame(&mut pcm[..n]);
|
||||||
|
if !recv_spk.load(Ordering::Relaxed) {
|
||||||
|
playout_ring.write(&pcm[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recv_fr.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => break,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("closed") || msg.contains("reset") {
|
||||||
|
error!("recv fatal: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Transient error — continue
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal task (presence)
|
||||||
|
let sig_t = transport.clone();
|
||||||
|
let sig_r = running.clone();
|
||||||
|
let sig_p = participants.clone();
|
||||||
|
let event_cb = Arc::new(event_cb);
|
||||||
|
let sig_cb = event_cb.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if !sig_r.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
sig_t.recv_signal(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate {
|
||||||
|
participants: parts,
|
||||||
|
..
|
||||||
|
}))) => {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let unique: Vec<ParticipantInfo> = parts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| seen.insert((p.fingerprint.clone(), p.alias.clone())))
|
||||||
|
.map(|p| ParticipantInfo {
|
||||||
|
fingerprint: p.fingerprint,
|
||||||
|
alias: p.alias,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let count = unique.len();
|
||||||
|
*sig_p.lock().await = unique;
|
||||||
|
sig_cb("room-update", &format!("{count} participants"));
|
||||||
|
}
|
||||||
|
Ok(Ok(Some(_))) => {}
|
||||||
|
Ok(Ok(None)) => break,
|
||||||
|
Ok(Err(_)) => break,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
running,
|
||||||
|
mic_muted,
|
||||||
|
spk_muted,
|
||||||
|
participants,
|
||||||
|
frames_sent,
|
||||||
|
frames_received,
|
||||||
|
audio_level,
|
||||||
|
transport,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
fingerprint,
|
||||||
|
_audio_handle: SyncWrapper(audio_handle),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_mic(&self) -> bool {
|
||||||
|
let was = self.mic_muted.load(Ordering::Relaxed);
|
||||||
|
self.mic_muted.store(!was, Ordering::Relaxed);
|
||||||
|
!was
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_speaker(&self) -> bool {
|
||||||
|
let was = self.spk_muted.load(Ordering::Relaxed);
|
||||||
|
self.spk_muted.store(!was, Ordering::Relaxed);
|
||||||
|
!was
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> EngineStatus {
|
||||||
|
let participants = {
|
||||||
|
let parts = self.participants.lock().await;
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.map(|p| ParticipantInfo {
|
||||||
|
fingerprint: p.fingerprint.clone(),
|
||||||
|
alias: p.alias.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}; // lock dropped here
|
||||||
|
EngineStatus {
|
||||||
|
mic_muted: self.mic_muted.load(Ordering::Relaxed),
|
||||||
|
spk_muted: self.spk_muted.load(Ordering::Relaxed),
|
||||||
|
participants,
|
||||||
|
frames_sent: self.frames_sent.load(Ordering::Relaxed),
|
||||||
|
frames_received: self.frames_received.load(Ordering::Relaxed),
|
||||||
|
audio_level: self.audio_level.load(Ordering::Relaxed),
|
||||||
|
call_duration_secs: self.start_time.elapsed().as_secs_f64(),
|
||||||
|
fingerprint: self.fingerprint.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(self) {
|
||||||
|
self.running.store(false, Ordering::SeqCst);
|
||||||
|
self.transport.close().await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
241
desktop/src-tauri/src/main.rs
Normal file
241
desktop/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod engine;
|
||||||
|
|
||||||
|
use engine::CallEngine;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct CallEvent {
|
||||||
|
kind: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct Participant {
|
||||||
|
fingerprint: String,
|
||||||
|
alias: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct CallStatus {
|
||||||
|
active: bool,
|
||||||
|
mic_muted: bool,
|
||||||
|
spk_muted: bool,
|
||||||
|
participants: Vec<Participant>,
|
||||||
|
encode_fps: u64,
|
||||||
|
recv_fps: u64,
|
||||||
|
audio_level: u32,
|
||||||
|
call_duration_secs: f64,
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
engine: Mutex<Option<CallEngine>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ping result with RTT and server identity hash.
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct PingResult {
|
||||||
|
rtt_ms: u32,
|
||||||
|
/// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
|
||||||
|
server_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ping a relay to check if it's online, measure RTT, and get server identity.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn ping_relay(relay: String) -> Result<PingResult, String> {
|
||||||
|
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let conn_result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
|
||||||
|
let server_fingerprint = conn
|
||||||
|
.peer_identity()
|
||||||
|
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
||||||
|
.and_then(|certs| certs.first().map(|c| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
c.as_ref().hash(&mut hasher);
|
||||||
|
let h = hasher.finish();
|
||||||
|
format!("{h:016x}")
|
||||||
|
}))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.close(0u32.into(), b"ping");
|
||||||
|
Ok(PingResult { rtt_ms, server_fingerprint })
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => Err(format!("{e}")),
|
||||||
|
Err(_) => Err("timeout (3s)".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read fingerprint from ~/.wzp/identity without connecting.
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_identity() -> Result<String, String> {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||||
|
let path = std::path::PathBuf::from(home).join(".wzp").join("identity");
|
||||||
|
if path.exists() {
|
||||||
|
if let Ok(hex) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok(seed) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
||||||
|
let fp = seed.derive_identity().public_identity().fingerprint;
|
||||||
|
return Ok(fp.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No identity yet — generate one so we can show the fingerprint
|
||||||
|
let seed = wzp_crypto::Seed::generate();
|
||||||
|
let fp = seed.derive_identity().public_identity().fingerprint;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
std::fs::write(&path, hex).ok();
|
||||||
|
Ok(fp.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn connect(
|
||||||
|
state: tauri::State<'_, Arc<AppState>>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
relay: String,
|
||||||
|
room: String,
|
||||||
|
alias: String,
|
||||||
|
os_aec: bool,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut engine_lock = state.engine.lock().await;
|
||||||
|
if engine_lock.is_some() {
|
||||||
|
return Err("already connected".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_clone = app.clone();
|
||||||
|
match CallEngine::start(relay, room, alias, os_aec, move |event_kind, message| {
|
||||||
|
let _ = app_clone.emit(
|
||||||
|
"call-event",
|
||||||
|
CallEvent {
|
||||||
|
kind: event_kind.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(eng) => {
|
||||||
|
*engine_lock = Some(eng);
|
||||||
|
Ok("connected".into())
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("{e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
|
||||||
|
let mut engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(engine) = engine_lock.take() {
|
||||||
|
engine.stop().await;
|
||||||
|
Ok("disconnected".into())
|
||||||
|
} else {
|
||||||
|
Err("not connected".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
|
||||||
|
let engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(ref engine) = *engine_lock {
|
||||||
|
Ok(engine.toggle_mic())
|
||||||
|
} else {
|
||||||
|
Err("not connected".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
|
||||||
|
let engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(ref engine) = *engine_lock {
|
||||||
|
Ok(engine.toggle_speaker())
|
||||||
|
} else {
|
||||||
|
Err("not connected".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus, String> {
|
||||||
|
let engine_lock = state.engine.lock().await;
|
||||||
|
if let Some(ref engine) = *engine_lock {
|
||||||
|
let status = engine.status().await;
|
||||||
|
Ok(CallStatus {
|
||||||
|
active: true,
|
||||||
|
mic_muted: status.mic_muted,
|
||||||
|
spk_muted: status.spk_muted,
|
||||||
|
participants: status
|
||||||
|
.participants
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| Participant {
|
||||||
|
fingerprint: p.fingerprint,
|
||||||
|
alias: p.alias,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
encode_fps: status.frames_sent,
|
||||||
|
recv_fps: status.frames_received,
|
||||||
|
audio_level: status.audio_level,
|
||||||
|
call_duration_secs: status.call_duration_secs,
|
||||||
|
fingerprint: status.fingerprint,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(CallStatus {
|
||||||
|
active: false,
|
||||||
|
mic_muted: false,
|
||||||
|
spk_muted: false,
|
||||||
|
participants: vec![],
|
||||||
|
encode_fps: 0,
|
||||||
|
recv_fps: 0,
|
||||||
|
audio_level: 0,
|
||||||
|
call_duration_secs: 0.0,
|
||||||
|
fingerprint: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tracing_subscriber::fmt().init();
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
engine: Mutex::new(None),
|
||||||
|
});
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.manage(state)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
ping_relay,
|
||||||
|
get_identity,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
toggle_mic,
|
||||||
|
toggle_speaker,
|
||||||
|
get_status,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running WarzonePhone Desktop");
|
||||||
|
}
|
||||||
33
desktop/src-tauri/tauri.conf.json
Normal file
33
desktop/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"productName": "WarzonePhone",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.wzp.desktop",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "WarzonePhone",
|
||||||
|
"width": 400,
|
||||||
|
"height": 640,
|
||||||
|
"resizable": true,
|
||||||
|
"minWidth": 360,
|
||||||
|
"minHeight": 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
110
desktop/src/identicon.ts
Normal file
110
desktop/src/identicon.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic identicon generator — creates a unique symmetric pattern
|
||||||
|
* from a hex fingerprint string, similar to MetaMask's Jazzicon / Ethereum blockies.
|
||||||
|
*
|
||||||
|
* Returns an SVG data URL that can be used as an <img> src.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function hashBytes(hex: string): number[] {
|
||||||
|
const clean = hex.replace(/[^0-9a-fA-F]/g, "");
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < clean.length; i += 2) {
|
||||||
|
bytes.push(parseInt(clean.substring(i, i + 2), 16));
|
||||||
|
}
|
||||||
|
// Pad to at least 16 bytes
|
||||||
|
while (bytes.length < 16) bytes.push(0);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
s /= 100;
|
||||||
|
l /= 100;
|
||||||
|
const k = (n: number) => (n + h / 30) % 12;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) =>
|
||||||
|
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||||
|
return [
|
||||||
|
Math.round(f(0) * 255),
|
||||||
|
Math.round(f(8) * 255),
|
||||||
|
Math.round(f(4) * 255),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateIdenticon(
|
||||||
|
fingerprint: string,
|
||||||
|
size: number = 36
|
||||||
|
): string {
|
||||||
|
const bytes = hashBytes(fingerprint);
|
||||||
|
|
||||||
|
// Derive colors from first bytes
|
||||||
|
const hue1 = (bytes[0] * 360) / 256;
|
||||||
|
const hue2 = ((bytes[1] * 360) / 256 + 120) % 360;
|
||||||
|
const [r1, g1, b1] = hslToRgb(hue1, 65, 35); // dark bg
|
||||||
|
const [r2, g2, b2] = hslToRgb(hue2, 70, 55); // bright fg
|
||||||
|
|
||||||
|
const bg = `rgb(${r1},${g1},${b1})`;
|
||||||
|
const fg = `rgb(${r2},${g2},${b2})`;
|
||||||
|
|
||||||
|
// 5x5 grid, left-right symmetric (only need 3 columns)
|
||||||
|
const grid: boolean[][] = [];
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
const row: boolean[] = [];
|
||||||
|
for (let x = 0; x < 3; x++) {
|
||||||
|
const byteIdx = 2 + y * 3 + x;
|
||||||
|
row.push(bytes[byteIdx % bytes.length] > 128);
|
||||||
|
}
|
||||||
|
// Mirror: col 3 = col 1, col 4 = col 0
|
||||||
|
grid.push([row[0], row[1], row[2], row[1], row[0]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render SVG
|
||||||
|
const cellSize = size / 5;
|
||||||
|
const r = size * 0.12; // border radius
|
||||||
|
let rects = "";
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
if (grid[y][x]) {
|
||||||
|
rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
<rect width="${size}" height="${size}" rx="${r}" fill="${bg}"/>
|
||||||
|
${rects}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an <img> element with the identicon.
|
||||||
|
* Click copies the fingerprint to clipboard.
|
||||||
|
*/
|
||||||
|
export function createIdenticonEl(
|
||||||
|
fingerprint: string,
|
||||||
|
size: number = 36,
|
||||||
|
clickToCopy: boolean = true
|
||||||
|
): HTMLImageElement {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = generateIdenticon(fingerprint, size);
|
||||||
|
img.width = size;
|
||||||
|
img.height = size;
|
||||||
|
img.style.borderRadius = `${size * 0.12}px`;
|
||||||
|
img.style.cursor = clickToCopy ? "pointer" : "default";
|
||||||
|
img.title = fingerprint;
|
||||||
|
|
||||||
|
if (clickToCopy && fingerprint) {
|
||||||
|
img.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(fingerprint).then(() => {
|
||||||
|
img.style.outline = "2px solid #4ade80";
|
||||||
|
setTimeout(() => {
|
||||||
|
img.style.outline = "";
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return img;
|
||||||
|
}
|
||||||
591
desktop/src/main.ts
Normal file
591
desktop/src/main.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { generateIdenticon, createIdenticonEl } from "./identicon";
|
||||||
|
|
||||||
|
// ── Elements ──
|
||||||
|
const connectScreen = document.getElementById("connect-screen")!;
|
||||||
|
const callScreen = document.getElementById("call-screen")!;
|
||||||
|
const roomInput = document.getElementById("room") as HTMLInputElement;
|
||||||
|
const aliasInput = document.getElementById("alias") as HTMLInputElement;
|
||||||
|
const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement;
|
||||||
|
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
|
||||||
|
const connectError = document.getElementById("connect-error")!;
|
||||||
|
const roomName = document.getElementById("room-name")!;
|
||||||
|
const callTimer = document.getElementById("call-timer")!;
|
||||||
|
const callStatus = document.getElementById("call-status")!;
|
||||||
|
const levelBar = document.getElementById("level-bar")!;
|
||||||
|
const participantsDiv = document.getElementById("participants")!;
|
||||||
|
const micBtn = document.getElementById("mic-btn")!;
|
||||||
|
const micIcon = document.getElementById("mic-icon")!;
|
||||||
|
const spkBtn = document.getElementById("spk-btn")!;
|
||||||
|
const spkIcon = document.getElementById("spk-icon")!;
|
||||||
|
const hangupBtn = document.getElementById("hangup-btn")!;
|
||||||
|
const statsDiv = document.getElementById("stats")!;
|
||||||
|
const myFingerprintEl = document.getElementById("my-fingerprint")!;
|
||||||
|
const myIdenticonEl = document.getElementById("my-identicon")!;
|
||||||
|
const recentRoomsDiv = document.getElementById("recent-rooms")!;
|
||||||
|
|
||||||
|
// Relay button
|
||||||
|
const relaySelected = document.getElementById("relay-selected")!;
|
||||||
|
const relayDot = document.getElementById("relay-dot")!;
|
||||||
|
const relayLabel = document.getElementById("relay-label")!;
|
||||||
|
|
||||||
|
// Relay dialog
|
||||||
|
const relayDialog = document.getElementById("relay-dialog")!;
|
||||||
|
const relayDialogClose = document.getElementById("relay-dialog-close")!;
|
||||||
|
const relayDialogList = document.getElementById("relay-dialog-list")!;
|
||||||
|
const relayAddName = document.getElementById("relay-add-name") as HTMLInputElement;
|
||||||
|
const relayAddAddr = document.getElementById("relay-add-addr") as HTMLInputElement;
|
||||||
|
const relayAddBtn = document.getElementById("relay-add-btn")!;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const settingsPanel = document.getElementById("settings-panel")!;
|
||||||
|
const settingsClose = document.getElementById("settings-close")!;
|
||||||
|
const settingsSave = document.getElementById("settings-save")!;
|
||||||
|
const settingsBtnHome = document.getElementById("settings-btn-home")!;
|
||||||
|
const settingsBtnCall = document.getElementById("settings-btn-call")!;
|
||||||
|
const sRoom = document.getElementById("s-room") as HTMLInputElement;
|
||||||
|
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
|
||||||
|
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
|
||||||
|
const sAgc = document.getElementById("s-agc") as HTMLInputElement;
|
||||||
|
const sFingerprint = document.getElementById("s-fingerprint")!;
|
||||||
|
const sRecentRooms = document.getElementById("s-recent-rooms")!;
|
||||||
|
const sClearRecent = document.getElementById("s-clear-recent")!;
|
||||||
|
|
||||||
|
let statusInterval: number | null = null;
|
||||||
|
let myFingerprint = "";
|
||||||
|
let userDisconnected = false;
|
||||||
|
|
||||||
|
// ── Data types ──
|
||||||
|
interface RelayServer {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
rtt?: number | null;
|
||||||
|
serverFingerprint?: string | null; // from ping
|
||||||
|
knownFingerprint?: string | null; // saved TOFU fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentRoom { relay: string; room: string; }
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
relays: RelayServer[];
|
||||||
|
selectedRelay: number;
|
||||||
|
room: string;
|
||||||
|
alias: string;
|
||||||
|
osAec: boolean;
|
||||||
|
agc: boolean;
|
||||||
|
recentRooms: RecentRoom[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings(): Settings {
|
||||||
|
const defaults: Settings = {
|
||||||
|
relays: [{ name: "Default", address: "193.180.213.68:4433" }],
|
||||||
|
selectedRelay: 0, room: "android", alias: "",
|
||||||
|
osAec: true, agc: true, recentRooms: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("wzp-settings");
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed.relay && !parsed.relays) {
|
||||||
|
parsed.relays = [{ name: "Default", address: parsed.relay }];
|
||||||
|
parsed.selectedRelay = 0;
|
||||||
|
delete parsed.relay;
|
||||||
|
}
|
||||||
|
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
|
||||||
|
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
|
||||||
|
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
|
||||||
|
}
|
||||||
|
return { ...defaults, ...parsed };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettingsObj(s: Settings) {
|
||||||
|
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedRelay(): RelayServer | undefined {
|
||||||
|
const s = loadSettings();
|
||||||
|
return s.relays[s.selectedRelay];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lock status ──
|
||||||
|
type LockStatus = "verified" | "new" | "changed" | "offline" | "unknown";
|
||||||
|
|
||||||
|
function lockStatus(relay: RelayServer): LockStatus {
|
||||||
|
if (relay.rtt === undefined || relay.rtt === null) return "unknown";
|
||||||
|
if (relay.rtt < 0) return "offline";
|
||||||
|
if (!relay.serverFingerprint) return "new";
|
||||||
|
if (!relay.knownFingerprint) return "new"; // first time
|
||||||
|
if (relay.serverFingerprint === relay.knownFingerprint) return "verified";
|
||||||
|
return "changed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockIcon(status: LockStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "verified": return "🔒";
|
||||||
|
case "new": return "🔓";
|
||||||
|
case "changed": return "⚠️";
|
||||||
|
case "offline": return "🔴";
|
||||||
|
case "unknown": return "⚪";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockColor(status: LockStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "verified": return "var(--green)";
|
||||||
|
case "new": return "var(--yellow)";
|
||||||
|
case "changed": return "var(--red)";
|
||||||
|
case "offline": return "var(--red)";
|
||||||
|
case "unknown": return "var(--text-dim)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply settings ──
|
||||||
|
function applySettings() {
|
||||||
|
const s = loadSettings();
|
||||||
|
roomInput.value = s.room;
|
||||||
|
aliasInput.value = s.alias;
|
||||||
|
osAecCheckbox.checked = s.osAec;
|
||||||
|
renderRecentRooms(s.recentRooms);
|
||||||
|
renderRelayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Relay button ──
|
||||||
|
function renderRelayButton() {
|
||||||
|
const s = loadSettings();
|
||||||
|
const sel = s.relays[s.selectedRelay];
|
||||||
|
if (sel) {
|
||||||
|
const ls = lockStatus(sel);
|
||||||
|
relayDot.textContent = lockIcon(ls);
|
||||||
|
relayDot.className = "relay-lock";
|
||||||
|
relayLabel.textContent = `${sel.name} (${sel.address})`;
|
||||||
|
} else {
|
||||||
|
relayDot.textContent = "⚪";
|
||||||
|
relayDot.className = "relay-lock";
|
||||||
|
relayLabel.textContent = "No relay configured";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relaySelected.addEventListener("click", () => openRelayDialog());
|
||||||
|
|
||||||
|
// ── Relay dialog ──
|
||||||
|
function openRelayDialog() {
|
||||||
|
renderRelayDialogList();
|
||||||
|
relayAddName.value = "";
|
||||||
|
relayAddAddr.value = "";
|
||||||
|
relayDialog.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRelayDialog() {
|
||||||
|
relayDialog.classList.add("hidden");
|
||||||
|
renderRelayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRelayDialogList() {
|
||||||
|
const s = loadSettings();
|
||||||
|
relayDialogList.innerHTML = "";
|
||||||
|
s.relays.forEach((r, i) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}`;
|
||||||
|
|
||||||
|
const ls = lockStatus(r);
|
||||||
|
const fp = r.serverFingerprint || r.address;
|
||||||
|
|
||||||
|
// Identicon
|
||||||
|
const icon = createIdenticonEl(fp, 32, true);
|
||||||
|
icon.title = r.serverFingerprint
|
||||||
|
? `Server: ${r.serverFingerprint}\nClick to copy`
|
||||||
|
: `No fingerprint yet`;
|
||||||
|
item.appendChild(icon);
|
||||||
|
|
||||||
|
// Info
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "relay-info";
|
||||||
|
info.innerHTML = `
|
||||||
|
<div class="relay-name">${escapeHtml(r.name)}</div>
|
||||||
|
<div class="relay-addr">${escapeHtml(r.address)}</div>
|
||||||
|
`;
|
||||||
|
item.appendChild(info);
|
||||||
|
|
||||||
|
// Lock + RTT
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "relay-meta";
|
||||||
|
const rttStr = r.rtt !== undefined && r.rtt !== null
|
||||||
|
? (r.rtt < 0 ? "offline" : `${r.rtt}ms`)
|
||||||
|
: "";
|
||||||
|
meta.innerHTML = `
|
||||||
|
<span class="relay-lock-icon" style="color:${lockColor(ls)}">${lockIcon(ls)}</span>
|
||||||
|
<span class="relay-rtt">${rttStr}</span>
|
||||||
|
`;
|
||||||
|
item.appendChild(meta);
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.className = "remove";
|
||||||
|
del.textContent = "×";
|
||||||
|
del.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const s = loadSettings();
|
||||||
|
s.relays.splice(i, 1);
|
||||||
|
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
|
||||||
|
saveSettingsObj(s);
|
||||||
|
renderRelayDialogList();
|
||||||
|
renderRelayButton();
|
||||||
|
});
|
||||||
|
item.appendChild(del);
|
||||||
|
|
||||||
|
// Click to select
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
const s = loadSettings();
|
||||||
|
s.selectedRelay = i;
|
||||||
|
|
||||||
|
// TOFU: if first time seeing this server, trust its fingerprint
|
||||||
|
if (r.serverFingerprint && !r.knownFingerprint) {
|
||||||
|
s.relays[i].knownFingerprint = r.serverFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettingsObj(s);
|
||||||
|
renderRelayDialogList();
|
||||||
|
renderRelayButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
relayDialogList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
relayAddBtn.addEventListener("click", () => {
|
||||||
|
const name = relayAddName.value.trim();
|
||||||
|
const addr = relayAddAddr.value.trim();
|
||||||
|
if (!addr) return;
|
||||||
|
const s = loadSettings();
|
||||||
|
s.relays.push({ name: name || addr, address: addr });
|
||||||
|
saveSettingsObj(s);
|
||||||
|
relayAddName.value = "";
|
||||||
|
relayAddAddr.value = "";
|
||||||
|
renderRelayDialogList();
|
||||||
|
pingAllRelays();
|
||||||
|
});
|
||||||
|
|
||||||
|
relayDialogClose.addEventListener("click", closeRelayDialog);
|
||||||
|
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
|
||||||
|
|
||||||
|
// ── Ping ──
|
||||||
|
interface PingResult { rtt_ms: number; server_fingerprint: string; }
|
||||||
|
|
||||||
|
async function pingAllRelays() {
|
||||||
|
const s = loadSettings();
|
||||||
|
for (let i = 0; i < s.relays.length; i++) {
|
||||||
|
const r = s.relays[i];
|
||||||
|
try {
|
||||||
|
const result: PingResult = await invoke("ping_relay", { relay: r.address });
|
||||||
|
r.rtt = result.rtt_ms;
|
||||||
|
r.serverFingerprint = result.server_fingerprint;
|
||||||
|
|
||||||
|
// TOFU: auto-save fingerprint on first contact
|
||||||
|
if (!r.knownFingerprint) {
|
||||||
|
r.knownFingerprint = result.server_fingerprint;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
r.rtt = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveSettingsObj(s);
|
||||||
|
renderRelayButton();
|
||||||
|
if (!relayDialog.classList.contains("hidden")) renderRelayDialogList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent rooms ──
|
||||||
|
function renderRecentRooms(rooms: RecentRoom[]) {
|
||||||
|
recentRoomsDiv.innerHTML = rooms
|
||||||
|
.map((r) => `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`)
|
||||||
|
.join("");
|
||||||
|
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
const ds = (el as HTMLElement).dataset;
|
||||||
|
roomInput.value = ds.room || "";
|
||||||
|
const s = loadSettings();
|
||||||
|
const idx = s.relays.findIndex((r) => r.address === ds.relay);
|
||||||
|
if (idx >= 0) { s.selectedRelay = idx; saveSettingsObj(s); renderRelayButton(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
|
applySettings();
|
||||||
|
setTimeout(pingAllRelays, 300);
|
||||||
|
|
||||||
|
// Load fingerprint + render identicon
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const fp: string = await invoke("get_identity");
|
||||||
|
myFingerprint = fp;
|
||||||
|
myFingerprintEl.textContent = fp;
|
||||||
|
myFingerprintEl.style.cursor = "pointer";
|
||||||
|
myFingerprintEl.addEventListener("click", () => {
|
||||||
|
navigator.clipboard.writeText(fp).then(() => {
|
||||||
|
const orig = myFingerprintEl.textContent;
|
||||||
|
myFingerprintEl.textContent = "Copied!";
|
||||||
|
setTimeout(() => { myFingerprintEl.textContent = orig; }, 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identicon next to fingerprint
|
||||||
|
const icon = createIdenticonEl(fp, 28, true);
|
||||||
|
myIdenticonEl.innerHTML = "";
|
||||||
|
myIdenticonEl.appendChild(icon);
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Connect ──
|
||||||
|
connectBtn.addEventListener("click", doConnect);
|
||||||
|
[roomInput, aliasInput].forEach((el) =>
|
||||||
|
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
|
||||||
|
);
|
||||||
|
|
||||||
|
async function doConnect() {
|
||||||
|
const relay = getSelectedRelay();
|
||||||
|
if (!relay) { connectError.textContent = "No relay selected"; return; }
|
||||||
|
|
||||||
|
// Warn on fingerprint mismatch
|
||||||
|
const ls = lockStatus(relay);
|
||||||
|
if (ls === "changed") {
|
||||||
|
if (!confirm(`Server fingerprint has changed!\n\nKnown: ${relay.knownFingerprint}\nNew: ${relay.serverFingerprint}\n\nThis could indicate a man-in-the-middle attack. Continue?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// User accepted — update known fingerprint
|
||||||
|
const s = loadSettings();
|
||||||
|
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
|
||||||
|
saveSettingsObj(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't block connect on offline — ping may have failed transiently
|
||||||
|
|
||||||
|
connectError.textContent = "";
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
connectBtn.textContent = "Connecting...";
|
||||||
|
userDisconnected = false;
|
||||||
|
|
||||||
|
const s = loadSettings();
|
||||||
|
s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked;
|
||||||
|
const room = roomInput.value.trim();
|
||||||
|
if (room) {
|
||||||
|
const entry: RecentRoom = { relay: relay.address, room };
|
||||||
|
s.recentRooms = [entry, ...s.recentRooms.filter((r) => !(r.relay === relay.address && r.room === room))].slice(0, 5);
|
||||||
|
}
|
||||||
|
saveSettingsObj(s);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("connect", {
|
||||||
|
relay: relay.address, room: roomInput.value,
|
||||||
|
alias: aliasInput.value, osAec: osAecCheckbox.checked,
|
||||||
|
});
|
||||||
|
showCallScreen();
|
||||||
|
} catch (e: any) {
|
||||||
|
connectError.textContent = String(e);
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
connectBtn.textContent = "Connect";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCallScreen() {
|
||||||
|
connectScreen.classList.add("hidden");
|
||||||
|
callScreen.classList.remove("hidden");
|
||||||
|
roomName.textContent = roomInput.value;
|
||||||
|
callStatus.className = "status-dot";
|
||||||
|
statusInterval = window.setInterval(pollStatus, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConnectScreen() {
|
||||||
|
callScreen.classList.add("hidden");
|
||||||
|
connectScreen.classList.remove("hidden");
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
connectBtn.textContent = "Connect";
|
||||||
|
levelBar.style.width = "0%";
|
||||||
|
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mute / hangup ──
|
||||||
|
micBtn.addEventListener("click", async () => {
|
||||||
|
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
|
||||||
|
});
|
||||||
|
spkBtn.addEventListener("click", async () => {
|
||||||
|
try { const m: boolean = await invoke("toggle_speaker"); spkBtn.classList.toggle("muted", m); spkIcon.textContent = m ? "Spk Off" : "Spk"; } catch {}
|
||||||
|
});
|
||||||
|
hangupBtn.addEventListener("click", async () => {
|
||||||
|
userDisconnected = true;
|
||||||
|
try { await invoke("disconnect"); } catch {}
|
||||||
|
showConnectScreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (callScreen.classList.contains("hidden")) return;
|
||||||
|
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||||
|
if (e.key === "m") micBtn.click();
|
||||||
|
if (e.key === "s") spkBtn.click();
|
||||||
|
if (e.key === "q") hangupBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Status polling ──
|
||||||
|
interface CallStatusI {
|
||||||
|
active: boolean; mic_muted: boolean; spk_muted: boolean;
|
||||||
|
participants: { fingerprint: string; alias: string | null }[];
|
||||||
|
encode_fps: number; recv_fps: number; audio_level: number;
|
||||||
|
call_duration_secs: number; fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(secs: number): string {
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const st: CallStatusI = await invoke("get_status");
|
||||||
|
if (!st.active) {
|
||||||
|
if (!userDisconnected && reconnectAttempts < 5) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
callStatus.className = "status-dot reconnecting";
|
||||||
|
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/5)...`;
|
||||||
|
const relay = getSelectedRelay();
|
||||||
|
if (relay) {
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await invoke("connect", { relay: relay.address, room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked });
|
||||||
|
reconnectAttempts = 0; callStatus.className = "status-dot";
|
||||||
|
} catch {}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnectAttempts = 0; showConnectScreen(); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
if (st.fingerprint) myFingerprint = st.fingerprint;
|
||||||
|
|
||||||
|
micBtn.classList.toggle("muted", st.mic_muted);
|
||||||
|
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
||||||
|
spkBtn.classList.toggle("muted", st.spk_muted);
|
||||||
|
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
|
||||||
|
callTimer.textContent = formatDuration(st.call_duration_secs);
|
||||||
|
|
||||||
|
const rms = st.audio_level;
|
||||||
|
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
||||||
|
levelBar.style.width = `${pct}%`;
|
||||||
|
|
||||||
|
// Participants with identicons
|
||||||
|
if (st.participants.length === 0) {
|
||||||
|
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
||||||
|
} else {
|
||||||
|
participantsDiv.innerHTML = "";
|
||||||
|
st.participants.forEach((p) => {
|
||||||
|
const name = p.alias || "Anonymous";
|
||||||
|
const fp = p.fingerprint || "";
|
||||||
|
const isMe = fp && myFingerprint.includes(fp);
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "participant";
|
||||||
|
|
||||||
|
// Identicon avatar
|
||||||
|
const icon = createIdenticonEl(fp || name, 36, true);
|
||||||
|
if (isMe) icon.style.outline = "2px solid var(--accent)";
|
||||||
|
row.appendChild(icon);
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "info";
|
||||||
|
info.innerHTML = `
|
||||||
|
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
||||||
|
<div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
|
||||||
|
`;
|
||||||
|
row.appendChild(info);
|
||||||
|
participantsDiv.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
listen("call-event", (event: any) => {
|
||||||
|
const { kind } = event.payload;
|
||||||
|
if (kind === "room-update") pollStatus();
|
||||||
|
if (kind === "disconnected" && !userDisconnected) pollStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Settings ──
|
||||||
|
function openSettings() {
|
||||||
|
const s = loadSettings();
|
||||||
|
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
|
||||||
|
sFingerprint.textContent = myFingerprint || "(loading...)";
|
||||||
|
renderSettingsRecentRooms(s.recentRooms);
|
||||||
|
settingsPanel.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
function closeSettings() { settingsPanel.classList.add("hidden"); }
|
||||||
|
|
||||||
|
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
||||||
|
if (rooms.length === 0) {
|
||||||
|
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sRecentRooms.innerHTML = rooms.map((r, i) => `
|
||||||
|
<div class="recent-room-item">
|
||||||
|
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
||||||
|
<button class="remove" data-idx="${i}">×</button>
|
||||||
|
</div>`).join("");
|
||||||
|
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
|
||||||
|
const s = loadSettings();
|
||||||
|
s.recentRooms.splice(idx, 1);
|
||||||
|
saveSettingsObj(s);
|
||||||
|
renderSettingsRecentRooms(s.recentRooms);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsBtnHome.addEventListener("click", openSettings);
|
||||||
|
settingsBtnCall.addEventListener("click", openSettings);
|
||||||
|
settingsClose.addEventListener("click", closeSettings);
|
||||||
|
settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) closeSettings(); });
|
||||||
|
|
||||||
|
settingsSave.addEventListener("click", () => {
|
||||||
|
const s = loadSettings();
|
||||||
|
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
|
||||||
|
saveSettingsObj(s);
|
||||||
|
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
|
||||||
|
renderRecentRooms(s.recentRooms);
|
||||||
|
closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
sClearRecent.addEventListener("click", () => {
|
||||||
|
const s = loadSettings();
|
||||||
|
s.recentRooms = [];
|
||||||
|
saveSettingsObj(s);
|
||||||
|
renderSettingsRecentRooms([]);
|
||||||
|
renderRecentRooms([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
|
||||||
|
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
653
desktop/src/style.css
Normal file
653
desktop/src/style.css
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f0f1a;
|
||||||
|
--surface: #1a1a2e;
|
||||||
|
--surface2: #222244;
|
||||||
|
--primary: #0f3460;
|
||||||
|
--accent: #e94560;
|
||||||
|
--text: #eee;
|
||||||
|
--text-dim: #777;
|
||||||
|
--green: #4ade80;
|
||||||
|
--red: #ef4444;
|
||||||
|
--yellow: #facc15;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ── Connect screen ── */
|
||||||
|
#connect-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connect-screen h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: -12px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input[type="text"] {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input[type="text"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Relay button ── */
|
||||||
|
.relay-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-selected:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.relay-lock {
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-selected .arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.green { background: var(--green); }
|
||||||
|
.dot.yellow { background: var(--yellow); }
|
||||||
|
.dot.red { background: var(--red); }
|
||||||
|
.dot.gray { background: #555; }
|
||||||
|
|
||||||
|
/* ── Relay dialog ── */
|
||||||
|
#relay-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-dialog-card {
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-dialog-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-dialog-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-dialog-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.relay-dialog-item { cursor: pointer; transition: background 0.1s; }
|
||||||
|
.relay-dialog-item:hover { background: var(--surface2); }
|
||||||
|
.relay-dialog-item.selected { background: var(--primary); border: 1px solid var(--accent); }
|
||||||
|
|
||||||
|
.relay-dialog-item .relay-info { flex: 1; min-width: 0; overflow: hidden; }
|
||||||
|
.relay-dialog-item .relay-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; }
|
||||||
|
|
||||||
|
.relay-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-lock-icon { font-size: 16px; }
|
||||||
|
.relay-meta .relay-rtt { font-size: 10px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.relay-dialog-item .remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-dialog-item .remove:hover { color: var(--red); }
|
||||||
|
|
||||||
|
.relay-add-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-add-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-add-row input {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-add-row input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.relay-add-row .primary {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover { opacity: 0.9; }
|
||||||
|
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-rooms {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-room {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-room:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Call screen ── */
|
||||||
|
#call-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
display: inline-block;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.reconnecting {
|
||||||
|
background: var(--yellow);
|
||||||
|
animation: blink 0.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-timer {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Audio level meter ── */
|
||||||
|
.level-meter {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, var(--green) 0%, var(--yellow) 60%, var(--red) 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Participants ── */
|
||||||
|
.participants {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #ffffff08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.participant .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant .avatar.me {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant .info { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.participant .name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant .fp {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant .you-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: #e9456020;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Controls ── */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover { background: var(--primary); }
|
||||||
|
|
||||||
|
.control-btn.muted {
|
||||||
|
background: var(--red);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.hangup {
|
||||||
|
background: var(--red);
|
||||||
|
color: white;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.hangup:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ── Stats ── */
|
||||||
|
.stats {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Icon button ── */
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
.icon-btn.small { width: 28px; height: 28px; font-size: 14px; }
|
||||||
|
|
||||||
|
.call-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings panel ── */
|
||||||
|
#settings-panel {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section input[type="text"] {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section input[type="text"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fp-display-large {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-rooms-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-room-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-room-item .remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-room-item .remove:hover { color: var(--red); }
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
15
desktop/tsconfig.json
Normal file
15
desktop/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
desktop/vite.config.ts
Normal file
15
desktop/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||||
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
|
},
|
||||||
|
});
|
||||||
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