38 Commits

Author SHA1 Message Date
Siavash Sameni
073756ed4b fix: auto-switch decoder codec to match incoming packets
The CallDecoder now inspects each incoming packet's codec_id and
automatically switches the audio decoder if it differs from the
current profile. This enables cross-codec interop where one client
sends Opus and the other sends Codec2 — previously the receiver
would try to decode with the wrong codec, producing garbled audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:25:24 +04:00
Siavash Sameni
2fcc2d77cf feat: add --profile/--codec flag to CLI for forcing codec selection
Enables debugging Codec2 by allowing forced codec selection from CLI.
Supports: good, degraded, catastrophic, codec2-3200, codec2-1200.
Frame size, timing, and jitter buffer are all adjusted dynamically
based on the selected profile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:23:36 +04:00
Siavash Sameni
f7ccb67b02 fix: desktop ping closes endpoint properly, prevents resource leaks
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:00:32 +04:00
Siavash Sameni
4df08eadbd fix: don't block connect on offline ping — always allow connection attempt
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Server may be reachable even if ping failed (transient timeout).
User should always be able to try connecting. Fingerprint change
still shows confirm dialog (accept/reject).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:20:38 +04:00
Siavash Sameni
6d776097c8 feat: relay ping handling, identity persistence, linux build script (backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Backported from feat/android-voip-client:
- Relay: SNI "ping" connections handled gracefully (no timeout errors)
- Relay: identity persisted in ~/.wzp/relay-identity (stable fingerprint)
- Linux fire-and-forget build script (Hetzner VM)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:45:27 +04:00
Siavash Sameni
9f7962a6cd fix: vec allocation for desktop AudioRing (match Android fix)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Same fix as Android: Box::new([0i16; 16384]) allocates 32KB on the
stack before moving to heap. Use vec![].into_boxed_slice() for
direct heap allocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 05:26:59 +04:00
Siavash Sameni
8c9befb15d ci: skip build on CI-only file changes
Add paths-ignore for .gitea/** so build.yml doesn't waste runner time
when only workflow files are modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:12:32 +04:00
Siavash Sameni
3f869a4cd7 ci: add GitHub mirror workflow
Automatically pushes branches and tags to github.com:manawenuz/wzp.git
on every push to Forgejo. Uses GH_SSH_KEY secret for authentication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:50:39 +04:00
Siavash Sameni
2263e898e5 fix: port AudioRing reader-detects-lap fix to desktop client
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Same fix as Android (4af7c5f): writer never touches read_pos,
reader self-corrects when lapped. Power-of-2 capacity (16384),
bitmask indexing, overflow/underrun counters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:42:33 +04:00
Siavash Sameni
9ab57ba037 merge: fj/feat/android-voip-client — congestion fix, AEC toggle, debug logging
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m36s
Merged 10 commits from Android branch:
- Send task crash fix on QUIC congestion (continue instead of break)
- AEC toggle + NoiseSuppressor on Android
- Debug reporter for crash diagnostics
- Mic mute crackling fix
- Participant dedup in UI
- Proper QUIC connection close on hangup
- Null alias display fix
- Tracing → Android logcat
- Incident reports for send-task crash and playout ring desync

Conflict resolved in room.rs: kept Android's improved debug logging
(recv gap tracking, lock contention, forward latency, send errors)
inside our media_task async block for parallel signal handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:13:43 +04:00
Siavash Sameni
7806d4ec04 feat: identicons, server fingerprints, lock status (TOFU)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Identicon generator:
- Deterministic 5x5 symmetric pattern from fingerprint hash
- HSL-derived colors, rendered as inline SVG
- Click any identicon to copy its fingerprint to clipboard
- Used for participants, user identity, and relay servers

Server identity (TOFU — Trust On First Use):
- Ping returns server fingerprint (QUIC peer certificate hash)
- First contact: auto-saved as known fingerprint
- Subsequent pings: compared against known fingerprint
- Lock icons: locked (verified), unlocked (new), warning (changed), red (offline)
- Fingerprint mismatch shows confirmation dialog before connecting

UI updates:
- Participants show identicons instead of letter avatars
- User identity shows identicon + fingerprint on connect screen
- Manage Relays shows identicon per server with lock status
- Relay button shows lock icon instead of colored dot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:02:42 +04:00
Siavash Sameni
d31b81a21d fix: replace relay dropdown with direct dialog on click
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m53s
- Click relay button opens Manage Relays dialog directly (no dropdown)
- Click a relay in the dialog to select it (highlighted with accent border)
- × button to delete, Add Relay button to add new
- Removed all dropdown menu code and CSS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:53:13 +04:00
Claude
4d54b6f9e4 docs: incident reports for send-task crash and playout ring desync
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
Two root-caused bugs documented with full evidence:

1. Send task fatal exit on QUIC congestion (FIXED in 2092245)
   - send_media() Err(Blocked) caused break → killed entire call
   - Now drops packet and continues

2. Playout ring buffer cursor desync (ROOT-CAUSED, fix pending)
   - AudioRing::write() mutates read_pos from producer thread on overflow
   - Violates SPSC contract → reader/writer fight over read_pos
   - Causes 12-16s bidirectional silence ~25-30s into call
   - Both clients affected simultaneously

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:52:14 +00:00
Siavash Sameni
c268ce419a fix: relay dialog overflow — stack inputs, full-width Add button
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
- Dialog fits within 360px window (was overflowing at 420px)
- Add inputs stacked: name + host:port in a row, "Add Relay" button below
- Text overflow with ellipsis on relay names and addresses
- Proper min-width: 0 on flex children to prevent overflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:49:26 +04:00
Siavash Sameni
61b6e67610 feat: relay server dropdown with status indicators and manage dialog
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m38s
- Relay selector as dropdown with green/yellow/red status dots
  (green < 200ms, yellow > 200ms, red = offline, gray = unknown)
- All relays pinged on startup, RTT shown next to each
- "Manage Relays..." dialog: add/remove servers, see live status
- Clicking a relay in dropdown selects it, fills connect form
- Recent room chips auto-select matching relay
- Migrates old single-relay settings format automatically
- Prevents connecting to offline relays

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:44:19 +04:00
Siavash Sameni
dddf5d2e2d feat: relay ping with RTT display, fix dead_code warning
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
- New ping_relay Tauri command: QUIC connect with 3s timeout, returns RTT ms
- Relay status shown next to input field: "42ms" (green) or "offline" (red)
- Auto-pings on app startup and debounced on relay input change
- Fix SyncWrapper dead_code warning with #[allow(dead_code)]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:41:28 +04:00
Siavash Sameni
ed272d29f8 feat: fingerprint at startup, relay+room pairs, auto-reconnect, cleanup
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s
#7 Fingerprint shown before connecting — new get_identity command reads
   ~/.wzp/identity at startup (generates if missing). Click to copy.

#8 Recent rooms store (relay, room) pairs — clicking a chip fills both
   fields. Settings panel shows relay alongside room name. Migrates
   old string[] format automatically.

#9 Auto-reconnect on unexpected disconnect — exponential backoff
   (1s, 2s, 4s... max 10s), up to 5 attempts. Yellow blinking dot
   shows reconnecting state. Stops if user clicks hangup.

#10 Audio handle cleanup — CPAL handles stored in SyncWrapper (no more
    mem::forget), dropped properly on CallEngine::stop().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:15:05 +04:00
Claude
2b3bdae440 fix: enable Rust tracing → Android logcat via tracing-android
Rust tracing subscriber was never initialized — all info!/warn!/error!
calls in the engine went to /dev/null. This meant our send/recv health
logging was invisible and we couldn't confirm the congestion fix was
active.

Now initializes tracing-android layer on first nativeInit(), routing
all Rust logs to logcat under tag "wzp_android". Also expanded logcat
filter in DebugReporter to capture engine-level log lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:03:28 +00:00
Siavash Sameni
21f5b24cbf fix: keep audio handles alive for call duration, fix Send+Sync
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m39s
The VPIO/CPAL audio handles were dropped at the end of start(),
killing the audio unit immediately. Audio I/O stopped working
after the first frame.

- Store audio handle in CallEngine via SyncWrapper
- Drop MutexGuard before returning from status() (Send future)
- Audio streams now live for the entire call duration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:00:16 +04:00
Siavash Sameni
9b733010ab fix: blocking_lock panic in status(), fingerprint copy-to-clipboard
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m13s
- Change status() from blocking_lock to async lock().await —
  fixes "Cannot block the current thread from within a runtime" panic
  that froze the call timer and broke audio
- Click fingerprint to copy to clipboard (both connect and settings screens)
- Show "Copied!" feedback on click

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:53:31 +04:00
Siavash Sameni
80d5bd7628 fix: survive QUIC congestion — drop packets instead of killing send task
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m14s
send_datagram() returns Err(Blocked) when the QUIC congestion window
is full. This is transient — the window reopens once ACKs arrive.
Previously, all send paths treated this as fatal (break/return),
which killed the send task and cascaded via tokio::select! to kill
the entire call.

Now: log warning, drop the packet, continue. Brief audio glitch
(20-100ms) instead of complete call death. FEC on the receiver
side recovers most dropped packets.

Fixed in:
- CLI run_live send task (continue + error counter)
- CLI run_file_mode send paths (2 locations)
- Desktop engine send task

Also hardened recv tasks: transient errors (non-closed/reset)
are survived instead of causing exit.

Matches the fix applied to Android client (engine.rs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:48:20 +04:00
Siavash Sameni
4a195a923a feat: settings panel with Cmd+, shortcut (macOS standard)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m48s
- Full settings page as modal overlay (blur backdrop)
- Opens via gear icon on connect/call screens or Cmd+, (Ctrl+, on Win/Linux)
- Escape or click outside to close
- Settings: relay, room, alias, OS AEC toggle, AGC toggle
- Identity section showing fingerprint and identity file path
- Recent rooms management (remove individual, clear all)
- Save syncs back to connect form
- Gear icon on both connect and in-call screens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:44:22 +04:00
Siavash Sameni
f726f8cfa4 feat: desktop GUI enhancements — audio level, call timer, VPIO, settings
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m47s
- Audio level meter with log-scale RMS visualization
- Call duration timer
- VPIO (OS AEC) wired through to engine with fallback to CPAL
- "You" badge on own participant entry
- Recent rooms list (click to reuse)
- Enter key to connect from form fields
- Improved dark theme with pulse animation on status dot
- Settings persistence via localStorage (relay, room, alias, AEC, recent rooms)
- Fingerprint display on connect screen
- Keyboard shortcuts skip input fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:40:07 +04:00
Claude
20922455bd fix: send task crash on QUIC congestion + AEC toggle + debug reporter
Root cause: send_media() returns Err(Blocked) when QUIC congestion
window is full. The send task treated ANY send error as fatal (break),
killing the entire call. Now send errors drop the packet and continue.

Also hardened recv task to survive transient errors and added health
logging (recv gap tracking, periodic stats) to both send and recv.

Relay: added comprehensive debug logging — recv gaps, lock contention,
forward latency, send errors — all per-participant with 5s stats.

Other changes:
- AEC toggle in Settings (persisted, applied on next call)
- Debug report: records call audio (WAV), RMS histogram (CSV), logcat,
  stats. Emailed as zip via Android share intent after call ends.
- Replaced LinearProgressIndicator with Box (compose version compat)
- FileProvider for sharing debug zip attachments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 07:38:56 +00:00
Siavash Sameni
e468454464 feat: Tauri desktop GUI app with call engine
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m27s
- New desktop/ directory with Tauri v2 + Vite + TypeScript
- Rust backend: CallEngine wrapping wzp-client audio + transport
- Web frontend: connect screen, in-call screen with participants,
  mic/speaker mute, keyboard shortcuts (m/s/q)
- Dark theme UI, settings persistence via localStorage
- Platform-aware --os-aec: warns on Windows/Linux (not yet implemented)
- Workspace updated to include desktop/src-tauri

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:25:54 +04:00
Siavash Sameni
d1c96cd71f feat: macOS VoiceProcessingIO for hardware AEC + delay-compensated NLMS
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m33s
- Add --os-aec flag: uses Apple VoiceProcessingIO audio unit for
  hardware echo cancellation (same engine as FaceTime)
- New vpio feature + audio_vpio.rs: combined capture+playback via VPIO
- Improved software AEC: delay-compensated leaky NLMS with Geigel DTD
  (60ms tail, 40ms delay, configurable via --aec-delay)
- Add --aec-delay flag for tuning software AEC delay compensation
- Add dev-fast Cargo profile (opt-level 2 with incremental compilation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:10:10 +04:00
Siavash Sameni
1b00b5e2a4 feat: improved AEC, keyboard shortcuts, dedup participants, dev-fast profile
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m40s
AEC improvements:
- Reduce echo tail from 100ms to 30ms (3.3x faster, suited for laptops)
- Add double-talk detection: freeze adaptation when near-end speaks
- Add residual echo suppression
- Disable AEC by default in --android mode (macOS has built-in AEC)

CLI features:
- Keyboard shortcuts: m=mic mute, s=speaker mute, q=quit (raw terminal mode)
- Dedup participants in RoomUpdate display (same fingerprint+alias shown once)
- Add dev-fast profile (opt-level 2 with incremental compilation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:15:23 +04:00
Claude
e6564bab57 fix: mic mute crackling + add AEC/NoiseSuppressor + dedup room participants
Mic mute: the send loop now zeros the capture buffer when muted instead
of relying on write_audio() to skip writes. Previously stale ring data
and AGC amplification of near-silence caused crackling artifacts.

AEC: attach Android's hardware AcousticEchoCanceler to the AudioRecord
session. Also attach NoiseSuppressor when available. Both are released
on capture stop.

Room UI: deduplicate participants by fingerprint so ghost entries from
stale relay state don't show duplicate names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:06:35 +00:00
Siavash Sameni
cfb48df1ef feat: direct playout mode, AEC far-end, audio processing switches
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m28s
- Add --android/--direct-playout: bypass jitter buffer, decode on recv
  (matches Android engine architecture)
- Wire AEC far-end reference from decoded playout to encoder
- Add --no-aec, --no-agc, --no-fec, --no-silence, --no-denoise switches
- Fix BufferSize::Fixed(960) → Default for macOS CoreAudio compat
- Optimize wzp-codec, wzp-fec, audiopus, nnnoiseless in debug profile
- Add capture callback size diagnostic logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:48:34 +04:00
Claude
aebf9156c0 fix: dedup participants in UI, wait for QUIC close ack before exiting
UI: deduplicate room participants by fingerprint so ghost entries from
stale relay state don't show duplicates.

Engine: after select! ends, call close_now() + connection.closed() with
500ms timeout to wait for the relay to acknowledge the CONNECTION_CLOSE.
Previously the close frame was queued but the runtime died before quinn
could retransmit if the first packet was lost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:40:06 +00:00
Claude
9bbaec6b35 fix: use shutdown_timeout so QUIC CONNECTION_CLOSE actually gets sent
shutdown_background() killed the tokio runtime before quinn could send the
CONNECTION_CLOSE frame on the wire, so the relay never knew the client left.
Now use shutdown_timeout(500ms) to give quinn time to flush the close frame,
matching the desktop client pattern (which uses 2s timeout).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:20:20 +00:00
Siavash Sameni
ba29d8354f fix: send alias via CallOffer handshake (match Android approach)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:10:07 +04:00
Siavash Sameni
0908507a7a Merge remote-tracking branch 'origin/feat/android-voip-client' into feat/desktop-audio-rewrite 2026-04-06 09:04:55 +04:00
Siavash Sameni
860c90394d feat: rewrite desktop audio I/O with lock-free ring buffers
- Replace Mutex-based CPAL callbacks with atomic SPSC ring buffers
- Proper async send/recv loops (no block_on), 20ms playout tick
- Add signal task for RoomUpdate presence display
- Add --alias, --raw-room flags and key persistence (~/.wzp/identity)
- Add SetAlias signal variant and relay-side handling
- Graceful Ctrl+C shutdown with force-quit on second press

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:04:51 +04:00
Claude
dc66b60d18 fix: null alias display — Android JSONObject.optString returns literal "null"
o.optString("alias", null) returns the string "null" when the JSON value
is JSON null. Use o.isNull() check first. Also handle empty fingerprint
edge case with "unknown" fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:04:47 +00:00
Claude
a9c4260b4e fix: close QUIC connection on hangup so relay removes participant immediately
stop_call() now calls close_now() on the stored transport handle before
killing the tokio runtime. This sends a QUIC CONNECTION_CLOSE frame so
the relay's recv loop breaks immediately, triggering leave() + RoomUpdate
broadcast. Previously the runtime was killed first, so transport.close()
never ran and the relay kept stale participants until idle timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 04:58:24 +00:00
Claude
7eb136fcb3 fix: settings save button (back=discard), fix missing alias in featherchat tests
- Settings now uses draft state — changes only persist on explicit Save
- Back button discards unsaved changes
- Added applyServers() for batch server updates
- Added missing alias field to CallOffer in featherchat tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 04:30:23 +00:00
Claude
550a124972 fix: add missing alias arg to perform_handshake call in wzp-web
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 04:15:24 +00:00
59 changed files with 15152 additions and 508 deletions

View File

@@ -7,6 +7,8 @@ on:
- 'feat/*'
tags:
- 'v*'
paths-ignore:
- '.gitea/**'
workflow_dispatch:
env:

View 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

3302
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ members = [
"crates/wzp-client",
"crates/wzp-web",
"crates/wzp-android",
"desktop/src-tauri",
]
[workspace.package]
@@ -53,3 +54,24 @@ wzp-fec = { path = "crates/wzp-fec" }
wzp-crypto = { path = "crates/wzp-crypto" }
wzp-transport = { path = "crates/wzp-transport" }
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

View File

@@ -29,5 +29,15 @@
android:name="com.wzp.service.CallService"
android:foregroundServiceType="microphone"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -8,10 +8,19 @@ import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import androidx.core.content.ContextCompat
import com.wzp.engine.WzpEngine
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.pow
import kotlin.math.sqrt
/**
* Audio pipeline that captures mic audio and plays received audio using
@@ -43,9 +52,17 @@ class AudioPipeline(private val context: Context) {
/** Capture (mic) gain in dB. 0 = unity. */
@Volatile
var captureGainDb: Float = 0f
/** Whether to attach hardware AEC. Must be set before start(). */
var aecEnabled: Boolean = true
/** Enable debug recording of PCM + RMS histogram to cache dir. */
var debugRecording: Boolean = true
private var captureThread: Thread? = null
private var playoutThread: Thread? = null
private val debugDir: File by lazy {
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
}
fun start(engine: WzpEngine) {
if (running) return
running = true
@@ -89,6 +106,15 @@ class AudioPipeline(private val context: Context) {
}
}
private fun computeRms(pcm: ShortArray, count: Int): Int {
var sumSq = 0.0
for (i in 0 until count) {
val s = pcm[i].toDouble()
sumSq += s * s
}
return sqrt(sumSq / count).toInt()
}
private fun parkThread() {
try {
Thread.sleep(Long.MAX_VALUE)
@@ -127,25 +153,86 @@ class AudioPipeline(private val context: Context) {
return
}
// Attach hardware AEC if available and enabled in settings
var aec: AcousticEchoCanceler? = null
var ns: NoiseSuppressor? = null
if (aecEnabled) {
if (AcousticEchoCanceler.isAvailable()) {
try {
aec = AcousticEchoCanceler.create(recorder.audioSessionId)
aec?.enabled = true
Log.i(TAG, "AEC enabled (session=${recorder.audioSessionId})")
} catch (e: Exception) {
Log.w(TAG, "AEC init failed: ${e.message}")
}
} else {
Log.w(TAG, "AEC not available on this device")
}
// Attach hardware noise suppressor if available
if (NoiseSuppressor.isAvailable()) {
try {
ns = NoiseSuppressor.create(recorder.audioSessionId)
ns?.enabled = true
Log.i(TAG, "NoiseSuppressor enabled")
} catch (e: Exception) {
Log.w(TAG, "NoiseSuppressor init failed: ${e.message}")
}
}
} else {
Log.i(TAG, "AEC disabled by user setting")
}
recorder.startRecording()
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
Log.i(TAG, "capture started: ${SAMPLE_RATE}Hz mono, buf=$bufSize, aec=${aec?.enabled}, ns=${ns?.enabled}")
val pcm = ShortArray(FRAME_SAMPLES)
// Debug: PCM file + RMS CSV
var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
var frameIdx = 0L
if (debugRecording) {
try {
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "capture.pcm")), 65536)
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "capture_rms.csv")))
rmsCsv.write("frame,time_ms,rms\n")
} catch (e: Exception) {
Log.w(TAG, "debug recording init failed: ${e.message}")
}
}
try {
while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
applyGain(pcm, read, captureGainDb)
engine.writeAudio(pcm)
// Debug: write raw PCM + RMS
if (pcmOut != null) {
byteConv.clear()
for (i in 0 until read) byteConv.putShort(pcm[i])
pcmOut.write(byteConv.array(), 0, read * 2)
}
if (rmsCsv != null) {
val rms = computeRms(pcm, read)
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
}
frameIdx++
} else if (read < 0) {
Log.e(TAG, "AudioRecord.read error: $read")
break
}
}
} finally {
pcmOut?.close()
rmsCsv?.close()
recorder.stop()
aec?.release()
ns?.release()
recorder.release()
Log.i(TAG, "capture stopped")
Log.i(TAG, "capture stopped (frames=$frameIdx)")
}
}
@@ -181,24 +268,57 @@ class AudioPipeline(private val context: Context) {
Log.i(TAG, "playout started: ${SAMPLE_RATE}Hz mono, buf=$bufSize")
val pcm = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES) // pre-allocated silence
val silence = ShortArray(FRAME_SAMPLES)
// Debug: PCM file + RMS CSV for playout
var pcmOut: BufferedOutputStream? = null
var rmsCsv: OutputStreamWriter? = null
val byteConv = ByteBuffer.allocate(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
var frameIdx = 0L
if (debugRecording) {
try {
pcmOut = BufferedOutputStream(FileOutputStream(File(debugDir, "playout.pcm")), 65536)
rmsCsv = OutputStreamWriter(FileOutputStream(File(debugDir, "playout_rms.csv")))
rmsCsv.write("frame,time_ms,rms\n")
} catch (e: Exception) {
Log.w(TAG, "debug playout recording init failed: ${e.message}")
}
}
try {
while (running) {
val read = engine.readAudio(pcm)
if (read >= FRAME_SAMPLES) {
applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read)
// Debug: write raw PCM + RMS
if (pcmOut != null) {
byteConv.clear()
for (i in 0 until read) byteConv.putShort(pcm[i])
pcmOut.write(byteConv.array(), 0, read * 2)
}
if (rmsCsv != null) {
val rms = computeRms(pcm, read)
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,$rms\n")
}
frameIdx++
} else {
// Not enough decoded audio — write silence to keep stream alive
track.write(silence, 0, FRAME_SAMPLES)
// Sleep briefly to avoid busy-spinning
// Log silence frames to RMS as 0
if (rmsCsv != null) {
val timeMs = frameIdx * FRAME_SAMPLES * 1000L / SAMPLE_RATE
rmsCsv.write("$frameIdx,$timeMs,0\n")
}
frameIdx++
Thread.sleep(5)
}
}
} finally {
pcmOut?.close()
rmsCsv?.close()
track.stop()
track.release()
Log.i(TAG, "playout stopped")
Log.i(TAG, "playout stopped (frames=$frameIdx)")
}
}
}

View File

@@ -27,6 +27,7 @@ class SettingsRepository(context: Context) {
private const val KEY_CAPTURE_GAIN = "capture_gain_db"
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
private const val KEY_AEC_ENABLED = "aec_enabled"
}
// --- Servers ---
@@ -112,6 +113,11 @@ class SettingsRepository(context: Context) {
fun savePreferIPv6(prefer: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, prefer).apply() }
fun loadPreferIPv6(): Boolean = prefs.getBoolean(KEY_PREFER_IPV6, false)
// --- AEC ---
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
// --- Identity seed ---
/**

View File

@@ -0,0 +1,198 @@
package com.wzp.debug
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Collects call debug data (audio recordings, logs, histograms, stats)
* into a zip file for email sharing.
*/
class DebugReporter(private val context: Context) {
companion object {
private const val TAG = "DebugReporter"
private const val SAMPLE_RATE = 48000
}
/**
* Build a zip with all debug data.
* Returns the zip File on success, or null on failure.
*/
suspend fun collectZip(
callDurationSecs: Double,
finalStatsJson: String,
aecEnabled: Boolean,
alias: String,
server: String,
room: String
): File? = withContext(Dispatchers.IO) {
try {
val debugDir = File(context.cacheDir, "wzp_debug")
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
// 1. Call metadata
val meta = buildString {
appendLine("=== WZ Phone Debug Report ===")
appendLine("Timestamp: $timestamp")
appendLine("Alias: $alias")
appendLine("Server: $server")
appendLine("Room: $room")
appendLine("Duration: ${"%.1f".format(callDurationSecs)}s")
appendLine("AEC: ${if (aecEnabled) "ON" else "OFF"}")
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
appendLine()
appendLine("=== Final Stats ===")
appendLine(finalStatsJson)
}
addTextEntry(zos, "meta.txt", meta)
// 2. Logcat — WZP-related tags
val logcat = collectLogcat()
addTextEntry(zos, "logcat.txt", logcat)
// 3. Capture audio (mic) → WAV
val captureRaw = File(debugDir, "capture.pcm")
if (captureRaw.exists() && captureRaw.length() > 0) {
addWavEntry(zos, "capture.wav", captureRaw)
Log.i(TAG, "capture.pcm: ${captureRaw.length()} bytes -> WAV")
}
// 4. Playout audio (speaker) → WAV
val playoutRaw = File(debugDir, "playout.pcm")
if (playoutRaw.exists() && playoutRaw.length() > 0) {
addWavEntry(zos, "playout.wav", playoutRaw)
Log.i(TAG, "playout.pcm: ${playoutRaw.length()} bytes -> WAV")
}
// 5. RMS histogram CSV
val captureHist = File(debugDir, "capture_rms.csv")
if (captureHist.exists()) addFileEntry(zos, "capture_rms.csv", captureHist)
val playoutHist = File(debugDir, "playout_rms.csv")
if (playoutHist.exists()) addFileEntry(zos, "playout_rms.csv", playoutHist)
}
Log.i(TAG, "zip created: ${zipFile.length()} bytes (${zipFile.length() / 1024}KB)")
// Clean up raw debug files (keep zip)
debugDir.listFiles()?.forEach { it.delete() }
zipFile
} catch (e: Exception) {
Log.e(TAG, "debug report failed", e)
null
}
}
/** Clean up any leftover debug files from a previous session. */
fun prepareForCall() {
val debugDir = File(context.cacheDir, "wzp_debug")
if (debugDir.exists()) {
debugDir.listFiles()?.forEach { it.delete() }
}
debugDir.mkdirs()
// Also clean up old zip files
context.cacheDir.listFiles()?.filter { it.name.startsWith("wzp_debug_") }?.forEach { it.delete() }
}
private fun collectLogcat(): String {
return try {
val process = Runtime.getRuntime().exec(
arrayOf(
"logcat", "-d",
"-t", "5000",
"--format", "threadtime"
)
)
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
output.lines()
.filter { line ->
line.contains("wzp", ignoreCase = true) ||
line.contains("WzpEngine") ||
line.contains("AudioPipeline") ||
line.contains("WzpCall") ||
line.contains("CallService") ||
line.contains("AudioTrack") ||
line.contains("AudioRecord") ||
line.contains("AcousticEchoCanceler") ||
line.contains("NoiseSuppressor") ||
line.contains("FATAL") ||
line.contains("ANR") ||
line.contains("AudioFlinger") ||
line.contains("DebugReporter") ||
line.contains("QUIC") ||
line.contains("quinn") ||
line.contains("send task") ||
line.contains("recv task") ||
line.contains("send stats") ||
line.contains("recv stats") ||
line.contains("send_media") ||
line.contains("FEC block") ||
line.contains("recv gap") ||
line.contains("frames_dropped") ||
line.contains("opus")
}
.joinToString("\n")
} catch (e: Exception) {
"Failed to collect logcat: ${e.message}"
}
}
private fun addWavEntry(zos: ZipOutputStream, name: String, pcmFile: File) {
val dataSize = pcmFile.length().toInt()
val byteRate = SAMPLE_RATE * 1 * 16 / 8
val blockAlign = 1 * 16 / 8
zos.putNextEntry(ZipEntry(name))
// Write WAV header (44 bytes)
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
header.put("RIFF".toByteArray())
header.putInt(36 + dataSize)
header.put("WAVE".toByteArray())
header.put("fmt ".toByteArray())
header.putInt(16)
header.putShort(1) // PCM
header.putShort(1) // mono
header.putInt(SAMPLE_RATE)
header.putInt(byteRate)
header.putShort(blockAlign.toShort())
header.putShort(16) // bits per sample
header.put("data".toByteArray())
header.putInt(dataSize)
zos.write(header.array())
// Stream PCM data directly (avoids loading entire file into memory)
FileInputStream(pcmFile).use { it.copyTo(zos) }
zos.closeEntry()
}
private fun addTextEntry(zos: ZipOutputStream, name: String, content: String) {
zos.putNextEntry(ZipEntry(name))
zos.write(content.toByteArray())
zos.closeEntry()
}
private fun addFileEntry(zos: ZipOutputStream, name: String, file: File) {
zos.putNextEntry(ZipEntry(name))
FileInputStream(file).use { it.copyTo(zos) }
zos.closeEntry()
}
}

View File

@@ -54,7 +54,7 @@ data class CallStats(
val o = arr.getJSONObject(i)
RoomMember(
fingerprint = o.optString("fingerprint", ""),
alias = o.optString("alias", null)
alias = if (o.isNull("alias")) null else o.optString("alias", null)
)
}
}
@@ -92,5 +92,6 @@ data class RoomMember(
) {
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
val displayName: String
get() = alias ?: fingerprint.take(8)
get() = alias?.takeIf { it.isNotBlank() }
?: fingerprint.take(8).ifEmpty { "unknown" }
}

View File

@@ -1,8 +1,10 @@
package com.wzp.ui.call
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -21,7 +23,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.wzp.ui.settings.SettingsScreen
import kotlinx.coroutines.launch
/**
* Main activity hosting the in-call Compose UI.
@@ -31,6 +38,10 @@ import com.wzp.ui.settings.SettingsScreen
*/
class CallActivity : ComponentActivity() {
companion object {
private const val TAG = "CallActivity"
}
private val viewModel: CallViewModel by viewModels()
private val audioPermissionLauncher = registerForActivityResult(
@@ -69,6 +80,45 @@ class CallActivity : ComponentActivity() {
) {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
// Watch for debug zip ready → launch email intent
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.debugZipReady.collect { zipFile ->
if (zipFile != null && zipFile.exists()) {
Log.i(TAG, "debug zip ready: ${zipFile.absolutePath} (${zipFile.length()} bytes)")
launchEmailIntent(zipFile)
viewModel.onDebugReportSent()
}
}
}
}
}
private fun launchEmailIntent(zipFile: java.io.File) {
try {
val authority = "${applicationContext.packageName}.fileprovider"
Log.i(TAG, "FileProvider authority: $authority, file: ${zipFile.absolutePath}")
val uri = FileProvider.getUriForFile(this, authority, zipFile)
Log.i(TAG, "FileProvider URI: $uri")
val intent = Intent(Intent.ACTION_SEND).apply {
type = "message/rfc822"
putExtra(Intent.EXTRA_EMAIL, arrayOf("manwefarm@gmail.com"))
putExtra(Intent.EXTRA_SUBJECT, "WZ Phone Debug Report - ${zipFile.name}")
putExtra(
Intent.EXTRA_TEXT,
"Debug report attached.\n\nContains: call recordings (WAV), RMS histograms (CSV), logcat, stats."
)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, "Send debug report"))
Log.i(TAG, "email intent launched")
} catch (e: Exception) {
Log.e(TAG, "email intent failed", e)
Toast.makeText(this, "Failed to launch email: ${e.message}", Toast.LENGTH_LONG).show()
}
}
override fun onDestroy() {

View File

@@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRouteManager
import com.wzp.data.SettingsRepository
import com.wzp.debug.DebugReporter
import com.wzp.engine.CallStats
import com.wzp.service.CallService
import com.wzp.engine.WzpCallback
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
@@ -33,6 +35,10 @@ class CallViewModel : ViewModel(), WzpCallback {
private var audioStarted = false
private var appContext: Context? = null
private var settings: SettingsRepository? = null
private var debugReporter: DebugReporter? = null
private var lastStatsJson: String = "{}"
private var lastCallDuration: Double = 0.0
private var lastCallServer: String = ""
private val _callState = MutableStateFlow(0)
val callState: StateFlow<Int> get() = _callState.asStateFlow()
@@ -76,6 +82,21 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _seedHex = MutableStateFlow("")
val seedHex: StateFlow<String> = _seedHex.asStateFlow()
private val _aecEnabled = MutableStateFlow(true)
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
/** True when a call just ended and debug report can be sent. */
private val _debugReportAvailable = MutableStateFlow(false)
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
/** Status: null=idle, "Preparing..."=in progress, "ready"=zip ready, "Error:..."=failed */
private val _debugReportStatus = MutableStateFlow<String?>(null)
val debugReportStatus: StateFlow<String?> = _debugReportStatus.asStateFlow()
/** The zip file ready to be emailed. Set by sendDebugReport, consumed by Activity. */
private val _debugZipReady = MutableStateFlow<File?>(null)
val debugZipReady: StateFlow<File?> = _debugZipReady.asStateFlow()
private var statsJob: Job? = null
companion object {
@@ -96,6 +117,9 @@ class CallViewModel : ViewModel(), WzpCallback {
if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx)
}
if (debugReporter == null) {
debugReporter = DebugReporter(appCtx)
}
if (settings == null) {
settings = SettingsRepository(appCtx)
loadSettings()
@@ -114,6 +138,7 @@ class CallViewModel : ViewModel(), WzpCallback {
_playoutGainDb.value = s.loadPlayoutGain()
_captureGainDb.value = s.loadCaptureGain()
_seedHex.value = s.getOrCreateSeedHex()
_aecEnabled.value = s.loadAecEnabled()
}
fun selectServer(index: Int) {
@@ -149,6 +174,14 @@ class CallViewModel : ViewModel(), WzpCallback {
}
}
/** Batch-apply servers and selection from Settings draft state. */
fun applyServers(servers: List<ServerEntry>, selected: Int) {
_servers.value = servers
_selectedServer.value = selected.coerceIn(0, servers.lastIndex)
settings?.saveServers(servers)
settings?.saveSelectedServer(_selectedServer.value)
}
fun setRoomName(name: String) {
_roomName.value = name
settings?.saveRoom(name)
@@ -176,6 +209,11 @@ class CallViewModel : ViewModel(), WzpCallback {
settings?.saveSeedHex(hex)
}
fun setAecEnabled(enabled: Boolean) {
_aecEnabled.value = enabled
settings?.saveAecEnabled(enabled)
}
/**
* Resolve DNS hostname to IP address on the Kotlin/Android side,
* since Rust's DNS resolution may not work on Android.
@@ -214,6 +252,7 @@ class CallViewModel : ViewModel(), WzpCallback {
/** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */
private fun teardown(stopService: Boolean = true) {
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
val hadCall = audioStarted
CallService.onStopFromNotification = null
stopAudio()
stopStatsPolling()
@@ -223,6 +262,9 @@ class CallViewModel : ViewModel(), WzpCallback {
engine = null
engineInitialized = false
_callState.value = 0
if (hadCall) {
_debugReportAvailable.value = true
}
if (stopService) {
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
}
@@ -233,6 +275,10 @@ class CallViewModel : ViewModel(), WzpCallback {
val serverEntry = _servers.value[_selectedServer.value]
val room = _roomName.value
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
_debugReportAvailable.value = false
_debugReportStatus.value = null
lastCallServer = serverEntry.address
debugReporter?.prepareForCall()
try {
// Teardown previous call but don't stop the service (we're about to restart it)
teardown(stopService = false)
@@ -297,6 +343,40 @@ class CallViewModel : ViewModel(), WzpCallback {
fun clearError() { _errorMessage.value = null }
fun sendDebugReport() {
val reporter = debugReporter ?: return
_debugReportStatus.value = "Preparing debug report..."
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val zipFile = reporter.collectZip(
callDurationSecs = lastCallDuration,
finalStatsJson = lastStatsJson,
aecEnabled = _aecEnabled.value,
alias = _alias.value,
server = lastCallServer,
room = _roomName.value
)
if (zipFile != null) {
_debugZipReady.value = zipFile
_debugReportStatus.value = "ready"
} else {
_debugReportStatus.value = "Error: failed to create zip"
}
_debugReportAvailable.value = false
}
}
/** Called by Activity after email intent is launched. */
fun onDebugReportSent() {
_debugZipReady.value = null
_debugReportStatus.value = null
}
fun dismissDebugReport() {
_debugReportAvailable.value = false
_debugReportStatus.value = null
_debugZipReady.value = null
}
// WzpCallback
override fun onCallStateChanged(state: Int) { _callState.value = state }
override fun onQualityTierChanged(tier: Int) { _qualityTier.value = tier }
@@ -310,6 +390,7 @@ class CallViewModel : ViewModel(), WzpCallback {
audioPipeline = AudioPipeline(ctx).also {
it.playoutGainDb = _playoutGainDb.value
it.captureGainDb = _captureGainDb.value
it.aecEnabled = _aecEnabled.value
it.start(e)
}
audioRouteManager?.register()
@@ -334,7 +415,9 @@ class CallViewModel : ViewModel(), WzpCallback {
val json = engine?.getStats() ?: "{}"
if (json.isNotEmpty()) {
Log.d(TAG, "raw: $json")
lastStatsJson = json
val s = CallStats.fromJson(json)
lastCallDuration = s.durationSecs
_stats.value = s
if (s.state != 0) {
_callState.value = s.state

View File

@@ -24,7 +24,6 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -69,6 +68,8 @@ fun InCallScreen(
val preferIPv6 by viewModel.preferIPv6.collectAsState()
val playoutGainDb by viewModel.playoutGainDb.collectAsState()
val captureGainDb by viewModel.captureGainDb.collectAsState()
val debugReportAvailable by viewModel.debugReportAvailable.collectAsState()
val debugReportStatus by viewModel.debugReportStatus.collectAsState()
var showAddServerDialog by remember { mutableStateOf(false) }
@@ -228,6 +229,17 @@ fun InCallScreen(
color = MaterialTheme.colorScheme.error
)
}
// Debug report card — shown after call ends
if (debugReportAvailable || debugReportStatus != null) {
Spacer(modifier = Modifier.height(24.dp))
DebugReportCard(
available = debugReportAvailable,
status = debugReportStatus,
onSend = { viewModel.sendDebugReport() },
onDismiss = { viewModel.dismissDebugReport() }
)
}
} else {
// In-call UI
Spacer(modifier = Modifier.height(16.dp))
@@ -239,13 +251,17 @@ fun InCallScreen(
QualityIndicator(qualityTier, stats.qualityLabel)
if (stats.roomParticipantCount > 0) {
// Dedup by fingerprint — same key = same person, even if
// relay hasn't cleaned up stale entries yet.
val unique = stats.roomParticipants
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${stats.roomParticipantCount} in room",
text = "${unique.size} in room",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
stats.roomParticipants.forEach { member ->
unique.forEach { member ->
Text(
text = member.displayName,
style = MaterialTheme.typography.labelSmall,
@@ -438,15 +454,20 @@ private fun AudioLevelBar(audioLevel: Int) {
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = level,
Box(
modifier = Modifier
.fillMaxWidth(0.6f)
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Box(
modifier = Modifier
.fillMaxWidth(level)
.height(6.dp)
.background(MaterialTheme.colorScheme.primary)
)
}
}
}
@@ -598,3 +619,70 @@ private fun StatItem(label: String, value: String) {
)
}
}
@Composable
private fun DebugReportCard(
available: Boolean,
status: String?,
onSend: () -> Unit,
onDismiss: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Debug Report",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Email call recordings, logs & stats for analysis",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
when {
status != null && status.startsWith("Error") -> {
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onSend) { Text("Retry") }
TextButton(onClick = onDismiss) { Text("Dismiss") }
}
}
status != null && status != "ready" -> {
// Preparing zip...
Text(
text = status,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
available -> {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onSend) {
Text("Email Report")
}
TextButton(onClick = onDismiss) {
Text("Skip")
}
}
}
}
}
}
}

View File

@@ -21,9 +21,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Divider
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -36,9 +36,12 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -47,6 +50,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.wzp.ui.call.CallViewModel
import com.wzp.ui.call.ServerEntry
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -55,14 +59,39 @@ fun SettingsScreen(
onBack: () -> Unit
) {
val context = LocalContext.current
val servers by viewModel.servers.collectAsState()
val selectedServer by viewModel.selectedServer.collectAsState()
val roomName by viewModel.roomName.collectAsState()
val preferIPv6 by viewModel.preferIPv6.collectAsState()
val playoutGainDb by viewModel.playoutGainDb.collectAsState()
val captureGainDb by viewModel.captureGainDb.collectAsState()
val alias by viewModel.alias.collectAsState()
val seedHex by viewModel.seedHex.collectAsState()
// Snapshot current values into local draft state
val currentAlias by viewModel.alias.collectAsState()
val currentSeedHex by viewModel.seedHex.collectAsState()
val currentServers by viewModel.servers.collectAsState()
val currentSelectedServer by viewModel.selectedServer.collectAsState()
val currentRoomName by viewModel.roomName.collectAsState()
val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
val currentAecEnabled by viewModel.aecEnabled.collectAsState()
// Draft state — initialized from current values
var draftAlias by remember { mutableStateOf(currentAlias) }
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
val draftServers = remember { currentServers.toMutableStateList() }
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
var draftRoomName by remember { mutableStateOf(currentRoomName) }
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
var draftAecEnabled by remember { mutableStateOf(currentAecEnabled) }
// Track if anything changed
val hasChanges = draftAlias != currentAlias ||
draftSeedHex != currentSeedHex ||
draftServers.toList() != currentServers ||
draftSelectedServer != currentSelectedServer ||
draftRoomName != currentRoomName ||
draftPreferIPv6 != currentPreferIPv6 ||
draftPlayoutGain != currentPlayoutGain ||
draftCaptureGain != currentCaptureGain ||
draftAecEnabled != currentAecEnabled
var showAddServerDialog by remember { mutableStateOf(false) }
var showRestoreKeyDialog by remember { mutableStateOf(false) }
@@ -94,8 +123,24 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.weight(1f))
// Balance the back button
Spacer(modifier = Modifier.width(64.dp))
// Save button — only enabled when changes exist
Button(
onClick = {
viewModel.setAlias(draftAlias)
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
viewModel.setRoomName(draftRoomName)
viewModel.setPreferIPv6(draftPreferIPv6)
viewModel.setPlayoutGainDb(draftPlayoutGain)
viewModel.setCaptureGainDb(draftCaptureGain)
viewModel.setAecEnabled(draftAecEnabled)
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
onBack()
},
enabled = hasChanges
) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(24.dp))
@@ -104,8 +149,8 @@ fun SettingsScreen(
SectionHeader("Identity")
OutlinedTextField(
value = alias,
onValueChange = { viewModel.setAlias(it) },
value = draftAlias,
onValueChange = { draftAlias = it },
label = { Text("Display Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
@@ -114,7 +159,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp))
// Fingerprint display
val fingerprint = if (seedHex.length >= 16) seedHex.take(16).uppercase() else "Not generated"
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
Text(
text = "Fingerprint",
style = MaterialTheme.typography.labelSmall,
@@ -134,7 +179,7 @@ fun SettingsScreen(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", seedHex))
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
}) {
Text("Copy Key")
@@ -153,16 +198,39 @@ fun SettingsScreen(
GainSlider(
label = "Voice Volume",
gainDb = playoutGainDb,
onGainChange = { viewModel.setPlayoutGainDb(it) }
gainDb = draftPlayoutGain,
onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
)
Spacer(modifier = Modifier.height(4.dp))
GainSlider(
label = "Mic Gain",
gainDb = captureGainDb,
onGainChange = { viewModel.setCaptureGainDb(it) }
gainDb = draftCaptureGain,
onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Echo Cancellation (AEC)",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Disable if audio sounds distorted",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = draftAecEnabled,
onCheckedChange = { draftAecEnabled = it }
)
}
Spacer(modifier = Modifier.height(24.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
@@ -175,11 +243,11 @@ fun SettingsScreen(
horizontalArrangement = Arrangement.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
servers.forEachIndexed { idx, entry ->
val isSelected = selectedServer == idx
draftServers.forEachIndexed { idx, entry ->
val isSelected = draftSelectedServer == idx
Row(verticalAlignment = Alignment.CenterVertically) {
FilledTonalIconButton(
onClick = { viewModel.selectServer(idx) },
onClick = { draftSelectedServer = idx },
modifier = Modifier
.padding(end = 2.dp)
.height(36.dp)
@@ -203,7 +271,12 @@ fun SettingsScreen(
// Show remove button for non-default servers
if (idx >= 2) {
TextButton(
onClick = { viewModel.removeServer(idx) },
onClick = {
draftServers.removeAt(idx)
if (draftSelectedServer >= draftServers.size) {
draftSelectedServer = 0
}
},
modifier = Modifier.height(36.dp)
) {
Text("X", color = MaterialTheme.colorScheme.error)
@@ -224,7 +297,7 @@ fun SettingsScreen(
// Show selected server address
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Default: ${servers.getOrNull(selectedServer)?.address ?: "none"}",
text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -246,8 +319,8 @@ fun SettingsScreen(
modifier = Modifier.weight(1f)
)
Switch(
checked = preferIPv6,
onCheckedChange = { viewModel.setPreferIPv6(it) }
checked = draftPreferIPv6,
onCheckedChange = { draftPreferIPv6 = it }
)
}
@@ -259,8 +332,8 @@ fun SettingsScreen(
SectionHeader("Room")
OutlinedTextField(
value = roomName,
onValueChange = { viewModel.setRoomName(it) },
value = draftRoomName,
onValueChange = { draftRoomName = it },
label = { Text("Default Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
@@ -274,7 +347,7 @@ fun SettingsScreen(
AddServerDialog(
onDismiss = { showAddServerDialog = false },
onAdd = { host, port, label ->
viewModel.addServer("$host:$port", label)
draftServers.add(ServerEntry("$host:$port", label))
showAddServerDialog = false
}
)
@@ -284,9 +357,9 @@ fun SettingsScreen(
RestoreKeyDialog(
onDismiss = { showRestoreKeyDialog = false },
onRestore = { hex ->
viewModel.restoreSeed(hex)
draftSeedHex = hex
showRestoreKeyDialog = false
Toast.makeText(context, "Key restored", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
}
)
}
@@ -316,7 +389,7 @@ private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Un
)
Slider(
value = gainDb,
onValueChange = { onGainChange(Math.round(it).toFloat()) },
onValueChange = onGainChange,
valueRange = -20f..20f,
steps = 0,
modifier = Modifier.fillMaxWidth()

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="debug" path="." />
</paths>

View File

@@ -28,6 +28,7 @@ libc = "0.2"
jni = { version = "0.21", default-features = false }
rand = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
tracing-android = "0.2"
[build-dependencies]
cc = "1"

View File

@@ -67,6 +67,9 @@ pub(crate) struct EngineState {
pub playout_ring: AudioRing,
/// Current audio level (RMS) for UI display, updated by capture path.
pub audio_level_rms: AtomicU32,
/// QUIC transport handle — stored so stop_call() can close it immediately,
/// triggering relay-side leave + RoomUpdate broadcast.
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
}
pub struct WzpEngine {
@@ -87,6 +90,7 @@ impl WzpEngine {
capture_ring: AudioRing::new(),
playout_ring: AudioRing::new(),
audio_level_rms: AtomicU32::new(0),
quic_transport: Mutex::new(None),
});
Self {
state,
@@ -144,12 +148,25 @@ impl WzpEngine {
}
pub fn stop_call(&mut self) {
info!("stop_call: setting running=false");
self.state.running.store(false, Ordering::Release);
// Close QUIC connection — this wakes up all blocked recv/send futures
// inside block_on(run_call(...)) on the JNI thread. run_call will then
// wait up to 500ms for the peer to acknowledge the close before returning.
if let Some(transport) = self.state.quic_transport.lock().unwrap().take() {
info!("stop_call: closing QUIC connection");
transport.close_now();
}
let _ = self.state.command_tx.send(EngineCommand::Stop);
// Note: the runtime is still blocked in block_on(run_call(...)) on the
// start_call thread. Once run_call exits (triggered by running=false +
// connection close above), block_on returns and stores the runtime in
// self.tokio_runtime. We don't need to shut it down here.
if let Some(rt) = self.tokio_runtime.take() {
rt.shutdown_background();
rt.shutdown_timeout(std::time::Duration::from_millis(100));
}
self.call_start = None;
info!("stop_call: done");
}
pub fn set_mute(&self, muted: bool) {
@@ -223,6 +240,9 @@ async fn run_call(
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
// Store transport handle so stop_call() can close the connection immediately
*state.quic_transport.lock().unwrap() = Some(transport.clone());
// Crypto handshake
let mut kx = WarzoneKeyExchange::from_identity_seed(identity_seed);
let ephemeral_pub = kx.generate_ephemeral();
@@ -301,8 +321,18 @@ async fn run_call(
let mut block_id: u8 = 0;
// Send task: capture ring → Opus encode → FEC → MediaPackets
//
// IMPORTANT: send_media() uses quinn's send_datagram() which is
// synchronous and returns Err(Blocked) when the congestion window
// is full. We MUST NOT break on send errors — that would kill the
// entire call. Instead we drop the packet and keep going.
let send_task = async {
info!("send task started (Opus + RaptorQ FEC)");
let mut send_errors: u64 = 0;
let mut last_send_error_log = Instant::now();
let mut last_stats_log = Instant::now();
let mut frames_sent: u64 = 0;
let mut frames_dropped: u64 = 0;
loop {
if !state.running.load(Ordering::Relaxed) {
break;
@@ -319,6 +349,12 @@ async fn run_call(
continue;
}
// Mute: zero out the buffer so Opus encodes silence.
// We still read from the ring to prevent it from filling up.
if state.muted.load(Ordering::Relaxed) {
capture_buf.fill(0);
}
// AGC: normalize capture volume before encoding
capture_agc.process_frame(&mut capture_buf);
@@ -354,11 +390,24 @@ async fn run_call(
quality_report: None,
};
// Send source packet
// Send source packet — drop on error, never break
if let Err(e) = transport.send_media(&source_pkt).await {
error!("send error: {e}");
break;
send_errors += 1;
frames_dropped += 1;
// Log first few errors, then throttle to once per second
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
warn!(
seq = s,
send_errors,
frames_dropped,
"send_media error (dropping packet): {e}"
);
last_send_error_log = Instant::now();
}
// Don't feed to FEC either — the source is lost
continue;
}
frames_sent += 1;
// Feed encoded frame to FEC encoder
if let Err(e) = fec_enc.add_source_symbol(encoded) {
@@ -392,9 +441,11 @@ async fn run_call(
payload: Bytes::from(repair_data),
quality_report: None,
};
if let Err(e) = transport.send_media(&repair_pkt).await {
error!("send repair error: {e}");
break;
// Drop repair packets on error — never break
if let Err(_e) = transport.send_media(&repair_pkt).await {
send_errors += 1;
frames_dropped += 1;
// Don't log every repair failure — source error log covers it
}
}
if repair_count > 0 && (block_id % 50 == 0 || block_id == 0) {
@@ -416,10 +467,21 @@ async fn run_call(
frame_in_block = 0;
}
if s % 500 == 0 {
info!(seq = s, block_id, frame_in_block, "sending");
// Periodic stats every 5 seconds
if last_stats_log.elapsed().as_secs() >= 5 {
info!(
seq = s,
block_id,
frames_sent,
frames_dropped,
send_errors,
ring_avail = state.capture_ring.available(),
"send stats"
);
last_stats_log = Instant::now();
}
}
info!(frames_sent, frames_dropped, send_errors, "send task ended");
};
// Pre-allocate decode buffer
@@ -429,6 +491,10 @@ async fn run_call(
let recv_task = async {
let mut frames_decoded: u64 = 0;
let mut fec_recovered: u64 = 0;
let mut recv_errors: u64 = 0;
let mut last_recv_instant = Instant::now();
let mut max_recv_gap_ms: u64 = 0;
let mut last_stats_log = Instant::now();
info!("recv task started (Opus + RaptorQ FEC)");
loop {
if !state.running.load(Ordering::Relaxed) {
@@ -436,6 +502,21 @@ async fn run_call(
}
match transport_recv.recv_media().await {
Ok(Some(pkt)) => {
// Track recv gaps — large gaps indicate network or relay issues
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
last_recv_instant = Instant::now();
if recv_gap_ms > max_recv_gap_ms {
max_recv_gap_ms = recv_gap_ms;
}
if recv_gap_ms > 500 {
warn!(
recv_gap_ms,
seq = pkt.header.seq,
is_repair = pkt.header.is_repair,
"large recv gap — possible network stall"
);
}
let is_repair = pkt.header.is_repair;
let pkt_block = pkt.header.fec_block;
let pkt_symbol = pkt.header.fec_symbol;
@@ -452,7 +533,6 @@ async fn run_call(
if !is_repair {
match decoder.decode(&pkt.payload, &mut decode_buf) {
Ok(samples) => {
// AGC on playout — normalizes received audio volume
playout_agc.process_frame(&mut decode_buf[..samples]);
state.playout_ring.write(&decode_buf[..samples]);
frames_decoded += 1;
@@ -467,13 +547,8 @@ async fn run_call(
}
}
// Try FEC recovery for this block
// (useful when source packets were lost but repair arrived)
// Try FEC recovery
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
// FEC recovered the block — any previously missing frames
// are now available. In a full jitter buffer implementation,
// we'd insert recovered frames at the right position.
// For now, log recovery for telemetry.
fec_recovered += recovered_frames.len() as u64;
if fec_recovered % 50 == 1 {
info!(
@@ -490,24 +565,45 @@ async fn run_call(
fec_dec.expire_before(pkt_block.wrapping_sub(3));
}
if frames_decoded == 1 || frames_decoded % 500 == 0 {
info!(frames_decoded, fec_recovered, "recv stats");
}
let mut stats = state.stats.lock().unwrap();
stats.frames_decoded = frames_decoded;
stats.fec_recovered = fec_recovered;
drop(stats);
// Periodic stats every 5 seconds
if last_stats_log.elapsed().as_secs() >= 5 {
info!(
frames_decoded,
fec_recovered,
recv_errors,
max_recv_gap_ms,
playout_avail = state.playout_ring.available(),
"recv stats"
);
max_recv_gap_ms = 0;
last_stats_log = Instant::now();
}
}
Ok(None) => {
info!("relay disconnected");
info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)");
break;
}
Err(e) => {
error!("recv error: {e}");
break;
recv_errors += 1;
// Transient errors: log and keep going
let msg = e.to_string();
if msg.contains("closed") || msg.contains("reset") {
error!(recv_errors, "recv fatal: {e}");
break;
}
// Non-fatal: log throttled
if recv_errors <= 3 || recv_errors % 50 == 0 {
warn!(recv_errors, "recv error (continuing): {e}");
}
}
}
}
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended");
};
// Stats task — polls path quality + quinn RTT every 500ms
@@ -569,12 +665,22 @@ async fn run_call(
};
tokio::select! {
_ = send_task => {}
_ = recv_task => {}
_ = stats_task => {}
_ = signal_task => {}
_ = send_task => info!("send task ended"),
_ = recv_task => info!("recv task ended"),
_ = stats_task => info!("stats task ended"),
_ = signal_task => info!("signal task ended"),
}
transport.close().await.ok();
// Send CONNECTION_CLOSE and wait up to 500ms for the peer to acknowledge.
// This ensures the relay sees the close even if the first packet is lost.
info!("closing QUIC connection...");
transport.close_now();
match tokio::time::timeout(
std::time::Duration::from_millis(500),
transport.connection().closed(),
).await {
Ok(_) => info!("QUIC connection closed cleanly"),
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
}
Ok(())
}

View File

@@ -1,6 +1,7 @@
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
use std::panic;
use std::sync::Once;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring};
@@ -28,12 +29,27 @@ fn profile_from_int(value: jint) -> QualityProfile {
}
}
static INIT_LOGGING: Once = Once::new();
/// Initialize tracing → Android logcat (tag "wzp_android").
/// Safe to call multiple times — only the first call takes effect.
fn init_logging() {
INIT_LOGGING.call_once(|| {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
if let Ok(layer) = tracing_android::layer("wzp_android") {
let _ = tracing_subscriber::registry().with(layer).try_init();
}
});
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
_env: JNIEnv,
_class: JClass,
) -> jlong {
let result = panic::catch_unwind(|| {
init_logging();
let handle = Box::new(EngineHandle {
engine: WzpEngine::new(),
});

View File

@@ -23,10 +23,13 @@ serde_json = "1"
chrono = "0.4"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
cpal = { version = "0.15", optional = true }
coreaudio-rs = { version = "0.11", optional = true }
libc = "0.2"
[features]
default = []
audio = ["cpal"]
vpio = ["coreaudio-rs"]
[[bin]]
name = "wzp-client"

View File

@@ -3,12 +3,10 @@
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
//!
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS
//! thread that owns the stream. The public API exposes only `Send + Sync`
//! channel handles.
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use anyhow::{anyhow, Context};
@@ -16,6 +14,8 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SampleRate, StreamConfig};
use tracing::{info, warn};
use crate::audio_ring::AudioRing;
/// Number of samples per 20 ms frame at 48 kHz mono.
pub const FRAME_SAMPLES: usize = 960;
@@ -23,22 +23,24 @@ pub const FRAME_SAMPLES: usize = 960;
// 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`.
pub struct AudioCapture {
rx: mpsc::Receiver<Vec<i16>>,
ring: Arc<AudioRing>,
running: Arc<AtomicBool>,
}
impl AudioCapture {
/// Create and start capturing from the default input device at 48 kHz mono.
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_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()
.name("wzp-audio-capture".into())
@@ -59,53 +61,51 @@ impl AudioCapture {
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| {
warn!("input stream error: {e}");
};
let logged_cb_size = Arc::new(AtomicBool::new(false));
let stream = if use_f32 {
let buf = buf.clone();
let tx = tx.clone();
let ring = ring_cb.clone();
let running = running_clone.clone();
let logged = logged_cb_size.clone();
device.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
if !running.load(Ordering::Relaxed) {
return;
}
let mut lock = buf.lock().unwrap();
for &s in data {
lock.push(f32_to_i16(s));
if lock.len() == FRAME_SAMPLES {
let frame = lock.drain(..).collect();
let _ = tx.try_send(frame);
if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[audio] capture callback: {} f32 samples", data.len());
}
let mut tmp = [0i16; FRAME_SAMPLES];
for chunk in data.chunks(FRAME_SAMPLES) {
let n = chunk.len();
for i in 0..n {
tmp[i] = f32_to_i16(chunk[i]);
}
ring.write(&tmp[..n]);
}
},
err_cb,
None,
)?
} else {
let buf = buf.clone();
let tx = tx.clone();
let ring = ring_cb.clone();
let running = running_clone.clone();
let logged = logged_cb_size.clone();
device.build_input_stream(
&config,
move |data: &[i16], _: &cpal::InputCallbackInfo| {
if !running.load(Ordering::Relaxed) {
return;
}
let mut lock = buf.lock().unwrap();
for &s in data {
lock.push(s);
if lock.len() == FRAME_SAMPLES {
let frame = lock.drain(..).collect();
let _ = tx.try_send(frame);
}
if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[audio] capture callback: {} i16 samples", data.len());
}
ring.write(data);
},
err_cb,
None,
@@ -114,7 +114,6 @@ impl AudioCapture {
stream.play().context("failed to start input stream")?;
// Signal success to the caller before parking.
let _ = init_tx.send(Ok(()));
// Keep stream alive until stopped.
@@ -135,15 +134,12 @@ impl AudioCapture {
.map_err(|_| anyhow!("capture thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?;
Ok(Self { rx, running })
Ok(Self { ring, running })
}
/// Read the next frame of 960 PCM samples (blocking until available).
///
/// Returns `None` when the stream has been stopped or the channel is
/// disconnected.
pub fn read_frame(&self) -> Option<Vec<i16>> {
self.rx.recv().ok()
/// Get a reference to the capture ring buffer for direct polling.
pub fn ring(&self) -> &Arc<AudioRing> {
&self.ring
}
/// Stop capturing.
@@ -152,26 +148,34 @@ impl AudioCapture {
}
}
impl Drop for AudioCapture {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// 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`.
pub struct AudioPlayback {
tx: mpsc::SyncSender<Vec<i16>>,
ring: Arc<AudioRing>,
running: Arc<AtomicBool>,
}
impl AudioPlayback {
/// Create and start playback on the default output device at 48 kHz mono.
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_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()
.name("wzp-audio-playback".into())
@@ -192,62 +196,40 @@ impl AudioPlayback {
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| {
warn!("output stream error: {e}");
};
let stream = if use_f32 {
let ring = ring.clone();
let ring = ring_cb.clone();
device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let mut lock = ring.lock().unwrap();
for sample in data.iter_mut() {
*sample = match lock.pop_front() {
Some(s) => i16_to_f32(s),
None => 0.0,
};
let mut tmp = [0i16; FRAME_SAMPLES];
for chunk in data.chunks_mut(FRAME_SAMPLES) {
let n = chunk.len();
let read = ring.read(&mut tmp[..n]);
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,
None,
)?
} else {
let ring = ring.clone();
let ring = ring_cb.clone();
device.build_output_stream(
&config,
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
let mut lock = ring.lock().unwrap();
for sample in data.iter_mut() {
*sample = lock.pop_front().unwrap_or(0);
let read = ring.read(data);
// Fill remainder with silence if ring underran
for sample in &mut data[read..] {
*sample = 0;
}
},
err_cb,
@@ -257,7 +239,6 @@ impl AudioPlayback {
stream.play().context("failed to start output stream")?;
// Signal success to the caller before parking.
let _ = init_tx.send(Ok(()));
// Keep stream alive until stopped.
@@ -278,12 +259,12 @@ impl AudioPlayback {
.map_err(|_| anyhow!("playback thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?;
Ok(Self { tx, running })
Ok(Self { ring, running })
}
/// Write a frame of PCM samples for playback.
pub fn write_frame(&self, pcm: &[i16]) {
let _ = self.tx.try_send(pcm.to_vec());
/// Get a reference to the playout ring buffer for direct writing.
pub fn ring(&self) -> &Arc<AudioRing> {
&self.ring
}
/// Stop playback.
@@ -292,11 +273,16 @@ impl AudioPlayback {
}
}
impl Drop for AudioPlayback {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Check if the input device supports i16 at 48 kHz mono.
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
let supported = device
.supported_input_configs()
@@ -313,7 +299,6 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
Ok(false)
}
/// Check if the output device supports i16 at 48 kHz mono.
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
let supported = device
.supported_output_configs()

View 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)
}
}

View 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();
}
}

View File

@@ -42,6 +42,9 @@ pub struct CallConfig {
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
/// intermediate frames use a compact 4-byte MiniHeader.
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).
///
/// When true, the jitter buffer target depth is automatically adjusted
@@ -63,6 +66,7 @@ impl Default for CallConfig {
noise_suppression: true,
mini_frames_enabled: true,
adaptive_jitter: true,
aec_delay_ms: 40,
}
}
}
@@ -241,7 +245,7 @@ impl CallEncoder {
block_id: 0,
frame_in_block: 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(),
silence_detector: SilenceDetector::new(
config.silence_threshold_rms,
@@ -496,6 +500,49 @@ impl CallDecoder {
}
}
/// Switch the decoder to match an incoming packet's codec if it differs
/// from the current profile. This enables cross-codec interop (e.g. one
/// client sends Opus, the other sends Codec2).
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
return;
}
let new_profile = Self::profile_for_codec(incoming_codec);
info!(
from = ?self.profile.codec,
to = ?incoming_codec,
"decoder switching codec to match incoming packet"
);
if let Err(e) = self.audio_dec.set_profile(new_profile) {
warn!("failed to switch decoder profile: {e}");
return;
}
self.fec_dec = wzp_fec::create_decoder(&new_profile);
self.profile = new_profile;
}
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
fn profile_for_codec(codec: CodecId) -> QualityProfile {
match codec {
CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus16k => QualityProfile {
codec: CodecId::Opus16k,
fec_ratio: 0.3,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Opus6k => QualityProfile::DEGRADED,
CodecId::Codec2_3200 => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
CodecId::ComfortNoise => QualityProfile::GOOD,
}
}
/// Decode the next audio frame from the jitter buffer.
///
/// Returns PCM samples (48kHz mono) or None if not ready.
@@ -510,6 +557,9 @@ impl CallDecoder {
return Some(pcm.len());
}
// Auto-switch decoder if incoming codec differs from current.
self.switch_decoder_if_needed(pkt.header.codec_id);
self.last_was_cn = false;
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
Ok(n) => Some(n),

View File

@@ -14,17 +14,23 @@
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{error, info};
use tracing::{error, info, warn};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport;
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
const FRAME_SAMPLES_20MS: usize = 960; // 20ms @ 48kHz
const FRAME_SAMPLES_40MS: usize = 1920; // 40ms @ 48kHz
/// Compute frame samples at 48kHz for a given profile.
fn frame_samples_for(profile: &wzp_proto::QualityProfile) -> usize {
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
}
/// Generate a sine wave tone.
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64;
(0..FRAME_SAMPLES)
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64, frame_samples: usize) -> Vec<i16> {
let start_sample = frame_offset * frame_samples as u64;
(0..frame_samples)
.map(|i| {
let t = (start_sample + i as u64) as f32 / sample_rate as f32;
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
@@ -45,12 +51,32 @@ struct CliArgs {
seed_hex: Option<String>,
mnemonic: 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>,
_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 {
/// 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 {
if let Some(ref hex_str) = self.seed_hex {
let seed = wzp_crypto::Seed::from_hex(hex_str).expect("invalid --seed hex");
@@ -65,15 +91,56 @@ impl CliArgs {
info!(fingerprint = %fp, "identity from --mnemonic");
seed
} 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 id = seed.derive_identity();
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
}
}
}
/// Resolve a profile name to a QualityProfile.
fn resolve_profile(name: &str) -> wzp_proto::QualityProfile {
use wzp_proto::{CodecId, QualityProfile};
match name.to_lowercase().as_str() {
"good" | "opus" | "opus24k" => QualityProfile::GOOD,
"degraded" | "opus6k" => QualityProfile::DEGRADED,
"catastrophic" | "codec2-1200" | "c2-1200" | "1200" => QualityProfile::CATASTROPHIC,
"codec2-3200" | "c2-3200" | "3200" => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
other => {
eprintln!("unknown profile: {other}");
eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200");
std::process::exit(1);
}
}
}
fn parse_args() -> CliArgs {
let args: Vec<String> = std::env::args().collect();
let mut live = false;
@@ -86,8 +153,19 @@ fn parse_args() -> CliArgs {
let mut seed_hex = None;
let mut mnemonic = 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 metrics_file = None;
let mut profile_override = None;
let mut relay_str = None;
let mut i = 1;
@@ -130,6 +208,27 @@ fn parse_args() -> CliArgs {
i += 1;
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" => {
i += 1;
token = Some(args.get(i).expect("--token requires a value").to_string());
@@ -168,6 +267,14 @@ fn parse_args() -> CliArgs {
.expect("--drift-test value must be a number"),
);
}
"--profile" | "--codec" => {
i += 1;
profile_override = Some(
args.get(i)
.expect("--profile requires a value (good, degraded, catastrophic, codec2-3200)")
.to_string(),
);
}
"--sweep" => sweep = true,
"--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]");
@@ -179,14 +286,28 @@ fn parse_args() -> CliArgs {
eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
eprintln!(" --profile <name> Force quality profile: good, degraded, catastrophic, codec2-3200");
eprintln!(" --codec <name> Alias for --profile");
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
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!(" --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!();
eprintln!("Identity is auto-saved to ~/.wzp/identity on first run.");
eprintln!("Default relay: 127.0.0.1:4433");
std::process::exit(0);
}
@@ -219,8 +340,19 @@ fn parse_args() -> CliArgs {
seed_hex,
mnemonic,
room,
raw_room,
alias,
no_denoise,
no_aec,
no_agc,
no_fec,
no_silence,
direct_playout,
aec_delay_ms,
os_aec,
token,
_metrics_file: metrics_file,
profile_override,
}
}
@@ -241,17 +373,30 @@ async fn main() -> anyhow::Result<()> {
let seed = cli.resolve_seed();
// Resolve profile override
let profile = cli.profile_override.as_deref().map(resolve_profile);
if let Some(ref p) = profile {
info!(codec = ?p.codec, frame_ms = p.frame_duration_ms, fec = p.fec_ratio, "forced profile");
}
info!(
relay = %cli.relay_addr,
live = cli.live,
send_tone = ?cli.send_tone_secs,
record = ?cli.record_file,
room = ?cli.room,
profile = ?cli.profile_override,
"WarzonePhone client"
);
// 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 {
Some(name) if cli.raw_room => {
info!(room = %name, "using raw room name as SNI (no hash)");
name.clone()
}
Some(name) => {
let hashed = wzp_crypto::hash_room_name(name);
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
@@ -287,14 +432,25 @@ async fn main() -> anyhow::Result<()> {
let _crypto_session = wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
None, // alias — desktop client doesn't set one yet
cli.alias.as_deref(),
).await?;
info!("crypto handshake complete");
if cli.live {
#[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"))]
{
@@ -315,19 +471,23 @@ async fn main() -> anyhow::Result<()> {
transport.close().await?;
Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file, profile).await
} else {
run_silence(transport).await
run_silence(transport, profile).await
}
}
/// Send silence frames (connectivity test).
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
let config = CallConfig::default();
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>, profile: Option<wzp_proto::QualityProfile>) -> anyhow::Result<()> {
let config = match profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let frame_samples = frame_samples_for(&config.profile);
let mut encoder = CallEncoder::new(&config);
let frame_duration = tokio::time::Duration::from_millis(20);
let pcm = vec![0i16; FRAME_SAMPLES];
let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64);
let pcm = vec![0i16; frame_samples];
let mut total_source = 0u64;
let mut total_repair = 0u64;
@@ -343,8 +503,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
}
total_bytes += pkt.payload.len() as u64;
if let Err(e) = transport.send_media(pkt).await {
error!("send error: {e}");
break;
warn!("send_media error (dropping packet): {e}");
}
}
if (i + 1) % 50 == 0 {
@@ -374,13 +533,20 @@ async fn run_file_mode(
send_tone_secs: Option<u32>,
send_file: Option<String>,
record_file: Option<String>,
profile: Option<wzp_proto::QualityProfile>,
) -> anyhow::Result<()> {
let config = CallConfig::default();
let config = match profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let frame_samples = frame_samples_for(&config.profile);
let frame_duration_ms = config.profile.frame_duration_ms as u64;
// --- Send task: generate tone or play file ---
let send_transport = transport.clone();
let send_handle = tokio::spawn(async move {
// Load PCM frames from file or generate tone
let frames_per_sec = 1000 / frame_duration_ms;
let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file {
// Read raw PCM file (48kHz mono s16le)
let bytes = match std::fs::read(path) {
@@ -392,14 +558,14 @@ async fn run_file_mode(
.collect();
let duration = samples.len() as f64 / 48_000.0;
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
samples.chunks(FRAME_SAMPLES)
.filter(|c| c.len() == FRAME_SAMPLES)
samples.chunks(frame_samples)
.filter(|c| c.len() == frame_samples)
.map(|c| c.to_vec())
.collect()
} else if let Some(secs) = send_tone_secs {
let total = (secs as u64) * 50;
info!(seconds = secs, frames = total, "sending 440Hz tone");
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect()
let total = (secs as u64) * frames_per_sec;
info!(seconds = secs, frames = total, frame_samples, frame_ms = frame_duration_ms, "sending 440Hz tone");
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i, frame_samples)).collect()
} else {
// No sending, just wait
tokio::signal::ctrl_c().await.ok();
@@ -408,7 +574,7 @@ async fn run_file_mode(
let mut encoder = CallEncoder::new(&config);
let _total_frames = pcm_frames.len() as u64;
let frame_duration = tokio::time::Duration::from_millis(20);
let frame_duration = tokio::time::Duration::from_millis(frame_duration_ms);
let mut total_source = 0u64;
let mut total_repair = 0u64;
@@ -429,8 +595,7 @@ async fn run_file_mode(
total_source += 1;
}
if let Err(e) = send_transport.send_media(pkt).await {
error!("send error: {e}");
return;
warn!("send_media error (dropping packet): {e}");
}
}
if (frame_idx + 1) % 250 == 0 {
@@ -459,8 +624,13 @@ async fn run_file_mode(
}
};
let mut decoder = CallDecoder::new(&CallConfig::default());
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
let recv_config = match profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let recv_frame_samples = frame_samples_for(&recv_config.profile);
let mut decoder = CallDecoder::new(&recv_config);
let mut pcm_buf = vec![0i16; recv_frame_samples.max(FRAME_SAMPLES_40MS)];
let mut all_pcm: Vec<i16> = Vec::new();
let mut frames_received = 0u64;
@@ -549,78 +719,534 @@ async fn run_file_mode(
}
/// 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")]
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_ring::AudioRing;
use wzp_client::call::JitterTelemetry;
let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?;
info!("Audio I/O started — press Ctrl+C to stop");
// Audio I/O: either VPIO (OS-level AEC) or separate CPAL streams.
#[cfg(all(target_os = "macos", feature = "vpio"))]
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 rt_handle = tokio::runtime::Handle::current();
let send_handle = std::thread::Builder::new()
.name("wzp-send-loop".into())
.spawn(move || {
let config = CallConfig::default();
let mut encoder = CallEncoder::new(&config);
loop {
let frame = match capture.read_frame() {
Some(f) => f,
None => break,
};
let packets = match encoder.encode_frame(&frame) {
Ok(p) => p,
Err(e) => {
error!("encode error: {e}");
continue;
}
};
for pkt in &packets {
if let Err(e) = rt_handle.block_on(send_transport.send_media(pkt)) {
error!("send error: {e}");
return;
let send_running = running.clone();
let send_mic_muted = mic_muted.clone();
let no_aec = opts.no_aec || opts.os_aec; // OS AEC replaces software AEC
let no_agc = opts.no_agc;
let _no_fec = opts.no_fec;
let send_farend = farend_ring.clone();
let send_task = async move {
let mut encoder = CallEncoder::new(&config);
if no_aec { encoder.set_aec_enabled(false); }
if no_agc { encoder.set_agc_enabled(false); }
let mut capture_buf = vec![0i16; frame_samples];
let mut farend_buf = vec![0i16; frame_samples];
let mut frames_sent: u64 = 0;
let mut frames_dropped: u64 = 0;
let mut send_errors: u64 = 0;
let mut last_send_err = std::time::Instant::now();
let mut polls: u64 = 0;
let mut last_diag = std::time::Instant::now();
loop {
if !send_running.load(Ordering::Relaxed) {
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_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 mut decoder = CallDecoder::new(&config);
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
loop {
match recv_transport.recv_media().await {
Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair;
decoder.ingest(pkt);
// Only decode for source packets (1 source = 1 audio frame).
// Repair packets feed the FEC decoder but don't produce audio.
if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
playback.write_frame(&pcm_buf);
let decoder = StdArc::new(tokio::sync::Mutex::new(CallDecoder::new(&config)));
let decoder_recv = decoder.clone();
async move {
let mut packets_received: u64 = 0;
let mut recv_errors: u64 = 0;
let mut timeouts: u64 = 0;
// For direct playout: raw codec decoder + AGC
let direct_profile = recv_profile.unwrap_or(wzp_proto::QualityProfile::GOOD);
let mut opus_dec = if direct_playout {
Some(wzp_codec::create_decoder(direct_profile))
} 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) => {
error!("recv error: {e}");
}
}
};
// Playout tick — only used when NOT in direct playout mode
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;
}
}
return;
}
});
tokio::signal::ctrl_c().await?;
info!("Shutting down...");
let playout_config = match playout_profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let playout_frame_ms = playout_config.profile.frame_duration_ms as u64;
let playout_frame_samples = frame_samples_for(&playout_config.profile);
let mut decoder = CallDecoder::new(&playout_config);
let mut pcm_buf = vec![0i16; playout_frame_samples.max(FRAME_SAMPLES_40MS)];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(playout_frame_ms));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut telemetry = JitterTelemetry::new(5);
loop {
interval.tick().await;
if !playout_running.load(Ordering::Relaxed) {
break;
}
recv_handle.abort();
drop(send_handle);
transport.close().await?;
info!("done");
let mut decoded_this_tick = 0;
while let Some(n) = decoder.decode_next(&mut pcm_buf) {
playout_ring.write(&pcm_buf[..n]);
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(())
}

View File

@@ -110,6 +110,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
SignalMessage::SetAlias { .. } => CallSignalType::Offer, // reuse
}
}
@@ -125,6 +126,7 @@ mod tests {
ephemeral_pub: [2u8; 32],
signature: vec![3u8; 64],
supported_profiles: vec![QualityProfile::GOOD],
alias: None,
};
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
@@ -142,6 +144,7 @@ mod tests {
ephemeral_pub: [0; 32],
signature: vec![],
supported_profiles: vec![],
alias: None,
};
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));

View File

@@ -8,6 +8,10 @@
#[cfg(feature = "audio")]
pub mod audio_io;
#[cfg(feature = "audio")]
pub mod audio_ring;
#[cfg(feature = "vpio")]
pub mod audio_vpio;
pub mod bench;
pub mod call;
pub mod drift_test;

View File

@@ -1,53 +1,127 @@
//! Acoustic Echo Cancellation using NLMS adaptive filter.
//! Processes 480-sample (10ms) sub-frames at 48kHz.
//! Acoustic Echo Cancellation — delay-compensated leaky NLMS with
//! Geigel double-talk detection.
//!
//! Key insight: on a laptop, the round-trip audio latency (playout → speaker
//! → air → mic → capture) is 3050ms. 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.
///
/// 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.
/// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
pub struct EchoCanceller {
filter_coeffs: Vec<f32>,
// --- Adaptive filter ---
filter: Vec<f32>,
filter_len: usize,
far_end_buf: Vec<f32>,
far_end_pos: usize,
/// Circular buffer of far-end reference samples (after delay).
far_buf: Vec<f32>,
far_pos: usize,
/// NLMS step size.
mu: f32,
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
leak: f32,
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 {
/// Create a new echo canceller.
///
/// * `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 {
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 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 {
filter_coeffs: vec![0.0f32; filter_len],
filter: vec![0.0; filter_len],
filter_len,
far_end_buf: vec![0.0f32; filter_len],
far_end_pos: 0,
far_buf: vec![0.0; filter_len],
far_pos: 0,
mu: 0.01,
leak: 0.0001,
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.
///
/// Must be called with the audio that was played out through the speaker
/// *before* the corresponding near-end frame is processed.
/// Feed far-end (speaker) samples. These go into the delay buffer first;
/// once enough samples have accumulated, they are released to the filter's
/// circular buffer with the correct delay offset.
pub fn feed_farend(&mut self, farend: &[i16]) {
// Write raw samples into the delay ring.
for &s in farend {
self.far_end_buf[self.far_end_pos] = s as f32;
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
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.
///
/// 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 {
if !self.enabled {
return 1.0;
@@ -56,85 +130,96 @@ impl EchoCanceller {
let n = nearend.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_err_sq: f64 = 0.0;
for i in 0..n {
let near_f = nearend[i] as f32;
// --- estimate echo as dot(coeffs, farend_window) ---
// The far-end window for this sample starts at
// (far_end_pos - 1 - i) mod filter_len (most recent)
// and goes back filter_len samples.
// Position of far-end "now" for this near-end sample.
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
// --- Echo estimation: dot(filter, far_end_window) ---
let mut echo_est: 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 {
let fe_idx = (base + fl - k) % fl;
let fe = self.far_end_buf[fe_idx];
echo_est += self.filter_coeffs[k] * fe;
let fe = self.far_buf[fe_idx];
echo_est += self.filter[k] * fe;
power += fe * fe;
}
let error = near_f - echo_est;
// --- NLMS coefficient update ---
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
let step = self.mu * error / norm;
for k in 0..fl {
let fe_idx = (base + fl - k) % fl;
let fe = self.far_end_buf[fe_idx];
self.filter_coeffs[k] += step * fe;
// --- NLMS adaptation (only when far-end active & no double-talk) ---
if far_active && !is_doubletalk && power > 10.0 {
let step = self.mu * error / (power + 1.0);
for k in 0..fl {
let fe_idx = (base + fl - k) % fl;
self.filter[k] += step * self.far_buf[fe_idx];
}
}
// Clamp output
let out = error.max(-32768.0).min(32767.0);
let out = error.clamp(-32768.0, 32767.0);
nearend[i] = out as i16;
sum_near_sq += (near_f as f64) * (near_f as f64);
sum_err_sq += (out as f64) * (out as f64);
sum_near_sq += (near_f as f64).powi(2);
sum_err_sq += (out as f64).powi(2);
}
// ERLE ratio
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) {
self.enabled = enabled;
}
/// Returns whether echo cancellation is currently enabled.
pub fn is_enabled(&self) -> bool {
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) {
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
self.far_end_pos = 0;
self.filter.iter_mut().for_each(|c| *c = 0.0);
self.far_buf.iter_mut().for_each(|s| *s = 0.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::*;
#[test]
fn aec_creates_with_correct_filter_len() {
let aec = EchoCanceller::new(48000, 100);
assert_eq!(aec.filter_len, 4800);
assert_eq!(aec.filter_coeffs.len(), 4800);
assert_eq!(aec.far_end_buf.len(), 4800);
fn creates_with_correct_sizes() {
let aec = EchoCanceller::with_delay(48000, 60, 40);
assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
}
#[test]
fn aec_passthrough_when_disabled() {
let mut aec = EchoCanceller::new(48000, 100);
fn passthrough_when_disabled() {
let mut aec = EchoCanceller::new(48000, 60);
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 erle = aec.process_frame(&mut frame);
assert_eq!(erle, 1.0);
aec.process_frame(&mut frame);
assert_eq!(frame, original);
}
#[test]
fn aec_reset_zeroes_state() {
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
aec.feed_farend(&farend);
aec.reset();
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);
fn silence_passthrough() {
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
aec.feed_farend(&vec![0i16; 960]);
let mut frame = vec![0i16; 960];
aec.process_frame(&mut frame);
assert!(frame.iter().all(|&s| s == 0));
}
#[test]
fn aec_reduces_echo_of_known_signal() {
// Use a small filter for speed. Feed a known far-end signal, then
// present the *same* signal as near-end (perfect echo, no room).
// After adaptation the output energy should drop.
let filter_ms = 5; // 240 taps at 48 kHz
let mut aec = EchoCanceller::new(48000, filter_ms);
fn reduces_echo_with_no_delay() {
// Simulate: far-end plays, echo arrives at mic attenuated by ~50%
// (realistic — speaker to mic on laptop loses volume).
let mut aec = EchoCanceller::with_delay(48000, 10, 0);
// Generate a simple repeating pattern.
let frame_len = 480usize;
let make_frame = |offset: usize| -> Vec<i16> {
let frame_len = 480;
let make_tone = |offset: usize| -> Vec<i16> {
(0..frame_len)
.map(|i| {
let t = (offset + i) as f64 / 48000.0;
@@ -195,18 +270,16 @@ mod tests {
.collect()
};
// Warm up the adaptive filter with several frames.
let mut last_erle = 1.0f32;
for frame_idx in 0..40 {
let farend = make_frame(frame_idx * frame_len);
for frame_idx in 0..100 {
let farend = make_tone(frame_idx * frame_len);
aec.feed_farend(&farend);
// Near-end = exact copy of far-end (pure echo).
let mut nearend = farend.clone();
// Near-end = attenuated copy of far-end (echo at ~50% volume).
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
last_erle = aec.process_frame(&mut nearend);
}
// After 40 frames the ERLE should be meaningfully > 1.
assert!(
last_erle > 1.0,
"expected ERLE > 1.0 after adaptation, got {last_erle}"
@@ -214,15 +287,49 @@ mod tests {
}
#[test]
fn aec_silence_passthrough() {
let mut aec = EchoCanceller::new(48000, 10);
// Feed silence far-end
aec.feed_farend(&vec![0i16; 480]);
// Near-end is silence too
let mut frame = vec![0i16; 480];
let erle = aec.process_frame(&mut frame);
assert!(erle >= 1.0);
// Output should still be silence
assert!(frame.iter().all(|&s| s == 0));
fn preserves_nearend_during_doubletalk() {
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
let frame_len = 960;
let nearend: Vec<i16> = (0..frame_len)
.map(|i| {
let t = i as f64 / 48000.0;
(10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
})
.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");
}
}

View File

@@ -656,6 +656,11 @@ pub enum SignalMessage {
/// List of participants currently in the room.
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.

View File

@@ -28,6 +28,7 @@ prometheus = "0.13"
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
tower-http = { version = "0.6", features = ["fs"] }
futures-util = "0.3"
dirs = "6"
[[bin]]
name = "wzp-relay"

View File

@@ -13,7 +13,7 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{error, info};
use tracing::{error, info, warn};
use wzp_proto::MediaTransport;
use wzp_relay::config::RelayConfig;
@@ -207,8 +207,39 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
}
// Generate ephemeral relay identity for crypto handshake
let relay_seed = wzp_crypto::Seed::generate();
// Load or generate relay identity — persisted in ~/.wzp/relay-identity
let relay_seed = {
let config_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".wzp");
let identity_path = config_dir.join("relay-identity");
if identity_path.exists() {
if let Ok(hex) = std::fs::read_to_string(&identity_path) {
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
info!("loaded relay identity from {}", identity_path.display());
s
} else {
warn!("corrupt relay identity file, generating new");
let s = wzp_crypto::Seed::generate();
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&identity_path, &hex);
s
}
} else {
let s = wzp_crypto::Seed::generate();
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&identity_path, &hex);
s
}
} else {
let s = wzp_crypto::Seed::generate();
let _ = std::fs::create_dir_all(&config_dir);
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&identity_path, &hex);
info!("generated relay identity at {}", identity_path.display());
s
}
};
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
@@ -299,6 +330,13 @@ async fn main() -> anyhow::Result<()> {
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
// Ping connections: client just measures QUIC connect RTT.
// No handshake, no streams — client closes immediately after connecting.
if room_name == "ping" {
info!(%addr, "ping connection (RTT probe)");
return;
}
// Probe connections use SNI "_probe" to identify themselves.
// They skip auth + handshake and just do Ping->Pong + presence gossip.
if room_name == "_probe" {

View File

@@ -10,7 +10,7 @@ use std::time::Duration;
use bytes::Bytes;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
use tracing::{debug, error, info, trace, warn};
use wzp_proto::packet::TrunkFrame;
use wzp_proto::MediaTransport;
@@ -141,6 +141,17 @@ impl Room {
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 {
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.
pub fn others(
&self,
@@ -374,68 +405,166 @@ async fn run_participant_plain(
session_id: &str,
) {
let addr = transport.connection().remote_address();
let mut packets_forwarded = 0u64;
loop {
let pkt = match transport.recv_media().await {
Ok(Some(pkt)) => pkt,
Ok(None) => {
info!(%addr, participant = participant_id, "disconnected");
break;
}
Err(e) => {
let msg = e.to_string();
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
info!(%addr, participant = participant_id, "connection closed: {e}");
} else {
error!(%addr, participant = participant_id, "recv error: {e}");
// Media forwarding task (with debug logging from Android fixes)
let media_room_mgr = room_mgr.clone();
let media_room_name = room_name.clone();
let media_transport = transport.clone();
let media_metrics = metrics.clone();
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();
info!(
room = %media_room_name,
participant = participant_id,
%addr,
session = %media_session_id,
"forwarding loop started (plain)"
);
loop {
let pkt = match media_transport.recv_media().await {
Ok(Some(pkt)) => pkt,
Ok(None) => {
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
break;
}
break;
}
};
// Update per-session quality metrics if a quality report is present
if let Some(ref report) = pkt.quality_report {
metrics.update_session_quality(session_id, report);
}
// Get current list of other participants
let others = {
let mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id)
};
// Forward to all others
let pkt_bytes = pkt.payload.len() as u64;
for other in &others {
match other {
ParticipantSender::Quic(t) => {
let _ = t.send_media(&pkt).await;
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;
}
ParticipantSender::WebSocket(_) => {
// WS clients receive raw payload bytes
let _ = other.send_raw(&pkt.payload).await;
}
}
}
let fan_out = others.len() as u64;
metrics.packets_forwarded.inc_by(fan_out);
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
packets_forwarded += 1;
if packets_forwarded % 500 == 0 {
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,
"participant stats"
);
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"
);
}
if let Some(ref report) = pkt.quality_report {
media_metrics.update_session_quality(&media_session_id, report);
}
let lock_start = std::time::Instant::now();
let others = {
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");
}
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 = %media_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;
}
}
}
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;
}
}
}
};
// Run both in parallel — exit when either finishes (disconnection)
tokio::select! {
_ = media_task => {}
_ = signal_task => {}
}
// Clean up — leave room and broadcast update to remaining participants
@@ -459,6 +588,19 @@ async fn run_participant_trunked(
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!(
room = %room_name,
participant = participant_id,
%addr,
session = session_id,
"forwarding loop started (trunked)"
);
// Per-peer TrunkedForwarders, keyed by the raw pointer of the peer
// transport (stable for the Arc's lifetime). We use the remote address
@@ -480,24 +622,50 @@ async fn run_participant_trunked(
let pkt = match result {
Ok(Some(pkt)) => pkt,
Ok(None) => {
info!(%addr, participant = participant_id, "disconnected");
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
break;
}
Err(e) => {
error!(%addr, participant = participant_id, "recv error: {e}");
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 = %room_name,
participant = participant_id,
recv_gap_ms,
seq = pkt.header.seq,
"large recv gap (trunked)"
);
}
if let Some(ref report) = pkt.quality_report {
metrics.update_session_quality(session_id, report);
}
let lock_start = std::time::Instant::now();
let others = {
let mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id)
};
let lock_ms = lock_start.elapsed().as_millis() as u64;
if lock_ms > 10 {
warn!(
room = %room_name,
participant = participant_id,
lock_ms,
"slow room_mgr lock (trunked)"
);
}
let fwd_start = std::time::Instant::now();
let pkt_bytes = pkt.payload.len() as u64;
for other in &others {
match other {
@@ -507,21 +675,44 @@ async fn run_participant_trunked(
.entry(peer_addr)
.or_insert_with(|| TrunkedForwarder::new(t.clone(), sid_bytes));
if let Err(e) = fwd.send(&pkt).await {
let _ = e;
send_errors += 1;
if send_errors <= 5 || send_errors % 100 == 0 {
warn!(
room = %room_name,
participant = participant_id,
peer = %peer_addr,
total_send_errors = send_errors,
"trunked send error: {e}"
);
}
}
}
ParticipantSender::WebSocket(_) => {
// WS clients bypass trunking — send raw payload directly
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 = %room_name,
participant = participant_id,
fwd_ms,
fan_out = others.len(),
"slow forward (trunked)"
);
}
let fan_out = others.len() as u64;
metrics.packets_forwarded.inc_by(fan_out);
metrics.bytes_forwarded.inc_by(pkt_bytes * fan_out);
packets_forwarded += 1;
if packets_forwarded % 500 == 0 {
// Periodic stats 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)
@@ -531,15 +722,30 @@ async fn run_participant_trunked(
participant = participant_id,
forwarded = packets_forwarded,
room_size,
fan_out,
max_recv_gap_ms,
max_forward_ms,
send_errors,
"participant stats (trunked)"
);
max_recv_gap_ms = 0;
max_forward_ms = 0;
last_log_instant = std::time::Instant::now();
}
}
_ = flush_interval.tick() => {
for fwd in forwarders.values_mut() {
if let Err(e) = fwd.flush().await {
let _ = e;
send_errors += 1;
if send_errors <= 5 || send_errors % 100 == 0 {
warn!(
room = %room_name,
participant = participant_id,
total_send_errors = send_errors,
"trunk flush error: {e}"
);
}
}
}
}

View File

@@ -33,6 +33,12 @@ impl QuinnTransport {
&self.connection
}
/// Close the QUIC connection immediately (synchronous, no async needed).
/// The relay will detect the close and remove this participant from the room.
pub fn close_now(&self) {
self.connection.close(quinn::VarInt::from_u32(0), b"hangup");
}
/// Feed an external RTT observation (e.g. from QUIC path stats) into the path monitor.
pub fn feed_rtt(&self, rtt_ms: u32) {
self.path_monitor.lock().unwrap().observe_rtt(rtt_ms);

View File

@@ -272,7 +272,7 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
// Crypto handshake with relay
let handshake_start = std::time::Instant::now();
let bridge_seed = wzp_crypto::Seed::generate();
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).await {
match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0, None).await {
Ok(_session) => {
let elapsed = handshake_start.elapsed().as_secs_f64();
state.metrics.handshake_latency.observe(elapsed);

View 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
View 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>;

View 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;

View File

@@ -0,0 +1,166 @@
# Incident Report: Playout Ring Buffer Cursor Desync — Bidirectional Audio Loss
**Date:** 2026-04-06
**Severity:** Critical — causes 10-16 seconds of complete bidirectional silence mid-call
**Status:** Root-caused, fix pending
**Affects:** All clients using `AudioRing` (Android, potentially desktop)
## Summary
Both participants in a call experience simultaneous, prolonged audio silence (10-16 seconds) despite the QUIC transport, relay, and Rust codec pipeline all functioning normally. The root cause is a cursor desynchronization in the lock-free SPSC ring buffer (`AudioRing`) that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
## How We Know It's the Ring Buffer
### Evidence that eliminates other components
| Component | Evidence it's healthy | Source |
|-----------|----------------------|--------|
| **QUIC send path** | `frames_dropped=0, send_errors=0` on both clients | Engine send stats log |
| **QUIC recv path** | `max_recv_gap_ms=82, recv_errors=0` — no gaps >82ms | Engine recv stats log |
| **Relay forwarding** | `max_forward_ms=0, send_errors=0` in previous relay-instrumented test | Relay debug logging |
| **Opus codec** | `frames_decoded=2442` over 51.9s = 47 frames/sec (correct for 20ms) | Final stats JSON |
| **FEC** | `fec_recovered=4870` — FEC working normally | Final stats JSON |
| **Audio capture** | Pixel 6 capture has 0% silence; Nothing has gaps but those are expected mic pauses | capture_rms.csv |
### Evidence pointing to the ring buffer
1. **Both clients go silent at the exact same wall-clock moment (26.66s into call)** — rules out per-device issues; the common factor is the relay, but the relay was proven healthy in prior tests.
2. **`playout_avail=8640` at stats dump time** — the playout ring reports 8640 samples available (180ms, nearly full at the 9600 capacity). The recv task believes it has successfully written data into the ring. But the AudioTrack playout thread is reading silence (RMS=0 for 12+ seconds).
3. **Recv task continued receiving packets with no gaps**`max_recv_gap_ms=82` across the entire call. The decoded audio was written to the ring continuously.
4. **Silence starts and ends cleanly** — the transition from audio → silence happens within a single 20ms frame (frame 1332: rms=101, frame 1333: rms=0). This is not network degradation (which shows gradual quality loss). It's a discrete state change — the reader suddenly stops seeing data.
5. **Recovery is also discrete** — at ~38.8s (Sharp Hawk) and ~42.7s (Pixel 6), audio snaps back with high-energy frames (rms=3296+). Not a gradual reconnection.
## The Ring Buffer Code
**File:** `crates/wzp-android/src/audio_ring.rs`
```rust
const RING_CAPACITY: usize = 960 * 10; // 9600 samples = 200ms at 48kHz
pub struct AudioRing {
buf: Box<[i16; RING_CAPACITY]>,
write_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
read_pos: AtomicUsize, // monotonically increasing, wraps at usize::MAX
}
```
### `available()` — how many samples can be read
```rust
pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Acquire);
w.wrapping_sub(r) // relies on usize wrapping arithmetic
}
```
### `write()` — producer (Rust recv task thread, inside tokio block_on)
```rust
pub fn write(&self, samples: &[i16]) -> usize {
let w = self.write_pos.load(Ordering::Relaxed);
let count = samples.len().min(RING_CAPACITY);
// ... write samples at (w + i) % RING_CAPACITY ...
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
// If we overwrote unread data, advance read_pos
if self.available() > RING_CAPACITY {
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
self.read_pos.store(new_read, Ordering::Release);
}
}
```
### `read()` — consumer (Kotlin AudioTrack JVM thread, via JNI)
```rust
pub fn read(&self, out: &mut [i16]) -> usize {
let avail = self.available();
let count = out.len().min(avail);
let r = self.read_pos.load(Ordering::Relaxed);
// ... read samples at (r + i) % RING_CAPACITY ...
self.read_pos.store(r.wrapping_add(count), Ordering::Release);
count
}
```
## Suspected Failure Modes
### 1. Writer advances `read_pos` while reader is mid-read (data race)
The `write()` method at lines 68-72 modifies `read_pos` from the writer thread when it detects overflow. But the `read()` method on the consumer thread also modifies `read_pos`. This violates the SPSC contract — `read_pos` is supposed to be owned by the consumer.
**Scenario:**
1. Reader loads `read_pos = R` (line 82)
2. Writer detects overflow, stores `read_pos = R'` (line 71) where `R' > R`
3. Reader finishes reading, stores `read_pos = R + count` (line 88) — **overwrites** the writer's `R'` with a stale, smaller value
After step 3, the ring's `read_pos` has gone backwards. Now `available()` returns `write_pos.wrapping_sub(old_read_pos)` which is larger than `RING_CAPACITY`. Every subsequent `write()` call hits the overflow branch and keeps advancing `read_pos`, but the reader keeps overwriting it back. The ring is in a corrupted state where the reader and writer are fighting over `read_pos`.
### 2. `wrapping_sub` returns astronomically large values
`available()` uses `w.wrapping_sub(r)`. On a 64-bit platform, if due to the race above `r > w`, `wrapping_sub` returns `usize::MAX - (r - w) + 1` — an enormous number. The `read()` method caps this with `out.len().min(avail)` so it reads `out.len()` samples. But those samples are from indices calculated as `(r + i) % RING_CAPACITY` which wraps correctly. The samples read would be whatever was in the buffer at those positions — potentially stale/old data, or zeros from initialization.
However, the playout RMS CSV shows clean zeros (RMS=0), not garbage. This suggests the ring is returning the zeroed-out initial buffer contents, meaning `read_pos` has jumped far ahead of `write_pos`, pointing to memory that was never written to (or was written long ago and has since been zeroed by the overflow advance logic).
### 3. Why silence lasts exactly 12-16 seconds
After the desync, each `write()` call (every 20ms when a packet is decoded) enters the overflow branch and resets `read_pos`. But the reader immediately overwrites it back in its next `read()` call. This tug-of-war continues until one of two things happens:
- The cursors happen to realign through wrapping arithmetic
- A timing coincidence where the writer's store to `read_pos` happens to "win" the race
The 12-16 second duration is non-deterministic and depends on exact thread scheduling and cursor values.
## Reproduction Pattern
The bug manifests after roughly 25-30 seconds of a call. This timing is suspicious:
- At 48kHz mono, 20ms frames = 50 frames/sec
- Each decoded frame writes 960 samples to the ring
- After 25 seconds: `write_pos ≈ 25 * 50 * 960 = 1,200,000`
- The ring capacity is 9600, so `write_pos` has wrapped around `RING_CAPACITY` about 125 times
The wrapping of the monotonic cursors past certain thresholds, combined with the reader/writer `read_pos` race, likely triggers the desync at this scale.
## Data Files
All data from two independent test sessions (3 calls total) is in `/workspace/wzp/debug/`:
| File | Contents |
|------|----------|
| `wzp_debug_20260406_120546.zip` | Sharp Hawk (Nothing A059) — 51.9s call |
| `wzp_debug_20260406_120549.zip` | Bright Viper (Pixel 6) — 51.9s call |
| `wzp_debug_20260406_111733.zip` | Sharp Hawk — earlier 72.0s call, same pattern |
| `wzp_debug_20260406_111735.zip` | Bright Viper — earlier 72.0s call, same pattern |
| `wzp_debug_20260406_105858.zip` | First session (pre-logging fix), 39.8s call |
| `wzp_debug_20260406_105900.zip` | First session, 39.7s call |
### Key fields in each zip
- `meta.txt` — device, duration, final stats JSON
- `playout_rms.csv` — per-frame (20ms) RMS of AudioTrack output; silence = RMS 0
- `capture_rms.csv` — per-frame RMS of AudioRecord input
- `logcat.txt` — Android logcat filtered to WZP + audio tags
### How to reproduce the analysis
```python
import csv
with open("playout_rms.csv") as f:
for row in csv.DictReader(f):
if int(row['rms']) == 0 and int(row['time_ms']) > 2000:
print(f"SILENCE at {row['time_ms']}ms")
```
## Affected Code
- `crates/wzp-android/src/audio_ring.rs` — the `AudioRing` struct, specifically the `write()` method's overflow handling that mutates `read_pos` from the producer thread
- Any client using `AudioRing` for playout (currently only Android; desktop uses `cpal` directly)
## Constraints for the Fix
1. Must remain lock-free — AudioTrack thread runs at `Thread.MAX_PRIORITY` and cannot block
2. Must handle overflow gracefully — if the reader falls behind, old audio should be dropped, not cause a desync
3. The writer (Rust recv task) and reader (Kotlin AudioTrack via JNI) run on different threads with different scheduling priorities
4. Ring capacity is 200ms which is tight — any fix must not increase latency significantly
5. The `write_pos` and `read_pos` are the only synchronization mechanism (no mutex, no condvar)

View File

@@ -0,0 +1,123 @@
# Incident Report: Send Task Fatal Exit on QUIC Congestion
**Date:** 2026-04-06
**Severity:** High — causes complete audio loss mid-call
**Status:** Fixed in Android client, **pending fix in desktop client and web client**
## Summary
A QUIC congestion event causes `send_datagram()` to return `Err(Blocked)`. The send task treats this as a fatal error and exits, which kills the entire call via `tokio::select!`. Audio becomes one-way (recv still works briefly) then dies completely.
## Root Cause
In the engine's send loop (`run_call` function), `transport.send_media()` errors were handled with `break`:
```rust
// BEFORE (broken)
if let Err(e) = transport.send_media(&source_pkt).await {
error!("send error: {e}");
break; // <-- kills send task, which kills everything
}
```
Quinn's `send_datagram()` is synchronous and returns `Err(SendDatagramError::Blocked)` when the QUIC congestion window is full. This is a **transient condition** — the window opens again once ACKs arrive. But the `break` kills the send task, and since all tasks run under `tokio::select!`, the recv task, stats task, and signal task all die too.
### Why it manifests as "intermittent disconnections"
- Mobile networks have brief congestion spikes (cell tower handoff, WiFi interference)
- A single spike fills the QUIC congestion window
- One `Blocked` error → send task exits → `select!` cancels recv → complete silence
- The QUIC connection stays open (no error logged), so stats polling continues showing stale data
- From the user's perspective: audio drops for 5-20 seconds then "maybe comes back" (it doesn't — they're hearing cached playout ring drain)
### Evidence from debug reports
**Relay logs** confirmed the relay was healthy:
- `max_forward_ms=0` — relay forwards instantly
- `send_errors=0` — no relay-side failures
- The relay saw `large recv gap` warnings on participant 1 (Nothing A059): 722ms → 814ms → 1778ms → 3500ms → 6091ms — the client progressively stopped sending
**Client stats** confirmed:
- `frames_encoded` kept incrementing (Opus encoder running)
- `frames_decoded` froze at a fixed value (recv task died)
- `fec_recovered` froze simultaneously
- RTT, loss, jitter all frozen (stats task died)
## Fix Applied
### Android client (`crates/wzp-android/src/engine.rs`)
```rust
// AFTER (fixed)
if let Err(e) = transport.send_media(&source_pkt).await {
send_errors += 1;
frames_dropped += 1;
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
warn!(seq = s, send_errors, frames_dropped,
"send_media error (dropping packet): {e}");
last_send_error_log = Instant::now();
}
continue; // <-- drop packet, keep going
}
```
Same pattern applied to FEC repair packet sends.
Recv task also hardened: transient errors (non-closed/reset) are now logged and survived rather than causing exit.
Added periodic health logging to both tasks (5-second intervals):
- Send: `frames_sent`, `frames_dropped`, `send_errors`, `ring_avail`
- Recv: `frames_decoded`, `fec_recovered`, `recv_errors`, `max_recv_gap_ms`, `playout_avail`
### Relay (`crates/wzp-relay/src/room.rs`)
Added debug logging to both plain and trunked forwarding loops:
- Per-recv gap tracking (warns on >200ms gaps)
- Room manager lock contention tracking (warns on >10ms)
- Forward latency tracking (warns on >50ms)
- Send error counting with peer identification
- 5-second periodic stats with all above metrics
## Affected Clients — FIX REQUIRED
### Desktop client (`crates/wzp-client/src/cli.rs`)
**Lines 345-348:**
```rust
if let Err(e) = transport.send_media(pkt).await {
error!("send error: {e}");
break; // <-- SAME BUG
}
```
**Lines 431-434:**
```rust
if let Err(e) = send_transport.send_media(pkt).await {
error!("send error: {e}");
return; // <-- SAME BUG
}
```
Both need the same continue-on-error pattern.
### Web client (`crates/wzp-web/src/main.rs`)
Needs audit — WebSocket transport may have different error semantics but same pattern should be checked.
## Testing
After fix, a congestion event will:
1. Log warnings with packet counts: `send_media error (dropping packet): Blocked`
2. Drop affected packets (brief audio glitch — ~20-100ms)
3. Resume normal sending once congestion window opens
4. FEC on the receiver side will recover most dropped packets
5. Call continues uninterrupted
## Timeline
- 10:37 — First crash observed (LinearProgressIndicator compose bug masked investigation)
- 10:58 — Debug reports collected, decoded stall pattern identified
- 11:16 — Relay debug logging deployed, confirmed relay is clean
- 11:17 — Second debug reports collected, send gaps correlated with relay recv gaps
- 11:30 — Root cause identified: `break` on `send_media` error in send task
- 11:45 — Fix applied and deployed

2
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

@@ -0,0 +1,8 @@
{
"hash": "9046c0bf",
"configHash": "ef0fc96f",
"lockfileHash": "d66891b1",
"browserHash": "8171ed59",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

143
desktop/index.html Normal file
View File

@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WarzonePhone</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<!-- Connect screen -->
<div id="connect-screen">
<h1>WarzonePhone</h1>
<p class="subtitle">Encrypted Voice</p>
<div class="form">
<label>Relay
<button id="relay-selected" class="relay-selected" type="button">
<span id="relay-dot" class="dot"></span>
<span id="relay-label">Select relay...</span>
<span class="arrow">&#9881;</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+,)">&#9881;</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+,)">&#9881;</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">&times;</button>
</div>
<div class="settings-section">
<h3>Connection</h3>
<label>Default Room
<input id="s-room" type="text" />
</label>
<label>Alias
<input id="s-alias" type="text" />
</label>
</div>
<div class="settings-section">
<h3>Audio</h3>
<label class="checkbox">
<input id="s-os-aec" type="checkbox" />
OS Echo Cancellation (macOS VoiceProcessingIO)
</label>
<label class="checkbox">
<input id="s-agc" type="checkbox" checked />
Automatic Gain Control
</label>
</div>
<div class="settings-section">
<h3>Identity</h3>
<div class="setting-row">
<span class="setting-label">Fingerprint</span>
<span id="s-fingerprint" class="fp-display-large"></span>
</div>
<div class="setting-row">
<span class="setting-label">Identity file</span>
<span class="fp-display">~/.wzp/identity</span>
</div>
</div>
<div class="settings-section">
<h3>Recent Rooms</h3>
<div id="s-recent-rooms" class="recent-rooms-list"></div>
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
</div>
<button id="settings-save" class="primary">Save</button>
</div>
</div>
<!-- Manage Relays dialog -->
<div id="relay-dialog" class="hidden">
<div class="settings-card relay-dialog-card">
<div class="settings-header">
<h2>Manage Relays</h2>
<button id="relay-dialog-close" class="icon-btn">&times;</button>
</div>
<div id="relay-dialog-list" class="relay-dialog-list"></div>
<div class="relay-add-row">
<div class="relay-add-inputs">
<input id="relay-add-name" type="text" placeholder="Name" />
<input id="relay-add-addr" type="text" placeholder="host:port" />
</div>
<button id="relay-add-btn" class="primary">Add Relay</button>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1350
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
desktop/package.json Normal file
View 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"
}
}

View 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"]

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

View File

@@ -0,0 +1,365 @@
//! Call engine for the desktop app — wraps wzp-client audio + transport
//! into a clean async interface for Tauri commands.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use tracing::{error, info};
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
use wzp_client::call::{CallConfig, CallEncoder};
use wzp_proto::MediaTransport;
const FRAME_SAMPLES: usize = 960;
/// Wrapper to make non-Sync audio handles safe to store in shared state.
/// The audio handle is only accessed from the thread that created it (drop),
/// never shared across threads — Sync is safe.
#[allow(dead_code)]
struct SyncWrapper(Box<dyn std::any::Any + Send>);
unsafe impl Sync for SyncWrapper {}
pub struct ParticipantInfo {
pub fingerprint: String,
pub alias: Option<String>,
}
pub struct EngineStatus {
pub mic_muted: bool,
pub spk_muted: bool,
pub participants: Vec<ParticipantInfo>,
pub frames_sent: u64,
pub frames_received: u64,
pub audio_level: u32,
pub call_duration_secs: f64,
pub fingerprint: String,
}
pub struct CallEngine {
running: Arc<AtomicBool>,
mic_muted: Arc<AtomicBool>,
spk_muted: Arc<AtomicBool>,
participants: Arc<Mutex<Vec<ParticipantInfo>>>,
frames_sent: Arc<AtomicU64>,
frames_received: Arc<AtomicU64>,
audio_level: Arc<AtomicU32>,
transport: Arc<wzp_transport::QuinnTransport>,
start_time: Instant,
fingerprint: String,
/// Keep audio handles alive for the duration of the call.
/// Wrapped in SyncWrapper because AudioUnit isn't Sync.
_audio_handle: SyncWrapper,
}
impl CallEngine {
pub async fn start<F>(
relay: String,
room: String,
alias: String,
_os_aec: bool,
event_cb: F,
) -> Result<Self, anyhow::Error>
where
F: Fn(&str, &str) + Send + Sync + 'static,
{
let _ = rustls::crypto::ring::default_provider().install_default();
let relay_addr: SocketAddr = relay.parse()?;
// Load or generate identity
let seed = {
let path = {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(home).join(".wzp").join("identity")
};
if path.exists() {
if let Ok(hex) = std::fs::read_to_string(&path) {
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
s
} else {
wzp_crypto::Seed::generate()
}
} else {
wzp_crypto::Seed::generate()
}
} else {
let s = wzp_crypto::Seed::generate();
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).ok();
}
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
std::fs::write(&path, hex).ok();
s
}
};
let fp = seed.derive_identity().public_identity().fingerprint;
let fingerprint = fp.to_string();
info!(%fp, "identity loaded");
// Connect
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let client_config = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config).await?;
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
// Handshake
let _session = wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
Some(&alias),
)
.await?;
info!("connected to relay, handshake complete");
event_cb("connected", &format!("joined room {room}"));
// Audio I/O — VPIO (OS AEC) on macOS, plain CPAL otherwise.
// The audio handle must be stored in CallEngine to keep streams alive.
let (capture_ring, playout_ring, audio_handle): (_, _, Box<dyn std::any::Any + Send>) =
if _os_aec {
#[cfg(target_os = "macos")]
{
match wzp_client::audio_vpio::VpioAudio::start() {
Ok(v) => {
let cr = v.capture_ring().clone();
let pr = v.playout_ring().clone();
info!("using VoiceProcessingIO (OS AEC)");
(cr, pr, Box::new(v))
}
Err(e) => {
info!("VPIO failed ({e}), falling back to CPAL");
let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?;
let cr = capture.ring().clone();
let pr = playback.ring().clone();
(cr, pr, Box::new((capture, playback)))
}
}
}
#[cfg(not(target_os = "macos"))]
{
info!("OS AEC not available on this platform, using CPAL");
let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?;
let cr = capture.ring().clone();
let pr = playback.ring().clone();
(cr, pr, Box::new((capture, playback)))
}
} else {
let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?;
let cr = capture.ring().clone();
let pr = playback.ring().clone();
(cr, pr, Box::new((capture, playback)))
};
let running = Arc::new(AtomicBool::new(true));
let mic_muted = Arc::new(AtomicBool::new(false));
let spk_muted = Arc::new(AtomicBool::new(false));
let participants: Arc<Mutex<Vec<ParticipantInfo>>> = Arc::new(Mutex::new(vec![]));
let frames_sent = Arc::new(AtomicU64::new(0));
let frames_received = Arc::new(AtomicU64::new(0));
let audio_level = Arc::new(AtomicU32::new(0));
// Send task
let send_t = transport.clone();
let send_r = running.clone();
let send_mic = mic_muted.clone();
let send_fs = frames_sent.clone();
let send_level = audio_level.clone();
let send_drops = Arc::new(AtomicU64::new(0));
tokio::spawn(async move {
let config = CallConfig {
noise_suppression: false,
suppression_enabled: false,
..CallConfig::default()
};
let mut encoder = CallEncoder::new(&config);
encoder.set_aec_enabled(false); // OS AEC or none
let mut buf = vec![0i16; FRAME_SAMPLES];
loop {
if !send_r.load(Ordering::Relaxed) {
break;
}
if capture_ring.available() < FRAME_SAMPLES {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
continue;
}
capture_ring.read(&mut buf);
// Compute RMS audio level for UI meter
if !buf.is_empty() {
let sum_sq: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum();
let rms = (sum_sq / buf.len() as f64).sqrt() as u32;
send_level.store(rms, Ordering::Relaxed);
}
if send_mic.load(Ordering::Relaxed) {
buf.fill(0);
}
match encoder.encode_frame(&buf) {
Ok(pkts) => {
for pkt in &pkts {
if let Err(e) = send_t.send_media(pkt).await {
// Transient congestion (Blocked) — drop packet, keep going
send_drops.fetch_add(1, Ordering::Relaxed);
if send_drops.load(Ordering::Relaxed) <= 3 {
tracing::warn!("send_media error (dropping packet): {e}");
}
}
}
send_fs.fetch_add(1, Ordering::Relaxed);
}
Err(e) => error!("encode: {e}"),
}
}
});
// Recv task (direct playout)
let recv_t = transport.clone();
let recv_r = running.clone();
let recv_spk = spk_muted.clone();
let recv_fr = frames_received.clone();
tokio::spawn(async move {
let mut opus_dec = wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD);
let mut agc = wzp_codec::AutoGainControl::new();
let mut pcm = vec![0i16; FRAME_SAMPLES];
loop {
if !recv_r.load(Ordering::Relaxed) {
break;
}
match tokio::time::timeout(
std::time::Duration::from_millis(100),
recv_t.recv_media(),
)
.await
{
Ok(Ok(Some(pkt))) => {
if !pkt.header.is_repair {
if let Ok(n) = opus_dec.decode(&pkt.payload, &mut pcm) {
agc.process_frame(&mut pcm[..n]);
if !recv_spk.load(Ordering::Relaxed) {
playout_ring.write(&pcm[..n]);
}
}
}
recv_fr.fetch_add(1, Ordering::Relaxed);
}
Ok(Ok(None)) => break,
Ok(Err(e)) => {
let msg = e.to_string();
if msg.contains("closed") || msg.contains("reset") {
error!("recv fatal: {e}");
break;
}
// Transient error — continue
}
Err(_) => {}
}
}
});
// Signal task (presence)
let sig_t = transport.clone();
let sig_r = running.clone();
let sig_p = participants.clone();
let event_cb = Arc::new(event_cb);
let sig_cb = event_cb.clone();
tokio::spawn(async move {
loop {
if !sig_r.load(Ordering::Relaxed) {
break;
}
match tokio::time::timeout(
std::time::Duration::from_millis(200),
sig_t.recv_signal(),
)
.await
{
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate {
participants: parts,
..
}))) => {
let mut seen = std::collections::HashSet::new();
let unique: Vec<ParticipantInfo> = parts
.into_iter()
.filter(|p| seen.insert((p.fingerprint.clone(), p.alias.clone())))
.map(|p| ParticipantInfo {
fingerprint: p.fingerprint,
alias: p.alias,
})
.collect();
let count = unique.len();
*sig_p.lock().await = unique;
sig_cb("room-update", &format!("{count} participants"));
}
Ok(Ok(Some(_))) => {}
Ok(Ok(None)) => break,
Ok(Err(_)) => break,
Err(_) => {}
}
}
});
Ok(Self {
running,
mic_muted,
spk_muted,
participants,
frames_sent,
frames_received,
audio_level,
transport,
start_time: Instant::now(),
fingerprint,
_audio_handle: SyncWrapper(audio_handle),
})
}
pub fn toggle_mic(&self) -> bool {
let was = self.mic_muted.load(Ordering::Relaxed);
self.mic_muted.store(!was, Ordering::Relaxed);
!was
}
pub fn toggle_speaker(&self) -> bool {
let was = self.spk_muted.load(Ordering::Relaxed);
self.spk_muted.store(!was, Ordering::Relaxed);
!was
}
pub async fn status(&self) -> EngineStatus {
let participants = {
let parts = self.participants.lock().await;
parts
.iter()
.map(|p| ParticipantInfo {
fingerprint: p.fingerprint.clone(),
alias: p.alias.clone(),
})
.collect()
}; // lock dropped here
EngineStatus {
mic_muted: self.mic_muted.load(Ordering::Relaxed),
spk_muted: self.spk_muted.load(Ordering::Relaxed),
participants,
frames_sent: self.frames_sent.load(Ordering::Relaxed),
frames_received: self.frames_received.load(Ordering::Relaxed),
audio_level: self.audio_level.load(Ordering::Relaxed),
call_duration_secs: self.start_time.elapsed().as_secs_f64(),
fingerprint: self.fingerprint.clone(),
}
}
pub async fn stop(self) {
self.running.store(false, Ordering::SeqCst);
self.transport.close().await.ok();
}
}

View File

@@ -0,0 +1,241 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod engine;
use engine::CallEngine;
use serde::Serialize;
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
#[derive(Clone, Serialize)]
struct CallEvent {
kind: String,
message: String,
}
#[derive(Clone, Serialize)]
struct Participant {
fingerprint: String,
alias: Option<String>,
}
#[derive(Clone, Serialize)]
struct CallStatus {
active: bool,
mic_muted: bool,
spk_muted: bool,
participants: Vec<Participant>,
encode_fps: u64,
recv_fps: u64,
audio_level: u32,
call_duration_secs: f64,
fingerprint: String,
}
struct AppState {
engine: Mutex<Option<CallEngine>>,
}
/// Ping result with RTT and server identity hash.
#[derive(Clone, Serialize)]
struct PingResult {
rtt_ms: u32,
/// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
server_fingerprint: String,
}
/// Ping a relay to check if it's online, measure RTT, and get server identity.
#[tauri::command]
async fn ping_relay(relay: String) -> Result<PingResult, String> {
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
let _ = rustls::crypto::ring::default_provider().install_default();
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
let client_cfg = wzp_transport::client_config();
let start = std::time::Instant::now();
let conn_result = tokio::time::timeout(
std::time::Duration::from_secs(3),
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
)
.await;
// Always close endpoint to prevent resource leaks
endpoint.close(0u32.into(), b"done");
match conn_result {
Ok(Ok(conn)) => {
let rtt_ms = start.elapsed().as_millis() as u32;
let server_fingerprint = conn
.peer_identity()
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
.and_then(|certs| certs.first().map(|c| {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
c.as_ref().hash(&mut hasher);
let h = hasher.finish();
format!("{h:016x}")
}))
.unwrap_or_else(|| {
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
});
conn.close(0u32.into(), b"ping");
Ok(PingResult { rtt_ms, server_fingerprint })
}
Ok(Err(e)) => Err(format!("{e}")),
Err(_) => Err("timeout (3s)".into()),
}
}
/// Read fingerprint from ~/.wzp/identity without connecting.
#[tauri::command]
fn get_identity() -> Result<String, String> {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let path = std::path::PathBuf::from(home).join(".wzp").join("identity");
if path.exists() {
if let Ok(hex) = std::fs::read_to_string(&path) {
if let Ok(seed) = wzp_crypto::Seed::from_hex(hex.trim()) {
let fp = seed.derive_identity().public_identity().fingerprint;
return Ok(fp.to_string());
}
}
}
// No identity yet — generate one so we can show the fingerprint
let seed = wzp_crypto::Seed::generate();
let fp = seed.derive_identity().public_identity().fingerprint;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
std::fs::write(&path, hex).ok();
Ok(fp.to_string())
}
#[tauri::command]
async fn connect(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
relay: String,
room: String,
alias: String,
os_aec: bool,
) -> Result<String, String> {
let mut engine_lock = state.engine.lock().await;
if engine_lock.is_some() {
return Err("already connected".into());
}
let app_clone = app.clone();
match CallEngine::start(relay, room, alias, os_aec, move |event_kind, message| {
let _ = app_clone.emit(
"call-event",
CallEvent {
kind: event_kind.to_string(),
message: message.to_string(),
},
);
})
.await
{
Ok(eng) => {
*engine_lock = Some(eng);
Ok("connected".into())
}
Err(e) => Err(format!("{e}")),
}
}
#[tauri::command]
async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
let mut engine_lock = state.engine.lock().await;
if let Some(engine) = engine_lock.take() {
engine.stop().await;
Ok("disconnected".into())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
Ok(engine.toggle_mic())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
Ok(engine.toggle_speaker())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
let status = engine.status().await;
Ok(CallStatus {
active: true,
mic_muted: status.mic_muted,
spk_muted: status.spk_muted,
participants: status
.participants
.into_iter()
.map(|p| Participant {
fingerprint: p.fingerprint,
alias: p.alias,
})
.collect(),
encode_fps: status.frames_sent,
recv_fps: status.frames_received,
audio_level: status.audio_level,
call_duration_secs: status.call_duration_secs,
fingerprint: status.fingerprint,
})
} else {
Ok(CallStatus {
active: false,
mic_muted: false,
spk_muted: false,
participants: vec![],
encode_fps: 0,
recv_fps: 0,
audio_level: 0,
call_duration_secs: 0.0,
fingerprint: String::new(),
})
}
}
fn main() {
tracing_subscriber::fmt().init();
let state = Arc::new(AppState {
engine: Mutex::new(None),
});
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(state)
.invoke_handler(tauri::generate_handler![
ping_relay,
get_identity,
connect,
disconnect,
toggle_mic,
toggle_speaker,
get_status,
])
.run(tauri::generate_context!())
.expect("error while running WarzonePhone Desktop");
}

View 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
View File

@@ -0,0 +1,110 @@
/**
* Deterministic identicon generator — creates a unique symmetric pattern
* from a hex fingerprint string, similar to MetaMask's Jazzicon / Ethereum blockies.
*
* Returns an SVG data URL that can be used as an <img> src.
*/
function hashBytes(hex: string): number[] {
const clean = hex.replace(/[^0-9a-fA-F]/g, "");
const bytes: number[] = [];
for (let i = 0; i < clean.length; i += 2) {
bytes.push(parseInt(clean.substring(i, i + 2), 16));
}
// Pad to at least 16 bytes
while (bytes.length < 16) bytes.push(0);
return bytes;
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [
Math.round(f(0) * 255),
Math.round(f(8) * 255),
Math.round(f(4) * 255),
];
}
export function generateIdenticon(
fingerprint: string,
size: number = 36
): string {
const bytes = hashBytes(fingerprint);
// Derive colors from first bytes
const hue1 = (bytes[0] * 360) / 256;
const hue2 = ((bytes[1] * 360) / 256 + 120) % 360;
const [r1, g1, b1] = hslToRgb(hue1, 65, 35); // dark bg
const [r2, g2, b2] = hslToRgb(hue2, 70, 55); // bright fg
const bg = `rgb(${r1},${g1},${b1})`;
const fg = `rgb(${r2},${g2},${b2})`;
// 5x5 grid, left-right symmetric (only need 3 columns)
const grid: boolean[][] = [];
for (let y = 0; y < 5; y++) {
const row: boolean[] = [];
for (let x = 0; x < 3; x++) {
const byteIdx = 2 + y * 3 + x;
row.push(bytes[byteIdx % bytes.length] > 128);
}
// Mirror: col 3 = col 1, col 4 = col 0
grid.push([row[0], row[1], row[2], row[1], row[0]]);
}
// Render SVG
const cellSize = size / 5;
const r = size * 0.12; // border radius
let rects = "";
for (let y = 0; y < 5; y++) {
for (let x = 0; x < 5; x++) {
if (grid[y][x]) {
rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`;
}
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" rx="${r}" fill="${bg}"/>
${rects}
</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
/**
* Create an <img> element with the identicon.
* Click copies the fingerprint to clipboard.
*/
export function createIdenticonEl(
fingerprint: string,
size: number = 36,
clickToCopy: boolean = true
): HTMLImageElement {
const img = document.createElement("img");
img.src = generateIdenticon(fingerprint, size);
img.width = size;
img.height = size;
img.style.borderRadius = `${size * 0.12}px`;
img.style.cursor = clickToCopy ? "pointer" : "default";
img.title = fingerprint;
if (clickToCopy && fingerprint) {
img.addEventListener("click", (e) => {
e.stopPropagation();
navigator.clipboard.writeText(fingerprint).then(() => {
img.style.outline = "2px solid #4ade80";
setTimeout(() => {
img.style.outline = "";
}, 600);
});
});
}
return img;
}

591
desktop/src/main.ts Normal file
View File

@@ -0,0 +1,591 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { generateIdenticon, createIdenticonEl } from "./identicon";
// ── Elements ──
const connectScreen = document.getElementById("connect-screen")!;
const callScreen = document.getElementById("call-screen")!;
const roomInput = document.getElementById("room") as HTMLInputElement;
const aliasInput = document.getElementById("alias") as HTMLInputElement;
const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement;
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
const connectError = document.getElementById("connect-error")!;
const roomName = document.getElementById("room-name")!;
const callTimer = document.getElementById("call-timer")!;
const callStatus = document.getElementById("call-status")!;
const levelBar = document.getElementById("level-bar")!;
const participantsDiv = document.getElementById("participants")!;
const micBtn = document.getElementById("mic-btn")!;
const micIcon = document.getElementById("mic-icon")!;
const spkBtn = document.getElementById("spk-btn")!;
const spkIcon = document.getElementById("spk-icon")!;
const hangupBtn = document.getElementById("hangup-btn")!;
const statsDiv = document.getElementById("stats")!;
const myFingerprintEl = document.getElementById("my-fingerprint")!;
const myIdenticonEl = document.getElementById("my-identicon")!;
const recentRoomsDiv = document.getElementById("recent-rooms")!;
// Relay button
const relaySelected = document.getElementById("relay-selected")!;
const relayDot = document.getElementById("relay-dot")!;
const relayLabel = document.getElementById("relay-label")!;
// Relay dialog
const relayDialog = document.getElementById("relay-dialog")!;
const relayDialogClose = document.getElementById("relay-dialog-close")!;
const relayDialogList = document.getElementById("relay-dialog-list")!;
const relayAddName = document.getElementById("relay-add-name") as HTMLInputElement;
const relayAddAddr = document.getElementById("relay-add-addr") as HTMLInputElement;
const relayAddBtn = document.getElementById("relay-add-btn")!;
// Settings
const settingsPanel = document.getElementById("settings-panel")!;
const settingsClose = document.getElementById("settings-close")!;
const settingsSave = document.getElementById("settings-save")!;
const settingsBtnHome = document.getElementById("settings-btn-home")!;
const settingsBtnCall = document.getElementById("settings-btn-call")!;
const sRoom = document.getElementById("s-room") as HTMLInputElement;
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
const sAgc = document.getElementById("s-agc") as HTMLInputElement;
const sFingerprint = document.getElementById("s-fingerprint")!;
const sRecentRooms = document.getElementById("s-recent-rooms")!;
const sClearRecent = document.getElementById("s-clear-recent")!;
let statusInterval: number | null = null;
let myFingerprint = "";
let userDisconnected = false;
// ── Data types ──
interface RelayServer {
name: string;
address: string;
rtt?: number | null;
serverFingerprint?: string | null; // from ping
knownFingerprint?: string | null; // saved TOFU fingerprint
}
interface RecentRoom { relay: string; room: string; }
interface Settings {
relays: RelayServer[];
selectedRelay: number;
room: string;
alias: string;
osAec: boolean;
agc: boolean;
recentRooms: RecentRoom[];
}
function loadSettings(): Settings {
const defaults: Settings = {
relays: [{ name: "Default", address: "193.180.213.68:4433" }],
selectedRelay: 0, room: "android", alias: "",
osAec: true, agc: true, recentRooms: [],
};
try {
const raw = localStorage.getItem("wzp-settings");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.relay && !parsed.relays) {
parsed.relays = [{ name: "Default", address: parsed.relay }];
parsed.selectedRelay = 0;
delete parsed.relay;
}
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
}
return { ...defaults, ...parsed };
}
} catch {}
return defaults;
}
function saveSettingsObj(s: Settings) {
localStorage.setItem("wzp-settings", JSON.stringify(s));
}
function getSelectedRelay(): RelayServer | undefined {
const s = loadSettings();
return s.relays[s.selectedRelay];
}
// ── Helpers ──
function escapeHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// ── Lock status ──
type LockStatus = "verified" | "new" | "changed" | "offline" | "unknown";
function lockStatus(relay: RelayServer): LockStatus {
if (relay.rtt === undefined || relay.rtt === null) return "unknown";
if (relay.rtt < 0) return "offline";
if (!relay.serverFingerprint) return "new";
if (!relay.knownFingerprint) return "new"; // first time
if (relay.serverFingerprint === relay.knownFingerprint) return "verified";
return "changed";
}
function lockIcon(status: LockStatus): string {
switch (status) {
case "verified": return "🔒";
case "new": return "🔓";
case "changed": return "⚠️";
case "offline": return "🔴";
case "unknown": return "⚪";
}
}
function lockColor(status: LockStatus): string {
switch (status) {
case "verified": return "var(--green)";
case "new": return "var(--yellow)";
case "changed": return "var(--red)";
case "offline": return "var(--red)";
case "unknown": return "var(--text-dim)";
}
}
// ── Apply settings ──
function applySettings() {
const s = loadSettings();
roomInput.value = s.room;
aliasInput.value = s.alias;
osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms);
renderRelayButton();
}
// ── Relay button ──
function renderRelayButton() {
const s = loadSettings();
const sel = s.relays[s.selectedRelay];
if (sel) {
const ls = lockStatus(sel);
relayDot.textContent = lockIcon(ls);
relayDot.className = "relay-lock";
relayLabel.textContent = `${sel.name} (${sel.address})`;
} else {
relayDot.textContent = "⚪";
relayDot.className = "relay-lock";
relayLabel.textContent = "No relay configured";
}
}
relaySelected.addEventListener("click", () => openRelayDialog());
// ── Relay dialog ──
function openRelayDialog() {
renderRelayDialogList();
relayAddName.value = "";
relayAddAddr.value = "";
relayDialog.classList.remove("hidden");
}
function closeRelayDialog() {
relayDialog.classList.add("hidden");
renderRelayButton();
}
function renderRelayDialogList() {
const s = loadSettings();
relayDialogList.innerHTML = "";
s.relays.forEach((r, i) => {
const item = document.createElement("div");
item.className = `relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}`;
const ls = lockStatus(r);
const fp = r.serverFingerprint || r.address;
// Identicon
const icon = createIdenticonEl(fp, 32, true);
icon.title = r.serverFingerprint
? `Server: ${r.serverFingerprint}\nClick to copy`
: `No fingerprint yet`;
item.appendChild(icon);
// Info
const info = document.createElement("div");
info.className = "relay-info";
info.innerHTML = `
<div class="relay-name">${escapeHtml(r.name)}</div>
<div class="relay-addr">${escapeHtml(r.address)}</div>
`;
item.appendChild(info);
// Lock + RTT
const meta = document.createElement("div");
meta.className = "relay-meta";
const rttStr = r.rtt !== undefined && r.rtt !== null
? (r.rtt < 0 ? "offline" : `${r.rtt}ms`)
: "";
meta.innerHTML = `
<span class="relay-lock-icon" style="color:${lockColor(ls)}">${lockIcon(ls)}</span>
<span class="relay-rtt">${rttStr}</span>
`;
item.appendChild(meta);
// Delete button
const del = document.createElement("button");
del.className = "remove";
del.textContent = "×";
del.addEventListener("click", (e) => {
e.stopPropagation();
const s = loadSettings();
s.relays.splice(i, 1);
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
saveSettingsObj(s);
renderRelayDialogList();
renderRelayButton();
});
item.appendChild(del);
// Click to select
item.addEventListener("click", () => {
const s = loadSettings();
s.selectedRelay = i;
// TOFU: if first time seeing this server, trust its fingerprint
if (r.serverFingerprint && !r.knownFingerprint) {
s.relays[i].knownFingerprint = r.serverFingerprint;
}
saveSettingsObj(s);
renderRelayDialogList();
renderRelayButton();
});
relayDialogList.appendChild(item);
});
}
relayAddBtn.addEventListener("click", () => {
const name = relayAddName.value.trim();
const addr = relayAddAddr.value.trim();
if (!addr) return;
const s = loadSettings();
s.relays.push({ name: name || addr, address: addr });
saveSettingsObj(s);
relayAddName.value = "";
relayAddAddr.value = "";
renderRelayDialogList();
pingAllRelays();
});
relayDialogClose.addEventListener("click", closeRelayDialog);
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
// ── Ping ──
interface PingResult { rtt_ms: number; server_fingerprint: string; }
async function pingAllRelays() {
const s = loadSettings();
for (let i = 0; i < s.relays.length; i++) {
const r = s.relays[i];
try {
const result: PingResult = await invoke("ping_relay", { relay: r.address });
r.rtt = result.rtt_ms;
r.serverFingerprint = result.server_fingerprint;
// TOFU: auto-save fingerprint on first contact
if (!r.knownFingerprint) {
r.knownFingerprint = result.server_fingerprint;
}
} catch {
r.rtt = -1;
}
}
saveSettingsObj(s);
renderRelayButton();
if (!relayDialog.classList.contains("hidden")) renderRelayDialogList();
}
// ── Recent rooms ──
function renderRecentRooms(rooms: RecentRoom[]) {
recentRoomsDiv.innerHTML = rooms
.map((r) => `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`)
.join("");
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
el.addEventListener("click", () => {
const ds = (el as HTMLElement).dataset;
roomInput.value = ds.room || "";
const s = loadSettings();
const idx = s.relays.findIndex((r) => r.address === ds.relay);
if (idx >= 0) { s.selectedRelay = idx; saveSettingsObj(s); renderRelayButton(); }
});
});
}
// ── Init ──
applySettings();
setTimeout(pingAllRelays, 300);
// Load fingerprint + render identicon
(async () => {
try {
const fp: string = await invoke("get_identity");
myFingerprint = fp;
myFingerprintEl.textContent = fp;
myFingerprintEl.style.cursor = "pointer";
myFingerprintEl.addEventListener("click", () => {
navigator.clipboard.writeText(fp).then(() => {
const orig = myFingerprintEl.textContent;
myFingerprintEl.textContent = "Copied!";
setTimeout(() => { myFingerprintEl.textContent = orig; }, 1000);
});
});
// Identicon next to fingerprint
const icon = createIdenticonEl(fp, 28, true);
myIdenticonEl.innerHTML = "";
myIdenticonEl.appendChild(icon);
} catch {}
})();
// ── Connect ──
connectBtn.addEventListener("click", doConnect);
[roomInput, aliasInput].forEach((el) =>
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
);
async function doConnect() {
const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; }
// Warn on fingerprint mismatch
const ls = lockStatus(relay);
if (ls === "changed") {
if (!confirm(`Server fingerprint has changed!\n\nKnown: ${relay.knownFingerprint}\nNew: ${relay.serverFingerprint}\n\nThis could indicate a man-in-the-middle attack. Continue?`)) {
return;
}
// User accepted — update known fingerprint
const s = loadSettings();
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
saveSettingsObj(s);
}
// Don't block connect on offline — ping may have failed transiently
connectError.textContent = "";
connectBtn.disabled = true;
connectBtn.textContent = "Connecting...";
userDisconnected = false;
const s = loadSettings();
s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked;
const room = roomInput.value.trim();
if (room) {
const entry: RecentRoom = { relay: relay.address, room };
s.recentRooms = [entry, ...s.recentRooms.filter((r) => !(r.relay === relay.address && r.room === room))].slice(0, 5);
}
saveSettingsObj(s);
try {
await invoke("connect", {
relay: relay.address, room: roomInput.value,
alias: aliasInput.value, osAec: osAecCheckbox.checked,
});
showCallScreen();
} catch (e: any) {
connectError.textContent = String(e);
connectBtn.disabled = false;
connectBtn.textContent = "Connect";
}
}
function showCallScreen() {
connectScreen.classList.add("hidden");
callScreen.classList.remove("hidden");
roomName.textContent = roomInput.value;
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
}
function showConnectScreen() {
callScreen.classList.add("hidden");
connectScreen.classList.remove("hidden");
connectBtn.disabled = false;
connectBtn.textContent = "Connect";
levelBar.style.width = "0%";
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
}
// ── Mute / hangup ──
micBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
});
spkBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_speaker"); spkBtn.classList.toggle("muted", m); spkIcon.textContent = m ? "Spk Off" : "Spk"; } catch {}
});
hangupBtn.addEventListener("click", async () => {
userDisconnected = true;
try { await invoke("disconnect"); } catch {}
showConnectScreen();
});
document.addEventListener("keydown", (e) => {
if (callScreen.classList.contains("hidden")) return;
if ((e.target as HTMLElement).tagName === "INPUT") return;
if (e.key === "m") micBtn.click();
if (e.key === "s") spkBtn.click();
if (e.key === "q") hangupBtn.click();
});
// ── Status polling ──
interface CallStatusI {
active: boolean; mic_muted: boolean; spk_muted: boolean;
participants: { fingerprint: string; alias: string | null }[];
encode_fps: number; recv_fps: number; audio_level: number;
call_duration_secs: number; fingerprint: string;
}
function formatDuration(secs: number): string {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
let reconnectAttempts = 0;
async function pollStatus() {
try {
const st: CallStatusI = await invoke("get_status");
if (!st.active) {
if (!userDisconnected && reconnectAttempts < 5) {
reconnectAttempts++;
callStatus.className = "status-dot reconnecting";
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/5)...`;
const relay = getSelectedRelay();
if (relay) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
setTimeout(async () => {
try {
await invoke("connect", { relay: relay.address, room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked });
reconnectAttempts = 0; callStatus.className = "status-dot";
} catch {}
}, delay);
}
return;
}
reconnectAttempts = 0; showConnectScreen(); return;
}
reconnectAttempts = 0;
if (st.fingerprint) myFingerprint = st.fingerprint;
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
spkBtn.classList.toggle("muted", st.spk_muted);
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
callTimer.textContent = formatDuration(st.call_duration_secs);
const rms = st.audio_level;
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
levelBar.style.width = `${pct}%`;
// Participants with identicons
if (st.participants.length === 0) {
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
} else {
participantsDiv.innerHTML = "";
st.participants.forEach((p) => {
const name = p.alias || "Anonymous";
const fp = p.fingerprint || "";
const isMe = fp && myFingerprint.includes(fp);
const row = document.createElement("div");
row.className = "participant";
// Identicon avatar
const icon = createIdenticonEl(fp || name, 36, true);
if (isMe) icon.style.outline = "2px solid var(--accent)";
row.appendChild(icon);
const info = document.createElement("div");
info.className = "info";
info.innerHTML = `
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
<div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
`;
row.appendChild(info);
participantsDiv.appendChild(row);
});
}
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
} catch {}
}
listen("call-event", (event: any) => {
const { kind } = event.payload;
if (kind === "room-update") pollStatus();
if (kind === "disconnected" && !userDisconnected) pollStatus();
});
// ── Settings ──
function openSettings() {
const s = loadSettings();
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
sFingerprint.textContent = myFingerprint || "(loading...)";
renderSettingsRecentRooms(s.recentRooms);
settingsPanel.classList.remove("hidden");
}
function closeSettings() { settingsPanel.classList.add("hidden"); }
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
if (rooms.length === 0) {
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
return;
}
sRecentRooms.innerHTML = rooms.map((r, i) => `
<div class="recent-room-item">
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
<button class="remove" data-idx="${i}">×</button>
</div>`).join("");
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const s = loadSettings();
s.recentRooms.splice(idx, 1);
saveSettingsObj(s);
renderSettingsRecentRooms(s.recentRooms);
});
});
}
settingsBtnHome.addEventListener("click", openSettings);
settingsBtnCall.addEventListener("click", openSettings);
settingsClose.addEventListener("click", closeSettings);
settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) closeSettings(); });
settingsSave.addEventListener("click", () => {
const s = loadSettings();
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
saveSettingsObj(s);
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms);
closeSettings();
});
sClearRecent.addEventListener("click", () => {
const s = loadSettings();
s.recentRooms = [];
saveSettingsObj(s);
renderSettingsRecentRooms([]);
renderRecentRooms([]);
});
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
e.preventDefault();
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
}
if (e.key === "Escape") {
if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
}
});

653
desktop/src/style.css Normal file
View File

@@ -0,0 +1,653 @@
:root {
--bg: #0f0f1a;
--surface: #1a1a2e;
--surface2: #222244;
--primary: #0f3460;
--accent: #e94560;
--text: #eee;
--text-dim: #777;
--green: #4ade80;
--red: #ef4444;
--yellow: #facc15;
--radius: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
user-select: none;
-webkit-user-select: none;
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 20px;
}
.hidden { display: none !important; }
/* ── Connect screen ── */
#connect-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 20px;
}
#connect-screen h1 {
font-size: 26px;
font-weight: 700;
letter-spacing: 1px;
}
.subtitle {
font-size: 13px;
color: var(--text-dim);
margin-top: -12px;
letter-spacing: 2px;
text-transform: uppercase;
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 320px;
}
.form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form input[type="text"] {
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.form input[type="text"]:focus {
border-color: var(--accent);
}
/* ── Relay button ── */
.relay-selected {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
cursor: pointer;
text-align: left;
transition: border-color 0.2s;
}
.relay-selected:hover { border-color: var(--accent); }
.relay-lock {
font-size: 14px;
flex-shrink: 0;
}
.relay-selected .arrow {
margin-left: auto;
font-size: 10px;
color: var(--text-dim);
}
.dot.green { background: var(--green); }
.dot.yellow { background: var(--yellow); }
.dot.red { background: var(--red); }
.dot.gray { background: #555; }
/* ── Relay dialog ── */
#relay-dialog {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.relay-dialog-card {
max-width: 360px;
width: 100%;
}
.relay-dialog-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
}
.relay-dialog-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border-radius: 8px;
padding: 8px 12px;
}
.relay-dialog-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.relay-dialog-item { cursor: pointer; transition: background 0.1s; }
.relay-dialog-item:hover { background: var(--surface2); }
.relay-dialog-item.selected { background: var(--primary); border: 1px solid var(--accent); }
.relay-dialog-item .relay-info { flex: 1; min-width: 0; overflow: hidden; }
.relay-dialog-item .relay-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; overflow: hidden; text-overflow: ellipsis; }
.relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; }
.relay-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.relay-lock-icon { font-size: 16px; }
.relay-meta .relay-rtt { font-size: 10px; color: var(--text-dim); }
.relay-dialog-item .remove {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
}
.relay-dialog-item .remove:hover { color: var(--red); }
.relay-add-row {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
border-top: 1px solid #333;
padding-top: 12px;
}
.relay-add-inputs {
display: flex;
gap: 6px;
}
.relay-add-row input {
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
flex: 1;
min-width: 0;
}
.relay-add-row input:focus { border-color: var(--accent); }
.relay-add-row .primary {
padding: 10px;
font-size: 14px;
}
.form-row {
display: flex;
gap: 16px;
align-items: center;
}
.checkbox {
flex-direction: row !important;
align-items: center;
gap: 8px !important;
cursor: pointer;
font-size: 13px !important;
}
.checkbox input { width: 16px; height: 16px; }
button.primary {
background: var(--accent);
color: white;
border: none;
border-radius: 8px;
padding: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
margin-top: 4px;
}
button.primary:hover { opacity: 0.9; }
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error {
color: var(--red);
font-size: 13px;
min-height: 18px;
}
.identity-info {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.fp-display {
font-family: monospace;
font-size: 11px;
color: var(--text-dim);
}
.recent-rooms {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 320px;
}
.recent-room {
background: var(--surface);
border: 1px solid #333;
border-radius: 16px;
padding: 4px 12px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.2s;
}
.recent-room:hover {
border-color: var(--accent);
color: var(--text);
}
/* ── Call screen ── */
#call-screen {
display: flex;
flex-direction: column;
flex: 1;
gap: 16px;
}
.call-header {
text-align: center;
padding: 8px;
}
.room-name {
font-size: 20px;
font-weight: 600;
}
.call-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
display: inline-block;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-dot.reconnecting {
background: var(--yellow);
animation: blink 0.5s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.1; }
}
.call-timer {
font-size: 14px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
/* ── Audio level meter ── */
.level-meter {
height: 4px;
background: var(--surface);
border-radius: 2px;
overflow: hidden;
}
.level-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--green) 0%, var(--yellow) 60%, var(--red) 100%);
border-radius: 2px;
transition: width 0.1s ease-out;
}
/* ── Participants ── */
.participants {
background: var(--surface);
border-radius: var(--radius);
padding: 12px 16px;
flex: 1;
overflow-y: auto;
min-height: 80px;
}
.participants-empty {
color: var(--text-dim);
font-size: 13px;
text-align: center;
padding: 20px 0;
}
.participant {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #ffffff08;
}
.participant:last-child { border-bottom: none; }
.participant .avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.participant .avatar.me {
background: var(--accent);
}
.participant .info { flex: 1; min-width: 0; }
.participant .name {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.participant .fp {
font-size: 10px;
color: var(--text-dim);
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
}
.participant .you-badge {
font-size: 10px;
color: var(--accent);
background: #e9456020;
padding: 1px 6px;
border-radius: 8px;
}
/* ── Controls ── */
.controls {
display: flex;
justify-content: center;
gap: 24px;
padding: 12px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
background: var(--surface2);
color: var(--text);
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
cursor: pointer;
transition: all 0.15s;
font-size: 13px;
font-weight: 600;
}
.control-btn:hover { background: var(--primary); }
.control-btn.muted {
background: var(--red);
color: white;
}
.control-btn.hangup {
background: var(--red);
color: white;
width: 64px;
height: 64px;
font-size: 14px;
}
.control-btn.hangup:hover { opacity: 0.85; }
/* ── Stats ── */
.stats {
text-align: center;
font-size: 10px;
color: var(--text-dim);
font-family: monospace;
padding: 4px;
}
/* ── Icon button ── */
.icon-btn {
background: none;
border: 1px solid #444;
border-radius: 8px;
color: var(--text-dim);
font-size: 18px;
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.icon-btn:hover { border-color: var(--accent); color: var(--text); }
.icon-btn.small { width: 28px; height: 28px; font-size: 14px; }
.call-header-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* ── Settings panel ── */
#settings-panel {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.settings-card {
background: var(--bg);
border: 1px solid #333;
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 380px;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.settings-header h2 {
font-size: 18px;
font-weight: 600;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.settings-section h3 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.settings-section label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.settings-section input[type="text"] {
background: var(--surface);
border: 1px solid #333;
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 14px;
outline: none;
}
.settings-section input[type="text"]:focus {
border-color: var(--accent);
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.setting-label {
font-size: 12px;
color: var(--text-dim);
}
.fp-display-large {
font-family: monospace;
font-size: 12px;
color: var(--text);
word-break: break-all;
}
.recent-rooms-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.recent-room-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--surface);
border-radius: 8px;
padding: 6px 10px;
font-size: 13px;
}
.recent-room-item .remove {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 16px;
}
.recent-room-item .remove:hover { color: var(--red); }
.secondary-btn {
background: var(--surface);
border: 1px solid #444;
border-radius: 8px;
padding: 8px;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.secondary-btn:hover { border-color: var(--accent); color: var(--text); }

15
desktop/tsconfig.json Normal file
View 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
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
export default defineConfig({
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: "esnext",
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
});

122
scripts/build-linux-notify.sh Executable file
View 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/"