Compare commits
37 Commits
opus-DRED
...
395a0c557e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
395a0c557e | ||
|
|
da593f9510 | ||
|
|
7bddc6b5a6 | ||
|
|
3b85604b41 | ||
|
|
a8c2011445 | ||
|
|
ded49bdb7b | ||
|
|
369347ce54 | ||
|
|
44f04b55e8 | ||
|
|
85c2146760 | ||
|
|
96ccb4f333 | ||
|
|
95a905e1b5 | ||
|
|
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,52 @@ 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::Opus32k => QualityProfile::STUDIO_32K,
|
||||||
|
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||||
|
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||||
|
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 +560,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,59 @@ 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,
|
||||||
|
},
|
||||||
|
"studio-32k" | "opus32k" | "32k" => QualityProfile::STUDIO_32K,
|
||||||
|
"studio-48k" | "opus48k" | "48k" | "studio" => QualityProfile::STUDIO_48K,
|
||||||
|
"studio-64k" | "opus64k" | "64k" | "studio-high" => QualityProfile::STUDIO_64K,
|
||||||
|
other => {
|
||||||
|
eprintln!("unknown profile: {other}");
|
||||||
|
eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200, studio-32k, studio-48k, studio-64k");
|
||||||
|
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 +156,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 +211,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 +270,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 +289,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 +343,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 +376,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 +435,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 +474,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 +506,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 +536,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 +561,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 +577,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 +598,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 +627,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 +722,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ pub async fn perform_handshake(
|
|||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles: vec![
|
supported_profiles: vec![
|
||||||
|
QualityProfile::STUDIO_64K,
|
||||||
|
QualityProfile::STUDIO_48K,
|
||||||
|
QualityProfile::STUDIO_32K,
|
||||||
QualityProfile::GOOD,
|
QualityProfile::GOOD,
|
||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
c if c.is_opus() => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
c if c.is_opus() => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
self.apply_bitrate(profile.codec)?;
|
self.apply_bitrate(profile.codec)?;
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ pub enum CodecId {
|
|||||||
Codec2_1200 = 4,
|
Codec2_1200 = 4,
|
||||||
/// Comfort noise descriptor (silence suppression)
|
/// Comfort noise descriptor (silence suppression)
|
||||||
ComfortNoise = 5,
|
ComfortNoise = 5,
|
||||||
|
/// Opus at 32kbps (studio low)
|
||||||
|
Opus32k = 6,
|
||||||
|
/// Opus at 48kbps (studio)
|
||||||
|
Opus48k = 7,
|
||||||
|
/// Opus at 64kbps (studio high)
|
||||||
|
Opus64k = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodecId {
|
impl CodecId {
|
||||||
@@ -27,6 +33,9 @@ impl CodecId {
|
|||||||
Self::Opus24k => 24_000,
|
Self::Opus24k => 24_000,
|
||||||
Self::Opus16k => 16_000,
|
Self::Opus16k => 16_000,
|
||||||
Self::Opus6k => 6_000,
|
Self::Opus6k => 6_000,
|
||||||
|
Self::Opus32k => 32_000,
|
||||||
|
Self::Opus48k => 48_000,
|
||||||
|
Self::Opus64k => 64_000,
|
||||||
Self::Codec2_3200 => 3_200,
|
Self::Codec2_3200 => 3_200,
|
||||||
Self::Codec2_1200 => 1_200,
|
Self::Codec2_1200 => 1_200,
|
||||||
Self::ComfortNoise => 0,
|
Self::ComfortNoise => 0,
|
||||||
@@ -36,8 +45,7 @@ impl CodecId {
|
|||||||
/// Preferred frame duration in milliseconds.
|
/// Preferred frame duration in milliseconds.
|
||||||
pub const fn frame_duration_ms(self) -> u8 {
|
pub const fn frame_duration_ms(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k => 20,
|
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
||||||
Self::Opus16k => 20,
|
|
||||||
Self::Opus6k => 40,
|
Self::Opus6k => 40,
|
||||||
Self::Codec2_3200 => 20,
|
Self::Codec2_3200 => 20,
|
||||||
Self::Codec2_1200 => 40,
|
Self::Codec2_1200 => 40,
|
||||||
@@ -48,7 +56,8 @@ impl CodecId {
|
|||||||
/// Sample rate expected by this codec.
|
/// Sample rate expected by this codec.
|
||||||
pub const fn sample_rate_hz(self) -> u32 {
|
pub const fn sample_rate_hz(self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
Self::Opus24k | Self::Opus16k | Self::Opus6k
|
||||||
|
| Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000,
|
||||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||||
Self::ComfortNoise => 48_000,
|
Self::ComfortNoise => 48_000,
|
||||||
}
|
}
|
||||||
@@ -63,6 +72,9 @@ impl CodecId {
|
|||||||
3 => Some(Self::Codec2_3200),
|
3 => Some(Self::Codec2_3200),
|
||||||
4 => Some(Self::Codec2_1200),
|
4 => Some(Self::Codec2_1200),
|
||||||
5 => Some(Self::ComfortNoise),
|
5 => Some(Self::ComfortNoise),
|
||||||
|
6 => Some(Self::Opus32k),
|
||||||
|
7 => Some(Self::Opus48k),
|
||||||
|
8 => Some(Self::Opus64k),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +83,12 @@ impl CodecId {
|
|||||||
pub const fn to_wire(self) -> u8 {
|
pub const fn to_wire(self) -> u8 {
|
||||||
self as u8
|
self as u8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if this is an Opus variant.
|
||||||
|
pub const fn is_opus(self) -> bool {
|
||||||
|
matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k
|
||||||
|
| Self::Opus32k | Self::Opus48k | Self::Opus64k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Describes the complete quality configuration for a call session.
|
/// Describes the complete quality configuration for a call session.
|
||||||
@@ -111,6 +129,30 @@ impl QualityProfile {
|
|||||||
frames_per_block: 8,
|
frames_per_block: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Studio low: Opus 32kbps, minimal FEC.
|
||||||
|
pub const STUDIO_32K: Self = Self {
|
||||||
|
codec: CodecId::Opus32k,
|
||||||
|
fec_ratio: 0.1,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Studio: Opus 48kbps, minimal FEC.
|
||||||
|
pub const STUDIO_48K: Self = Self {
|
||||||
|
codec: CodecId::Opus48k,
|
||||||
|
fec_ratio: 0.1,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Studio high: Opus 64kbps, minimal FEC.
|
||||||
|
pub const STUDIO_64K: Self = Self {
|
||||||
|
codec: CodecId::Opus64k,
|
||||||
|
fec_ratio: 0.1,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
};
|
||||||
|
|
||||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -665,6 +670,10 @@ pub struct RoomParticipant {
|
|||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
/// Optional display name set by the client.
|
/// Optional display name set by the client.
|
||||||
pub alias: Option<String>,
|
pub alias: Option<String>,
|
||||||
|
/// Relay label — identifies which relay this participant is connected to.
|
||||||
|
/// None for local participants, Some("Relay B") for federated.
|
||||||
|
#[serde(default)]
|
||||||
|
pub relay_label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reasons for ending a call.
|
/// Reasons for ending a call.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
182
desktop/index.html
Normal file
182
desktop/index.html
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<!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>
|
||||||
|
<div class="quality-control">
|
||||||
|
<div class="quality-header">
|
||||||
|
<span class="setting-label">QUALITY</span>
|
||||||
|
<span id="s-quality-label" class="quality-label">Auto</span>
|
||||||
|
</div>
|
||||||
|
<input id="s-quality" type="range" min="0" max="7" step="1" value="3" class="quality-slider" />
|
||||||
|
<div class="quality-ticks">
|
||||||
|
<span>64k</span>
|
||||||
|
<span>48k</span>
|
||||||
|
<span>32k</span>
|
||||||
|
<span>Auto</span>
|
||||||
|
<span>24k</span>
|
||||||
|
<span>6k</span>
|
||||||
|
<span>C2</span>
|
||||||
|
<span>1.2k</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<!-- Key changed warning dialog -->
|
||||||
|
<div id="key-warning" class="hidden">
|
||||||
|
<div class="settings-card key-warning-card">
|
||||||
|
<div class="key-warning-icon">⚠</div>
|
||||||
|
<h2>Server Key Changed</h2>
|
||||||
|
<p class="key-warning-text">The relay's identity has changed since you last connected. This usually happens when the server was restarted, but could also indicate a security issue.</p>
|
||||||
|
<div class="key-warning-fps">
|
||||||
|
<div class="key-fp-row">
|
||||||
|
<span class="key-fp-label">Previously known</span>
|
||||||
|
<code id="kw-old-fp" class="key-fp"></code>
|
||||||
|
</div>
|
||||||
|
<div class="key-fp-row">
|
||||||
|
<span class="key-fp-label">New key</span>
|
||||||
|
<code id="kw-new-fp" class="key-fp"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="key-warning-actions">
|
||||||
|
<button id="kw-accept" class="primary">Accept New Key</button>
|
||||||
|
<button id="kw-cancel" class="secondary-btn">Cancel</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 |
439
desktop/src-tauri/src/engine.rs
Normal file
439
desktop/src-tauri/src/engine.rs
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
//! 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::{CodecId, MediaTransport, QualityProfile};
|
||||||
|
|
||||||
|
const FRAME_SAMPLES_40MS: usize = 1920;
|
||||||
|
|
||||||
|
/// Resolve a quality string from the UI to a QualityProfile.
|
||||||
|
/// Returns None for "auto" (use default adaptive behavior).
|
||||||
|
fn resolve_quality(quality: &str) -> Option<QualityProfile> {
|
||||||
|
match quality {
|
||||||
|
"good" | "opus" => Some(QualityProfile::GOOD),
|
||||||
|
"degraded" | "opus6k" => Some(QualityProfile::DEGRADED),
|
||||||
|
"catastrophic" | "codec2-1200" => Some(QualityProfile::CATASTROPHIC),
|
||||||
|
"codec2-3200" => Some(QualityProfile {
|
||||||
|
codec: CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5,
|
||||||
|
frame_duration_ms: 20,
|
||||||
|
frames_per_block: 5,
|
||||||
|
}),
|
||||||
|
"studio-32k" => Some(QualityProfile::STUDIO_32K),
|
||||||
|
"studio-48k" => Some(QualityProfile::STUDIO_48K),
|
||||||
|
"studio-64k" => Some(QualityProfile::STUDIO_64K),
|
||||||
|
_ => None, // "auto" or unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 relay_label: 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 tx_codec: String,
|
||||||
|
pub rx_codec: 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>,
|
||||||
|
tx_codec: Arc<Mutex<String>>,
|
||||||
|
rx_codec: Arc<Mutex<String>>,
|
||||||
|
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,
|
||||||
|
quality: String,
|
||||||
|
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));
|
||||||
|
let tx_codec = Arc::new(Mutex::new(String::new()));
|
||||||
|
let rx_codec = Arc::new(Mutex::new(String::new()));
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
let send_quality = quality.clone();
|
||||||
|
let send_tx_codec = tx_codec.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let profile = resolve_quality(&send_quality);
|
||||||
|
let config = match profile {
|
||||||
|
Some(p) => CallConfig {
|
||||||
|
noise_suppression: false,
|
||||||
|
suppression_enabled: false,
|
||||||
|
..CallConfig::from_profile(p)
|
||||||
|
},
|
||||||
|
None => CallConfig {
|
||||||
|
noise_suppression: false,
|
||||||
|
suppression_enabled: false,
|
||||||
|
..CallConfig::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let frame_samples = (config.profile.frame_duration_ms as usize) * 48;
|
||||||
|
info!(codec = ?config.profile.codec, frame_samples, "send task starting");
|
||||||
|
*send_tx_codec.lock().await = format!("{:?}", config.profile.codec);
|
||||||
|
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 with auto codec switch)
|
||||||
|
let recv_t = transport.clone();
|
||||||
|
let recv_r = running.clone();
|
||||||
|
let recv_spk = spk_muted.clone();
|
||||||
|
let recv_fr = frames_received.clone();
|
||||||
|
let recv_rx_codec = rx_codec.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
|
||||||
|
let mut decoder = wzp_codec::create_decoder(initial_profile);
|
||||||
|
let mut current_codec = initial_profile.codec;
|
||||||
|
let mut agc = wzp_codec::AutoGainControl::new();
|
||||||
|
let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec
|
||||||
|
|
||||||
|
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 && pkt.header.codec_id != CodecId::ComfortNoise {
|
||||||
|
// Track RX codec
|
||||||
|
{
|
||||||
|
let mut rx = recv_rx_codec.lock().await;
|
||||||
|
let codec_name = format!("{:?}", pkt.header.codec_id);
|
||||||
|
if *rx != codec_name { *rx = codec_name; }
|
||||||
|
}
|
||||||
|
// Auto-switch decoder if incoming codec differs
|
||||||
|
if pkt.header.codec_id != current_codec {
|
||||||
|
let new_profile = match pkt.header.codec_id {
|
||||||
|
CodecId::Opus24k => QualityProfile::GOOD,
|
||||||
|
CodecId::Opus6k => QualityProfile::DEGRADED,
|
||||||
|
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
||||||
|
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
||||||
|
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
||||||
|
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
||||||
|
CodecId::Codec2_3200 => QualityProfile {
|
||||||
|
codec: CodecId::Codec2_3200,
|
||||||
|
fec_ratio: 0.5, frame_duration_ms: 20, frames_per_block: 5,
|
||||||
|
},
|
||||||
|
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
|
||||||
|
};
|
||||||
|
info!(from = ?current_codec, to = ?pkt.header.codec_id, "recv: switching decoder");
|
||||||
|
let _ = decoder.set_profile(new_profile);
|
||||||
|
current_codec = pkt.header.codec_id;
|
||||||
|
}
|
||||||
|
if let Ok(n) = decoder.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
relay_label: p.relay_label,
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
tx_codec,
|
||||||
|
rx_codec,
|
||||||
|
_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(),
|
||||||
|
relay_label: p.relay_label.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(),
|
||||||
|
tx_codec: self.tx_codec.lock().await.clone(),
|
||||||
|
rx_codec: self.rx_codec.lock().await.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(self) {
|
||||||
|
self.running.store(false, Ordering::SeqCst);
|
||||||
|
self.transport.close().await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
250
desktop/src-tauri/src/main.rs
Normal file
250
desktop/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
#![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>,
|
||||||
|
relay_label: 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,
|
||||||
|
tx_codec: String,
|
||||||
|
rx_codec: 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,
|
||||||
|
quality: String,
|
||||||
|
) -> 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, quality, 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,
|
||||||
|
relay_label: p.relay_label,
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
tx_codec: status.tx_codec,
|
||||||
|
rx_codec: status.rx_codec,
|
||||||
|
})
|
||||||
|
} 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(),
|
||||||
|
tx_codec: String::new(),
|
||||||
|
rx_codec: 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;
|
||||||
|
}
|
||||||
668
desktop/src/main.ts
Normal file
668
desktop/src/main.ts
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
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 sQuality = document.getElementById("s-quality") as HTMLInputElement;
|
||||||
|
const sQualityLabel = document.getElementById("s-quality-label")!;
|
||||||
|
|
||||||
|
// Quality slider config — best (left/green) to worst (right/red)
|
||||||
|
const QUALITY_STEPS = ["studio-64k", "studio-48k", "studio-32k", "auto", "good", "degraded", "codec2-3200", "catastrophic"];
|
||||||
|
const QUALITY_LABELS = ["Studio 64k", "Studio 48k", "Studio 32k", "Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"];
|
||||||
|
const QUALITY_COLORS = ["#22c55e", "#4ade80", "#86efac", "#a3e635", "#facc15", "#f59e0b", "#e97320", "#991b1b"];
|
||||||
|
|
||||||
|
function qualityToIndex(q: string): number {
|
||||||
|
const idx = QUALITY_STEPS.indexOf(q);
|
||||||
|
return idx >= 0 ? idx : 3; // default to "auto" (index 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQualityUI(index: number) {
|
||||||
|
sQualityLabel.textContent = QUALITY_LABELS[index];
|
||||||
|
sQualityLabel.style.color = QUALITY_COLORS[index];
|
||||||
|
sQuality.style.background = `linear-gradient(90deg, #22c55e 0%, #86efac 25%, #facc15 50%, #e97320 75%, #991b1b 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sQuality.addEventListener("input", () => {
|
||||||
|
updateQualityUI(parseInt(sQuality.value));
|
||||||
|
});
|
||||||
|
const sFingerprint = document.getElementById("s-fingerprint")!;
|
||||||
|
const sRecentRooms = document.getElementById("s-recent-rooms")!;
|
||||||
|
const sClearRecent = document.getElementById("s-clear-recent")!;
|
||||||
|
|
||||||
|
// Key warning dialog
|
||||||
|
const keyWarning = document.getElementById("key-warning")!;
|
||||||
|
const kwOldFp = document.getElementById("kw-old-fp")!;
|
||||||
|
const kwNewFp = document.getElementById("kw-new-fp")!;
|
||||||
|
const kwAccept = document.getElementById("kw-accept")!;
|
||||||
|
const kwCancel = document.getElementById("kw-cancel")!;
|
||||||
|
|
||||||
|
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;
|
||||||
|
quality: string;
|
||||||
|
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, quality: "auto", 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(); })
|
||||||
|
);
|
||||||
|
|
||||||
|
function showKeyWarning(oldFp: string, newFp: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
kwOldFp.textContent = oldFp;
|
||||||
|
kwNewFp.textContent = newFp;
|
||||||
|
keyWarning.classList.remove("hidden");
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
keyWarning.classList.add("hidden");
|
||||||
|
kwAccept.removeEventListener("click", onAccept);
|
||||||
|
kwCancel.removeEventListener("click", onCancel);
|
||||||
|
keyWarning.removeEventListener("click", onBackdrop);
|
||||||
|
};
|
||||||
|
const onAccept = () => { cleanup(); resolve(true); };
|
||||||
|
const onCancel = () => { cleanup(); resolve(false); };
|
||||||
|
const onBackdrop = (e: Event) => { if (e.target === keyWarning) { cleanup(); resolve(false); } };
|
||||||
|
|
||||||
|
kwAccept.addEventListener("click", onAccept);
|
||||||
|
kwCancel.addEventListener("click", onCancel);
|
||||||
|
keyWarning.addEventListener("click", onBackdrop);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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") {
|
||||||
|
const accepted = await showKeyWarning(relay.knownFingerprint || "", relay.serverFingerprint || "");
|
||||||
|
if (!accepted) return;
|
||||||
|
// User accepted — update known fingerprint
|
||||||
|
const s = loadSettings();
|
||||||
|
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
|
||||||
|
saveSettingsObj(s);
|
||||||
|
renderRelayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
quality: s.quality || "auto",
|
||||||
|
});
|
||||||
|
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 grouped by relay
|
||||||
|
if (st.participants.length === 0) {
|
||||||
|
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
||||||
|
} else {
|
||||||
|
participantsDiv.innerHTML = "";
|
||||||
|
// Group by relay_label (null = this relay)
|
||||||
|
const groups: Record<string, typeof st.participants> = {};
|
||||||
|
st.participants.forEach((p: any) => {
|
||||||
|
const relay = p.relay_label || "This Relay";
|
||||||
|
if (!groups[relay]) groups[relay] = [];
|
||||||
|
groups[relay].push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(groups).forEach(([relay, members]) => {
|
||||||
|
// Relay header
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "relay-group-header";
|
||||||
|
const isLocal = relay === "This Relay";
|
||||||
|
header.innerHTML = `<span class="relay-dot-small ${isLocal ? "green" : "blue"}"></span> ${escapeHtml(relay)}`;
|
||||||
|
participantsDiv.appendChild(header);
|
||||||
|
|
||||||
|
// Participants under this relay
|
||||||
|
(members as any[]).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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats line with codec badges
|
||||||
|
const txBadge = (st as any).tx_codec ? `<span class="codec-badge tx">${escapeHtml((st as any).tx_codec)}</span>` : "";
|
||||||
|
const rxBadge = (st as any).rx_codec ? `<span class="codec-badge rx">${escapeHtml((st as any).rx_codec)}</span>` : "";
|
||||||
|
statsDiv.innerHTML = `${txBadge} ${rxBadge} 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;
|
||||||
|
const qi = qualityToIndex(s.quality || "auto");
|
||||||
|
sQuality.value = String(qi);
|
||||||
|
updateQualityUI(qi);
|
||||||
|
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;
|
||||||
|
s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
872
desktop/src/style.css
Normal file
872
desktop/src/style.css
Normal file
@@ -0,0 +1,872 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Relay group headers ── */
|
||||||
|
.relay-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 6px 0 2px;
|
||||||
|
border-top: 1px solid #ffffff08;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-group-header:first-child {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-dot-small {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-dot-small.green { background: var(--green); }
|
||||||
|
.relay-dot-small.blue { background: #60a5fa; }
|
||||||
|
|
||||||
|
/* ── Codec badges ── */
|
||||||
|
.codec-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec-badge.tx {
|
||||||
|
background: #22c55e30;
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec-badge.rx {
|
||||||
|
background: #3b82f630;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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); }
|
||||||
|
|
||||||
|
/* ── Key warning dialog ── */
|
||||||
|
#key-warning {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-card {
|
||||||
|
max-width: 360px;
|
||||||
|
text-align: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--yellow);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-card h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-fps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-fp-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-fp-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-fp {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-actions .primary {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--yellow);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning-actions .secondary-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quality slider ── */
|
||||||
|
.quality-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text);
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-ticks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form select {
|
||||||
|
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 select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section select {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
141
docs/PRD-local-recording.md
Normal file
141
docs/PRD-local-recording.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# PRD: Local Recording + Cloud Mixer for Podcast-Quality Interviews
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
WarzonePhone delivers real-time encrypted voice, but the audio quality is limited by network conditions (codec compression, packet loss, jitter). Podcasters and interviewers need pristine, studio-grade recordings of each participant — independent of what the network delivers.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
**Dual-path architecture**: each client simultaneously (1) participates in the live call at whatever codec quality the network supports, and (2) records their own microphone locally as lossless PCM. After the session, all local recordings are uploaded to a self-hosted mixer service that aligns, normalizes, and outputs a final multi-track or mixed file.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
Mic ──┬── Opus/Codec2 ──► Network (live) │ ← real-time call
|
||||||
|
│ └──────────────────┘
|
||||||
|
│
|
||||||
|
└── WAV 48kHz ────► Local File │ ← pristine recording
|
||||||
|
(timestamped)
|
||||||
|
│
|
||||||
|
▼ (after hangup)
|
||||||
|
┌──────────────────┐
|
||||||
|
│ Mixer Service │ ← self-hosted
|
||||||
|
│ (align + mix) │
|
||||||
|
└──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Final MP3/WAV/FLAC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Phase 1: Local Recording (MVP)
|
||||||
|
|
||||||
|
**All clients (Desktop, Android, Web):**
|
||||||
|
|
||||||
|
1. **Record toggle**: User can enable "Record this call" before or during a call
|
||||||
|
2. **Recording pipeline**: Tap raw PCM from the microphone capture path *before* it enters the codec encoder
|
||||||
|
3. **File format**: WAV (48kHz, 16-bit, mono) — simple, universally supported, lossless
|
||||||
|
4. **Sync markers**: Embed a monotonic timestamp (ms since call start) at the beginning of the recording, and periodically (every 10s) write a sync marker packet into a sidecar JSON file:
|
||||||
|
```json
|
||||||
|
{"ts_ms": 30000, "seq": 1500, "wall_clock_utc": "2026-04-07T12:00:30Z"}
|
||||||
|
```
|
||||||
|
This allows the mixer to align recordings from different participants even if they join at different times.
|
||||||
|
5. **Storage**:
|
||||||
|
- Desktop: `~/.wzp/recordings/{room}_{timestamp}.wav`
|
||||||
|
- Android: `Documents/WarzonePhone/{room}_{timestamp}.wav`
|
||||||
|
- Web: IndexedDB blob or File System Access API
|
||||||
|
6. **File size estimate**: 48kHz * 16-bit * mono = 96 KB/s = ~5.6 MB/min = ~345 MB/hour
|
||||||
|
7. **UI indicator**: Red dot + timer showing recording is active and file size growing
|
||||||
|
8. **On hangup**: Close the WAV file, show "Recording saved" with file path/size
|
||||||
|
|
||||||
|
### Phase 2: Upload to Mixer
|
||||||
|
|
||||||
|
1. **Upload endpoint**: Self-hosted HTTP service (Rust or Go) that accepts WAV uploads with metadata
|
||||||
|
2. **Chunked/resumable upload**: Large files need resumable uploads (tus protocol or simple chunked POST)
|
||||||
|
3. **Upload metadata**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "uuid",
|
||||||
|
"participant_fingerprint": "xxxx:xxxx:...",
|
||||||
|
"alias": "Alice",
|
||||||
|
"room": "podcast-ep-42",
|
||||||
|
"duration_secs": 3600,
|
||||||
|
"sync_markers": [...],
|
||||||
|
"sample_rate": 48000,
|
||||||
|
"channels": 1,
|
||||||
|
"bit_depth": 16
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. **Upload UI**: Progress bar after hangup, option to upload now or later
|
||||||
|
5. **Retry on failure**: Queue uploads for retry if network is unavailable
|
||||||
|
|
||||||
|
### Phase 3: Mixer Service
|
||||||
|
|
||||||
|
1. **Alignment**: Use sync markers (wall clock + sequence numbers) to align recordings from all participants to a common timeline
|
||||||
|
2. **Silence trimming**: Detect and optionally trim leading/trailing silence
|
||||||
|
3. **Normalization**: Per-track loudness normalization (LUFS-based)
|
||||||
|
4. **Noise reduction**: Optional per-track noise gate or RNNoise pass
|
||||||
|
5. **Output formats**:
|
||||||
|
- Multi-track: ZIP of individual WAVs (aligned, normalized)
|
||||||
|
- Mixed: Single stereo or mono WAV/MP3/FLAC with all participants
|
||||||
|
- Podcast-ready: Loudness-normalized to -16 LUFS (podcast standard)
|
||||||
|
6. **Web UI**: Simple dashboard to see sessions, download outputs, preview waveforms
|
||||||
|
7. **Self-hosted**: Docker image, single binary, SQLite for metadata
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Recording tap point
|
||||||
|
|
||||||
|
The recording must tap *after* AGC (so levels are normalized) but *before* the codec encoder (to avoid compression artifacts). In the current architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mic → Ring Buffer → AGC → [TAP HERE for recording] → Opus/Codec2 → Network
|
||||||
|
```
|
||||||
|
|
||||||
|
**Desktop** (`engine.rs`): After `capture_agc.process_frame()`, before `encoder.encode()`
|
||||||
|
**Android** (`engine.rs`): Same location — after AGC, before encode
|
||||||
|
**CLI** (`call.rs`): After `self.agc.process_frame()` in `CallEncoder::encode_frame()`
|
||||||
|
|
||||||
|
### WAV writer
|
||||||
|
|
||||||
|
Use a simple streaming WAV writer that:
|
||||||
|
- Writes the WAV header with placeholder data length
|
||||||
|
- Appends PCM samples as they come
|
||||||
|
- On close, seeks back to update the data length in the header
|
||||||
|
|
||||||
|
### Sync mechanism
|
||||||
|
|
||||||
|
Wall-clock UTC alone is insufficient (clocks drift). The sync strategy:
|
||||||
|
1. Each participant records their local monotonic time + wall clock at call start
|
||||||
|
2. Periodically (every 10s), each participant writes: `{local_mono_ms, seq_number, utc_iso}`
|
||||||
|
3. The mixer uses sequence numbers (which are shared via the wire protocol) as ground truth for alignment, with wall clock as a fallback
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
|
||||||
|
- Local recordings never leave the device without explicit user action
|
||||||
|
- Upload is manual, not automatic
|
||||||
|
- The mixer service processes files and can delete originals after mixing
|
||||||
|
- No recording data flows through the relay — only the user's own mic
|
||||||
|
|
||||||
|
## Non-Goals (v1)
|
||||||
|
|
||||||
|
- Live transcription (future)
|
||||||
|
- Video recording (audio only)
|
||||||
|
- Automatic upload without user consent
|
||||||
|
- Recording other participants' audio (only your own mic)
|
||||||
|
- Real-time mixing (post-session only)
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| Phase | Scope | Effort |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| 1a | Local WAV recording on Desktop | 1-2 days |
|
||||||
|
| 1b | Local WAV recording on Android | 1-2 days |
|
||||||
|
| 1c | Sync markers + metadata sidecar | 1 day |
|
||||||
|
| 2a | Upload service (HTTP + storage) | 2-3 days |
|
||||||
|
| 2b | Upload UI in clients | 1-2 days |
|
||||||
|
| 3a | Mixer: alignment + normalization | 2-3 days |
|
||||||
|
| 3b | Mixer: web dashboard | 2-3 days |
|
||||||
|
| 3c | Docker packaging | 1 day |
|
||||||
56
docs/PRD-studio-quality.md
Normal file
56
docs/PRD-studio-quality.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# PRD: Studio Quality Tiers (Opus 32k/48k/64k)
|
||||||
|
|
||||||
|
## Status: Implemented
|
||||||
|
|
||||||
|
Studio quality tiers have been added to the wire protocol and all clients.
|
||||||
|
|
||||||
|
## What Was Added
|
||||||
|
|
||||||
|
### Wire Protocol (codec_id.rs)
|
||||||
|
|
||||||
|
Three new `CodecId` variants using the 4-bit header space (values 6-8):
|
||||||
|
|
||||||
|
| CodecId | Wire Value | Bitrate | Frame | Use Case |
|
||||||
|
|---------|-----------|---------|-------|----------|
|
||||||
|
| Opus32k | 6 | 32 kbps | 20ms | Studio low — noticeable improvement over 24k for voice |
|
||||||
|
| Opus48k | 7 | 48 kbps | 20ms | Studio — excellent voice, captures nuance |
|
||||||
|
| Opus64k | 8 | 64 kbps | 20ms | Studio high — near-transparent quality |
|
||||||
|
|
||||||
|
### Quality Profiles
|
||||||
|
|
||||||
|
| Profile | Codec | FEC | Bandwidth (with FEC) |
|
||||||
|
|---------|-------|-----|---------------------|
|
||||||
|
| STUDIO_32K | Opus 32k | 10% | ~35 kbps |
|
||||||
|
| STUDIO_48K | Opus 48k | 10% | ~53 kbps |
|
||||||
|
| STUDIO_64K | Opus 64k | 10% | ~70 kbps |
|
||||||
|
|
||||||
|
FEC is set to 10% (vs 20% for GOOD) — studio assumes a good network.
|
||||||
|
|
||||||
|
### Client Support
|
||||||
|
|
||||||
|
| Client | Selection | Status |
|
||||||
|
|--------|-----------|--------|
|
||||||
|
| Desktop (Tauri) | Quality slider in Settings (8 levels) | Done |
|
||||||
|
| CLI | `--profile studio-64k` / `studio-48k` / `studio-32k` | Done |
|
||||||
|
| Android | Needs codec picker update in SettingsScreen.kt | TODO |
|
||||||
|
| Web | Needs UI | TODO |
|
||||||
|
|
||||||
|
### Cross-Codec Interop
|
||||||
|
|
||||||
|
All decoder auto-switch paths (call.rs, desktop engine.rs) handle the new codec IDs. A studio-64k client can talk to a codec2-1200 client — the receiver auto-switches.
|
||||||
|
|
||||||
|
## When to Use Studio Tiers
|
||||||
|
|
||||||
|
- **Podcast recording sessions**: Use studio-64k for best quality (combined with local WAV recording for pristine output)
|
||||||
|
- **Music collaboration**: Opus at 48-64k captures instrument harmonics much better than 24k
|
||||||
|
- **Good network conditions**: Only useful when bandwidth isn't constrained; the extra bits are wasted on lossy networks
|
||||||
|
|
||||||
|
## When NOT to Use
|
||||||
|
|
||||||
|
- **Mobile data**: Stick with Auto/GOOD — studio tiers use 2-3x the bandwidth
|
||||||
|
- **High packet loss**: Studio profiles use minimal FEC (10%); degraded networks need DEGRADED or CATASTROPHIC profiles with 50-100% FEC
|
||||||
|
- **Large group calls**: Each participant's stream multiplies bandwidth; 64k * 10 participants = 640 kbps incoming
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
Old clients (before this change) will receive packets with CodecId 6/7/8 which they don't recognize. The `from_wire()` returns `None` for unknown values, causing the packet to be dropped. Old clients can still *send* to new clients fine (they use CodecId 0-5). This is acceptable for a pre-release protocol.
|
||||||
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