19 Commits

Author SHA1 Message Date
Siavash Sameni
e9e0d8d212 fix: replace tracing-android with android_logger (no sharded_slab SIGSEGV)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
tracing_subscriber::registry() allocates a sharded_slab which causes
SIGSEGV on Android 16 MTE devices during nativeInit. catch_unwind
can't catch SIGSEGV (it's a signal, not a panic).

Replace with android_logger (lightweight, no large allocations) +
tracing-log bridge so tracing::info! macros still work via logcat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:01:07 +04:00
Siavash Sameni
4e0356ef37 feat: desktop-style UI rewrite — dark theme, relay manager, identicons
Some checks failed
Mirror to GitHub / mirror (push) Failing after 42s
Build Release Binaries / build-amd64 (push) Failing after 3m34s
Complete InCallScreen rewrite matching desktop layout:

Connect screen:
- Dark theme (matching desktop CSS vars)
- Relay button with lock icon + RTT, opens Manage Relays dialog
- Room/Alias text fields, AEC checkbox, settings gear
- Full-width red Connect button
- Identity with identicon + fingerprint
- Recent rooms grouped and colored by server

Manage Relays dialog:
- Server list with identicons, lock icons, RTT, delete
- Selected server highlighted with accent border
- Add relay inputs + button

In-call screen:
- Room name + gear, green dot + timer
- Gradient audio level meter
- Participant card with identicons + fingerprints
- Mic / End / Spk controls
- TX/RX stats

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 05:38:57 +04:00
Siavash Sameni
264ef9c4d4 feat: relay ping with RTT, server TOFU, lock icons (Phase 2 backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Rust JNI:
- nativePingRelay: QUIC connect with 3s timeout, returns RTT + server
  certificate fingerprint as JSON. Static method, no engine needed.

Kotlin:
- WzpEngine.pingRelay() static wrapper
- SettingsRepository: TOFU fingerprint persistence (tofu_{address} keys)
- CallViewModel: pingAllServers() coroutine, lockStatus() helper,
  PingResult/LockStatus data types
- InCallScreen: server chips show lock icon + RTT color (green/yellow),
  "Ping All" button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:43:53 +04:00
Siavash Sameni
a9adb5cfd7 feat: identicons, tap-to-copy fingerprint, recent rooms (Phase 1 backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m47s
Backport from desktop client to Android:

Identicons:
- New Identicon.kt composable: deterministic 5x5 symmetric Canvas pattern
  from fingerprint hash (same algorithm as desktop identicon.ts)
- Participant list shows identicon + name + tappable fingerprint
- Settings page shows identicon next to fingerprint

CopyableFingerprint:
- Tap any fingerprint text to copy to clipboard with Toast feedback
- Used in participant list and settings page

Recent rooms:
- SettingsRepository: persists last 5 (relay, room) pairs
- CallViewModel: saves on startCall, exposes as StateFlow
- InCallScreen: clickable chips that fill room + select matching server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:37:46 +04:00
Siavash Sameni
a39b074d6e fix: DirectByteBuffer as class field — survives ART JIT OSR
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m58s
Previous attempt allocated DirectByteBuffer as local variables inside
runCapture/runPlayout. ART's JIT On-Stack Replacement nulled them
when recompiling the hot loop mid-execution.

Fix: allocate as class fields on AudioPipeline (captureDirectBuf,
playoutDirectBuf). Object fields live on the heap, immune to OSR
stack frame replacement.

Eliminates JNI array copies (GetShortArrayRegion/SetShortArrayRegion)
from the audio hot path, preventing ART GC SIGBUS crashes on
Android 16 with concurrent mark-compact GC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:22:54 +04:00
Siavash Sameni
9cab6e2347 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:13:29 +04:00
Siavash Sameni
5e93cb74f2 fix: filter tracing to INFO for wzp crates, WARN for jni crate
Some checks failed
Mirror to GitHub / mirror (push) Failing after 38s
Build Release Binaries / build-amd64 (push) Failing after 4m7s
The jni crate emits VERBOSE logs for every JNI method lookup (~10 lines
per call, 100+ calls/sec on audio threads). This floods logcat, consumes
CPU, and triggers system kills. Filter to only show INFO+ for our crates
and WARN+ for everything else.

Also fix build script: clean full Rust target to ensure libc++_shared.so
is always copied by cargo-ndk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:37:29 +04:00
Siavash Sameni
b56b4a759c revert: use ShortArray audio path (DirectByteBuffer causes null ptr crash)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m58s
DirectByteBuffer.clear() crashes with null pointer in ART's JIT OSR
compiled code on Android 16. Revert AudioPipeline to use the original
ShortArray writeAudio/readAudio path.

The DirectByteBuffer JNI functions remain in WzpEngine.kt and
jni_bridge.rs for future use once the OSR issue is resolved.

The original SIGBUS from ART GC is rare (~1 crash per 8 min call)
and doesn't warrant the DirectByteBuffer approach until we can
allocate the buffer as a class field outside the hot loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:17:15 +04:00
Siavash Sameni
6f99841cc7 fix: cloud build script — filter by server name, rsync upload, cx33
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m57s
- Filter hcloud by SERVER_NAME to avoid touching other servers
- Use rsync instead of tar (handles submodules, no macOS xattr spam)
- Default server type cx33
- Release APK failure is non-fatal (debug APK still produced)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:00:10 +04:00
Siavash Sameni
3b0811ce2e 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:49:59 +04:00
Siavash Sameni
9eed94850d fix: DirectByteBuffer audio path — eliminate JNI array copies
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m43s
Adds nativeWriteAudioDirect / nativeReadAudioDirect JNI functions
that accept a DirectByteBuffer instead of ShortArray. The buffer's
native memory is accessed directly by Rust via pointer — no
GetShortArrayRegion / SetShortArrayRegion, no GC-managed array
copies on the audio hot path.

This fixes SIGBUS crashes on Android 16 where ART's concurrent
mark-compact GC crashes when flipping thread roots during JNI
array operations on MAX_PRIORITY audio threads.

Old ShortArray methods kept for backward compatibility.
AudioPipeline switched to use Direct variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:29:08 +04:00
Siavash Sameni
5e9718aeb2 docs: incident report — SIGBUS in ART GC during audio JNI calls
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m37s
Android 16's concurrent mark-compact GC crashes when flipping
thread roots on our MAX_PRIORITY audio threads during JNI calls
(AudioRecord.read / AudioTrack.write). Not our code — all crash
frames are in libart.so.

Proposed fixes:
- Short term: DirectByteBuffer to reduce JNI transitions
- Long term: Oboe native audio from Rust (no JNI, no GC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:21:32 +04:00
Siavash Sameni
3093933602 fix: build script works on Ubuntu 24.04 (cmake 3.28) too
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m48s
cmake 3.28 works when ANDROID_NDK is set (not just ANDROID_NDK_HOME).
Relaxed version check from <=3.26 to <=3.30.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:00:06 +04:00
Siavash Sameni
4c6c909732 feat: comprehensive Android build script for Debian 12
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m56s
Documents WHY each version is pinned:
- cmake 3.25: 3.27+ rewrote Android-Determine.cmake with bugs
- NDK 26.1: NDK 27 scudo crashes on MTE devices (Nothing A059)
- JDK 17: Gradle 8.5 + AGP 8.2.0 official support
- ANDROID_NDK: cmake checks this, not ANDROID_NDK_HOME

Idempotent, works from clone or existing tree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:37:12 +04:00
Siavash Sameni
33fab9a049 fix: vec allocation for AudioRing, catch_unwind on tracing init, profiling
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m49s
- AudioRing: use vec![].into_boxed_slice() instead of Box::new([]) to
  avoid 32KB stack allocation that crashes scudo on Android
- JNI bridge: wrap tracing_subscriber init in catch_unwind to survive
  sharded_slab allocation failures on some devices
- Engine: per-step encode profiling (avg_agc_us, avg_opus_us, avg_fec_us,
  avg_send_us) logged every 5 seconds in send stats

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:46 +04:00
Siavash Sameni
31d2306915 feat: per-step encode profiling in send task stats
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m48s
Adds average microsecond timings for each encode step:
- avg_agc_us: AGC processing
- avg_opus_us: Opus encoding
- avg_fec_us: FEC encode + repair generation
- avg_send_us: QUIC send_media
- avg_total_us: sum of above

Logged every 5 seconds in send stats. Resets each interval.
Use to identify which step is bottlenecking the encode loop
on devices where fps drops below 50.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:18:33 +04:00
Siavash Sameni
4af7c5f94c fix: AudioRing cursor desync + capture thread use-after-free
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m56s
AudioRing (reader-detects-lap architecture):
- Writer NEVER touches read_pos — fixes SPSC invariant violation
- Reader self-corrects when lapped (snaps read_pos forward)
- Power-of-2 capacity (16384 = 341ms) with bitmask indexing
- Added overflow_count and underrun_count diagnostics
- Wired ring health into engine stats and periodic logging

Capture thread use-after-free (drain latch):
- Added CountDownLatch(2) to AudioPipeline
- Audio threads count down after exiting their loops
- teardown() awaits latch (200ms timeout) before destroy()
- Guarantees no in-flight JNI calls when native handle is freed
- stopAudio() no longer nulls pipeline (teardown handles it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:28:34 +04:00
Claude
6597b5bd86 docs: incident report + fix spec for capture thread use-after-free crash
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3s
SIGSEGV on hangup: capture thread calls writeAudio() via JNI after
teardown() has freed the native engine handle. TOCTOU race between
the nativeHandle==0L check and destroy() on the ViewModel thread.

Fix: CountDownLatch(2) — audio threads count down after exiting loops,
teardown() awaits before destroy(). 2 Kotlin files, no Rust changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:21:35 +00:00
Claude
ae9d8526dd docs: implementation spec for AudioRing SPSC desync fix
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m51s
Complete spec for fixing the playout ring buffer cursor race that
causes 12-16s bidirectional silence mid-call. Includes exact code,
memory ordering rationale, unit tests, and verification steps.

Any agent can implement from this document alone.

See also: debug/INCIDENT-2026-04-06-playout-ring-desync.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:16:47 +00:00
58 changed files with 3039 additions and 14359 deletions

3286
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

@@ -19,6 +19,8 @@ import java.io.FileOutputStream
import java.io.OutputStreamWriter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.math.pow
import kotlin.math.sqrt
@@ -59,6 +61,19 @@ class AudioPipeline(private val context: Context) {
private var captureThread: Thread? = null
private var playoutThread: Thread? = null
// DirectByteBuffers for zero-copy JNI audio transfer.
// Allocated as class fields (NOT locals) because ART's JIT OSR
// can null local variables when it replaces the stack frame mid-loop.
// These survive OSR because they're on the heap.
private val captureDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
private val playoutDirectBuf: ByteBuffer =
ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN)
/** Latch counted down by each audio thread after exiting its loop.
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
private var drainLatch: CountDownLatch? = null
private val debugDir: File by lazy {
File(context.cacheDir, "wzp_debug").also { it.mkdirs() }
}
@@ -66,9 +81,11 @@ class AudioPipeline(private val context: Context) {
fun start(engine: WzpEngine) {
if (running) return
running = true
drainLatch = CountDownLatch(2) // one for capture, one for playout
captureThread = Thread({
runCapture(engine)
drainLatch?.countDown() // signal: capture loop exited, no more JNI calls
// Park thread forever — exiting triggers a libcrypto TLS destructor
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
parkThread()
@@ -80,6 +97,7 @@ class AudioPipeline(private val context: Context) {
playoutThread = Thread({
runPlayout(engine)
drainLatch?.countDown() // signal: playout loop exited
parkThread()
}, "wzp-playout").apply {
isDaemon = true
@@ -92,10 +110,20 @@ class AudioPipeline(private val context: Context) {
fun stop() {
running = false
// Don't join threads are parked as daemons to avoid native TLS crash
// Don't join threads — they are parked as daemons to avoid native TLS crash.
// Don't null thread refs or drainLatch — teardown() needs awaitDrain().
Log.i(TAG, "audio pipeline stopped (running=false)")
}
/** Block until both audio threads have exited their loops (max 200ms).
* After this returns, no more JNI calls to the engine will be made. */
fun awaitDrain(): Boolean {
val ok = drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
if (!ok) Log.w(TAG, "awaitDrain: audio threads did not drain in 200ms")
captureThread = null
playoutThread = null
Log.i(TAG, "audio pipeline stopped")
drainLatch = null
return ok
}
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
@@ -206,7 +234,10 @@ class AudioPipeline(private val context: Context) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
applyGain(pcm, read, captureGainDb)
engine.writeAudio(pcm)
// Zero-copy write via DirectByteBuffer (class field, survives JIT OSR)
captureDirectBuf.clear()
captureDirectBuf.asShortBuffer().put(pcm, 0, read)
engine.writeAudioDirect(captureDirectBuf, read)
// Debug: write raw PCM + RMS
if (pcmOut != null) {
@@ -285,8 +316,12 @@ class AudioPipeline(private val context: Context) {
}
try {
while (running) {
val read = engine.readAudio(pcm)
// Zero-copy read via DirectByteBuffer (class field, survives JIT OSR)
playoutDirectBuf.clear()
val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES)
if (read >= FRAME_SAMPLES) {
playoutDirectBuf.rewind()
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read)

View File

@@ -28,6 +28,8 @@ class SettingsRepository(context: Context) {
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"
private const val KEY_RECENT_ROOMS = "recent_rooms"
private const val TOFU_PREFIX = "tofu_"
}
// --- Servers ---
@@ -138,4 +140,43 @@ class SettingsRepository(context: Context) {
fun saveSeedHex(hex: String) {
prefs.edit().putString(KEY_IDENTITY_SEED, hex).apply()
}
// --- Recent rooms ---
data class RecentRoom(val relay: String, val room: String)
fun addRecentRoom(relay: String, room: String) {
val rooms = loadRecentRooms().toMutableList()
rooms.removeAll { it.relay == relay && it.room == room }
rooms.add(0, RecentRoom(relay, room))
if (rooms.size > 5) rooms.subList(5, rooms.size).clear()
val arr = JSONArray()
rooms.forEach { arr.put(JSONObject().apply { put("relay", it.relay); put("room", it.room) }) }
prefs.edit().putString(KEY_RECENT_ROOMS, arr.toString()).apply()
}
fun loadRecentRooms(): List<RecentRoom> {
val json = prefs.getString(KEY_RECENT_ROOMS, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RecentRoom(o.getString("relay"), o.getString("room"))
}
} catch (_: Exception) { emptyList() }
}
fun clearRecentRooms() {
prefs.edit().remove(KEY_RECENT_ROOMS).apply()
}
// --- Server fingerprint TOFU ---
fun saveServerFingerprint(address: String, fingerprint: String) {
prefs.edit().putString("$TOFU_PREFIX$address", fingerprint).apply()
}
fun loadServerFingerprint(address: String): String? {
return prefs.getString("$TOFU_PREFIX$address", null)
}
}

View File

@@ -117,6 +117,26 @@ class WzpEngine(private val callback: WzpCallback) {
return nativeReadAudio(nativeHandle, pcm)
}
/**
* Write captured PCM from a DirectByteBuffer — zero JNI array copy.
* The buffer must be a direct ByteBuffer with native byte order containing i16 samples.
* Called from the AudioRecord capture thread.
*/
fun writeAudioDirect(buffer: java.nio.ByteBuffer, sampleCount: Int): Int {
if (nativeHandle == 0L) return 0
return nativeWriteAudioDirect(nativeHandle, buffer, sampleCount)
}
/**
* Read decoded PCM into a DirectByteBuffer — zero JNI array copy.
* The buffer must be a direct ByteBuffer with native byte order.
* Called from the AudioTrack playout thread.
*/
fun readAudioDirect(buffer: java.nio.ByteBuffer, maxSamples: Int): Int {
if (nativeHandle == 0L) return 0
return nativeReadAudioDirect(nativeHandle, buffer, maxSamples)
}
// -- JNI native methods --------------------------------------------------
private external fun nativeInit(): Long
@@ -130,12 +150,23 @@ class WzpEngine(private val callback: WzpCallback) {
private external fun nativeForceProfile(handle: Long, profile: Int)
private external fun nativeWriteAudio(handle: Long, pcm: ShortArray): Int
private external fun nativeReadAudio(handle: Long, pcm: ShortArray): Int
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): Int
private external fun nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
private external fun nativeDestroy(handle: Long)
companion object {
init {
System.loadLibrary("wzp_android")
}
/**
* Ping a relay server. Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}`
* or null if unreachable. Does not require an engine instance.
*/
fun pingRelay(address: String): String? = nativePingRelay(address)
@JvmStatic
private external fun nativePingRelay(relay: String): String?
}
}

View File

@@ -12,6 +12,7 @@ import com.wzp.engine.CallStats
import com.wzp.service.CallService
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,6 +20,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.Inet4Address
import java.net.Inet6Address
@@ -26,6 +29,13 @@ import java.net.InetAddress
data class ServerEntry(val address: String, val label: String)
data class PingResult(
val rttMs: Int,
val serverFingerprint: String,
)
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
class CallViewModel : ViewModel(), WzpCallback {
private var engine: WzpEngine? = null
@@ -70,6 +80,16 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _preferIPv6 = MutableStateFlow(false)
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
private val _recentRooms = MutableStateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>>(emptyList())
val recentRooms: StateFlow<List<com.wzp.data.SettingsRepository.RecentRoom>> = _recentRooms.asStateFlow()
/** Ping results keyed by server address. */
private val _pingResults = MutableStateFlow<Map<String, PingResult>>(emptyMap())
val pingResults: StateFlow<Map<String, PingResult>> = _pingResults.asStateFlow()
/** Known server fingerprints (TOFU). */
private val _knownFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
private val _playoutGainDb = MutableStateFlow(0f)
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
@@ -139,6 +159,7 @@ class CallViewModel : ViewModel(), WzpCallback {
_captureGainDb.value = s.loadCaptureGain()
_seedHex.value = s.getOrCreateSeedHex()
_aecEnabled.value = s.loadAecEnabled()
_recentRooms.value = s.loadRecentRooms()
}
fun selectServer(index: Int) {
@@ -182,6 +203,51 @@ class CallViewModel : ViewModel(), WzpCallback {
settings?.saveSelectedServer(_selectedServer.value)
}
/** Ping all servers in background, update results. */
fun pingAllServers() {
viewModelScope.launch {
val results = mutableMapOf<String, PingResult>()
val known = mutableMapOf<String, String>()
_servers.value.forEach { server ->
val pr = withContext(Dispatchers.IO) {
try {
val json = WzpEngine.pingRelay(server.address) ?: return@withContext null
val obj = JSONObject(json)
PingResult(
rttMs = obj.getInt("rtt_ms"),
serverFingerprint = obj.optString("server_fingerprint", ""),
)
} catch (e: Exception) {
Log.w(TAG, "ping ${server.address} failed: ${e.message}")
null
}
}
if (pr != null) {
results[server.address] = pr
// TOFU: save fingerprint on first contact
if (pr.serverFingerprint.isNotEmpty()) {
val saved = settings?.loadServerFingerprint(server.address)
if (saved == null) {
settings?.saveServerFingerprint(server.address, pr.serverFingerprint)
}
known[server.address] = saved ?: pr.serverFingerprint
}
}
}
_pingResults.value = results
_knownFingerprints.value = known
}
}
/** Get lock status for a server. */
fun lockStatus(address: String): LockStatus {
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
val known = _knownFingerprints.value[address]
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
if (known == null) return LockStatus.NEW
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
}
fun setRoomName(name: String) {
_roomName.value = name
settings?.saveRoom(name)
@@ -254,8 +320,17 @@ class CallViewModel : ViewModel(), WzpCallback {
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
val hadCall = audioStarted
CallService.onStopFromNotification = null
stopAudio()
stopAudio() // sets running=false (non-blocking)
stopStatsPolling()
// Wait for audio threads to exit their loops before destroying the engine.
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
val drained = audioPipeline?.awaitDrain() ?: true
if (!drained) {
Log.w(TAG, "teardown: audio threads did not drain in time")
}
audioPipeline = null
Log.i(TAG, "teardown: stopping engine")
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
@@ -278,6 +353,8 @@ class CallViewModel : ViewModel(), WzpCallback {
_debugReportAvailable.value = false
_debugReportStatus.value = null
lastCallServer = serverEntry.address
settings?.addRecentRoom(serverEntry.address, room)
_recentRooms.value = settings?.loadRecentRooms() ?: emptyList()
debugReporter?.prepareForCall()
try {
// Teardown previous call but don't stop the service (we're about to restart it)
@@ -399,8 +476,7 @@ class CallViewModel : ViewModel(), WzpCallback {
private fun stopAudio() {
if (!audioStarted) return
audioPipeline?.stop()
audioPipeline = null
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
audioRouteManager?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
package com.wzp.ui.components
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.min
/**
* Deterministic identicon — generates a unique 5x5 symmetric pattern
* from a hex fingerprint string. Identical algorithm to the desktop
* TypeScript implementation in identicon.ts.
*/
@Composable
fun Identicon(
fingerprint: String,
size: Dp = 36.dp,
clickToCopy: Boolean = true,
modifier: Modifier = Modifier,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
val bytes = hashBytes(fingerprint)
val (bg, fg) = deriveColors(bytes)
val grid = buildGrid(bytes)
Canvas(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(size * 0.12f))
.then(
if (clickToCopy && fingerprint.isNotEmpty()) {
Modifier.clickable {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
}
} else Modifier
)
) {
val cellW = this.size.width / 5f
val cellH = this.size.height / 5f
// Background
drawRect(color = bg, size = this.size)
// Foreground cells
for (y in 0 until 5) {
for (x in 0 until 5) {
if (grid[y][x]) {
drawRect(
color = fg,
topLeft = Offset(x * cellW, y * cellH),
size = Size(cellW, cellH),
)
}
}
}
}
}
/**
* Fingerprint text that copies to clipboard on tap.
*/
@Composable
fun CopyableFingerprint(
fingerprint: String,
modifier: Modifier = Modifier,
style: androidx.compose.ui.text.TextStyle = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color: Color = Color.Unspecified,
) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
androidx.compose.material3.Text(
text = fingerprint,
style = style,
color = color,
modifier = modifier.clickable {
if (fingerprint.isNotEmpty()) {
clipboard.setText(AnnotatedString(fingerprint))
Toast.makeText(context, "Fingerprint copied", Toast.LENGTH_SHORT).show()
}
}
)
}
// --- Internal helpers (matching desktop identicon.ts) ---
private fun hashBytes(hex: String): List<Int> {
val clean = hex.filter { it.isLetterOrDigit() }
val bytes = mutableListOf<Int>()
var i = 0
while (i + 1 < clean.length) {
val b = clean.substring(i, i + 2).toIntOrNull(16) ?: 0
bytes.add(b)
i += 2
}
// Pad to at least 16 bytes
while (bytes.size < 16) bytes.add(0)
return bytes
}
private fun deriveColors(bytes: List<Int>): Pair<Color, Color> {
val hue1 = bytes[0] * 360f / 256f
val hue2 = (bytes[1] * 360f / 256f + 120f) % 360f
val bg = hslToColor(hue1, 0.65f, 0.35f)
val fg = hslToColor(hue2, 0.70f, 0.55f)
return bg to fg
}
private fun buildGrid(bytes: List<Int>): List<List<Boolean>> {
return (0 until 5).map { y ->
val left = (0 until 3).map { x ->
val idx = 2 + y * 3 + x
bytes[idx % bytes.size] > 128
}
// Mirror: col3 = col1, col4 = col0
listOf(left[0], left[1], left[2], left[1], left[0])
}
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val k = { n: Float -> (n + h / 30f) % 12f }
val a = s * min(l, 1f - l)
val f = { n: Float ->
l - a * maxOf(-1f, minOf(k(n) - 3f, minOf(9f - k(n), 1f)))
}
return Color(f(0f), f(8f), f(4f))
}

View File

@@ -158,20 +158,30 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp))
// Fingerprint display
// Fingerprint display with identicon
val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
Text(
text = "Fingerprint",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = fingerprint.chunked(4).joinToString(" "),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
com.wzp.ui.components.Identicon(
fingerprint = draftSeedHex,
size = 40.dp,
)
Spacer(modifier = Modifier.width(12.dp))
com.wzp.ui.components.CopyableFingerprint(
fingerprint = fingerprint.chunked(4).joinToString(" "),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.height(12.dp))

View File

@@ -17,7 +17,6 @@ wzp-crypto = { workspace = true }
wzp-transport = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
bytes = { workspace = true }
serde = { workspace = true }
serde_json = "1"
@@ -28,7 +27,9 @@ 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"
android_logger = "0.14"
log = "0.4"
tracing-log = "0.2"
[build-dependencies]
cc = "1"

View File

@@ -1,91 +1,128 @@
//! Lock-free SPSC ring buffers for audio PCM transfer between
//! Kotlin AudioRecord/AudioTrack threads and the Rust engine.
//! Lock-free SPSC ring buffer — "Reader-Detects-Lap" architecture.
//!
//! These use a simple spin-free design: the producer writes and advances
//! a write cursor, the consumer reads and advances a read cursor.
//! Both cursors are atomic so no mutex is needed.
//! 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::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
/// Ring buffer capacity in i16 samples.
/// 960 samples * 10 frames = ~200ms of audio at 48kHz mono.
const RING_CAPACITY: usize = 960 * 10;
/// Ring buffer capacity — power of 2 for bitmask indexing.
/// 16384 samples = 341.3ms at 48kHz mono. 70% more headroom
/// than the previous 9600 (200ms) for surviving Android GC pauses.
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; RING_CAPACITY]>,
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 designed for SPSC — one thread writes, one reads.
// The atomics ensure visibility. The buffer itself is never accessed
// from the same index by both threads simultaneously because the
// producer only writes to positions between write_pos and read_pos,
// and the consumer only reads from positions between read_pos and write_pos.
// 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: Box::new([0i16; RING_CAPACITY]),
buf: vec![0i16; RING_CAPACITY].into_boxed_slice(),
write_pos: AtomicUsize::new(0),
read_pos: AtomicUsize::new(0),
overflow_count: AtomicU64::new(0),
underrun_count: AtomicU64::new(0),
}
}
/// Number of samples available to read.
/// Number of samples available to read (clamped to capacity).
pub fn available(&self) -> usize {
let w = self.write_pos.load(Ordering::Acquire);
let r = self.read_pos.load(Ordering::Acquire);
w.wrapping_sub(r)
let r = self.read_pos.load(Ordering::Relaxed);
w.wrapping_sub(r).min(RING_CAPACITY)
}
/// Number of samples that can be written without overwriting.
/// Number of samples that can be written without overwriting unread data.
pub fn free_space(&self) -> usize {
RING_CAPACITY - self.available()
RING_CAPACITY.saturating_sub(self.available())
}
/// Write samples into the ring. Returns number of samples written.
/// Drops oldest samples if the ring is full.
///
/// If the ring is full, old data is silently overwritten. The reader
/// will detect the lap and self-correct. The writer NEVER touches
/// `read_pos` — this is the key invariant that prevents cursor desync.
pub fn write(&self, samples: &[i16]) -> usize {
let w = self.write_pos.load(Ordering::Relaxed);
let count = samples.len().min(RING_CAPACITY);
let w = self.write_pos.load(Ordering::Relaxed);
for i in 0..count {
let idx = (w + i) % RING_CAPACITY;
// SAFETY: We're the only writer, and the reader won't read
// past read_pos which we haven't advanced past yet.
unsafe {
let ptr = self.buf.as_ptr() as *mut i16;
*ptr.add(idx) = samples[i];
*ptr.add((w + i) & RING_MASK) = samples[i];
}
}
self.write_pos.store(w.wrapping_add(count), Ordering::Release);
// If we overwrote unread data, advance read_pos
if self.available() > RING_CAPACITY {
let new_read = self.write_pos.load(Ordering::Relaxed).wrapping_sub(RING_CAPACITY);
self.read_pos.store(new_read, Ordering::Release);
}
count
}
/// 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. This is safe because only the
/// reader thread writes `read_pos`.
pub fn read(&self, out: &mut [i16]) -> usize {
let avail = self.available();
let count = out.len().min(avail);
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.
// Snap read_pos forward to oldest valid data in the buffer.
if avail > RING_CAPACITY {
r = w.wrapping_sub(RING_CAPACITY);
avail = RING_CAPACITY;
self.overflow_count.fetch_add(1, Ordering::Relaxed);
}
let count = out.len().min(avail);
if count == 0 {
if w == r {
self.underrun_count.fetch_add(1, Ordering::Relaxed);
}
return 0;
}
let r = self.read_pos.load(Ordering::Relaxed);
for i in 0..count {
let idx = (r + i) % RING_CAPACITY;
out[i] = unsafe { *self.buf.as_ptr().add(idx) };
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

@@ -183,6 +183,9 @@ impl WzpEngine {
stats.duration_secs = start.elapsed().as_secs_f64();
}
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
stats.playout_overflows = self.state.playout_ring.overflow_count();
stats.playout_underruns = self.state.playout_ring.underrun_count();
stats.capture_overflows = self.state.capture_ring.overflow_count();
stats
}
@@ -333,6 +336,12 @@ async fn run_call(
let mut last_stats_log = Instant::now();
let mut frames_sent: u64 = 0;
let mut frames_dropped: u64 = 0;
// Per-step timing accumulators (reset every stats log)
let mut t_agc_us: u64 = 0;
let mut t_opus_us: u64 = 0;
let mut t_fec_us: u64 = 0;
let mut t_send_us: u64 = 0;
let mut t_frames: u64 = 0;
loop {
if !state.running.load(Ordering::Relaxed) {
break;
@@ -356,9 +365,12 @@ async fn run_call(
}
// AGC: normalize capture volume before encoding
let t0 = Instant::now();
capture_agc.process_frame(&mut capture_buf);
t_agc_us += t0.elapsed().as_micros() as u64;
// Opus encode
let t0 = Instant::now();
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
Ok(n) => n,
Err(e) => {
@@ -366,6 +378,7 @@ async fn run_call(
continue;
}
};
t_opus_us += t0.elapsed().as_micros() as u64;
let encoded = &encode_buf[..encoded_len];
// Build source packet
@@ -391,6 +404,7 @@ async fn run_call(
};
// Send source packet — drop on error, never break
let t0 = Instant::now();
if let Err(e) = transport.send_media(&source_pkt).await {
send_errors += 1;
frames_dropped += 1;
@@ -405,11 +419,14 @@ async fn run_call(
last_send_error_log = Instant::now();
}
// Don't feed to FEC either — the source is lost
t_send_us += t0.elapsed().as_micros() as u64;
continue;
}
t_send_us += t0.elapsed().as_micros() as u64;
frames_sent += 1;
// Feed encoded frame to FEC encoder
let t0 = Instant::now();
if let Err(e) = fec_enc.add_source_symbol(encoded) {
warn!("fec add_source error: {e}");
}
@@ -466,9 +483,12 @@ async fn run_call(
block_id = block_id.wrapping_add(1);
frame_in_block = 0;
}
t_fec_us += t0.elapsed().as_micros() as u64;
t_frames += 1;
// Periodic stats every 5 seconds
if last_stats_log.elapsed().as_secs() >= 5 {
let avg = |total: u64| if t_frames > 0 { total / t_frames } else { 0 };
info!(
seq = s,
block_id,
@@ -476,8 +496,15 @@ async fn run_call(
frames_dropped,
send_errors,
ring_avail = state.capture_ring.available(),
capture_overflows = state.capture_ring.overflow_count(),
avg_agc_us = avg(t_agc_us),
avg_opus_us = avg(t_opus_us),
avg_fec_us = avg(t_fec_us),
avg_send_us = avg(t_send_us),
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
"send stats"
);
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0;
last_stats_log = Instant::now();
}
}
@@ -578,6 +605,8 @@ async fn run_call(
recv_errors,
max_recv_gap_ms,
playout_avail = state.playout_ring.available(),
playout_overflows = state.playout_ring.overflow_count(),
playout_underruns = state.playout_ring.underrun_count(),
"recv stats"
);
max_recv_gap_ms = 0;

View File

@@ -35,11 +35,18 @@ static INIT_LOGGING: Once = Once::new();
/// 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();
}
// Use android_logger directly — tracing_subscriber::registry() allocates
// a sharded_slab which causes SIGSEGV on Android 16 MTE devices.
// android_logger is lightweight and doesn't trigger scudo crashes.
let _ = std::panic::catch_unwind(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("wzp"),
);
// Bridge tracing → log so our tracing::info! macros work
let _ = tracing_log::LogTracer::init();
});
});
}
@@ -209,7 +216,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudio(
return 0;
}
let mut buf = vec![0i16; len];
// GetShortArrayRegion copies Java array into our buffer
if env.get_short_array_region(&pcm, 0, &mut buf).is_err() {
return 0;
}
@@ -243,6 +249,56 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudio(
result.unwrap_or(0)
}
/// Write captured PCM from a DirectByteBuffer — zero JNI array copies.
/// The ByteBuffer must contain little-endian i16 samples.
/// Called from the AudioRecord capture thread.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDirect(
env: JNIEnv,
_class: JClass,
handle: jlong,
buffer: jni::objects::JByteBuffer,
sample_count: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
if ptr.is_null() || sample_count <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize)
};
h.engine.write_audio(samples) as jint
}));
result.unwrap_or(0)
}
/// Read decoded PCM into a DirectByteBuffer — zero JNI array copies.
/// The ByteBuffer will be filled with little-endian i16 samples.
/// Called from the AudioTrack playout thread.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirect(
env: JNIEnv,
_class: JClass,
handle: jlong,
buffer: jni::objects::JByteBuffer,
max_samples: jint,
) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut());
if ptr.is_null() || max_samples <= 0 {
return 0;
}
let samples = unsafe {
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize)
};
h.engine.read_audio(samples) as jint
}));
result.unwrap_or(0)
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
_env: JNIEnv,
@@ -254,3 +310,79 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
drop(h);
}));
}
/// Ping a relay server — returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
/// Does NOT require an engine handle — creates a temporary QUIC connection.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
relay_j: JString,
) -> jstring {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
let addr: std::net::SocketAddr = match relay.parse() {
Ok(a) => a,
Err(_) => return None,
};
let _ = rustls::crypto::ring::default_provider().install_default();
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return None,
};
rt.block_on(async {
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = match wzp_transport::create_endpoint(bind, None) {
Ok(e) => e,
Err(_) => return None,
};
let client_cfg = wzp_transport::client_config();
let start = std::time::Instant::now();
match tokio::time::timeout(
std::time::Duration::from_secs(3),
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
)
.await
{
Ok(Ok(conn)) => {
let rtt_ms = start.elapsed().as_millis() as u64;
let server_fp = 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 h = std::collections::hash_map::DefaultHasher::new();
c.as_ref().hash(&mut h);
format!("{:016x}", h.finish())
})
})
.unwrap_or_default();
conn.close(0u32.into(), b"ping");
Some(format!(
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
rtt_ms, server_fp
))
}
_ => None,
}
})
}));
let json = match result {
Ok(Some(s)) => s,
_ => return JObject::null().into_raw(),
};
env.new_string(&json)
.map(|s| s.into_raw())
.unwrap_or(JObject::null().into_raw())
}

View File

@@ -51,6 +51,12 @@ pub struct CallStats {
pub underruns: u64,
/// Frames recovered by FEC.
pub fec_recovered: u64,
/// Playout ring overflow count (reader was lapped by writer).
pub playout_overflows: u64,
/// Playout ring underrun count (reader found empty buffer).
pub playout_underruns: u64,
/// Capture ring overflow count.
pub capture_overflows: u64,
/// Current mic audio level (RMS of i16 samples, 0-32767).
pub audio_level: u32,
/// Number of participants in the room (from last RoomUpdate).

View File

@@ -23,13 +23,10 @@ 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,10 +3,12 @@
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
//!
//! 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.
//! 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.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use anyhow::{anyhow, Context};
@@ -14,8 +16,6 @@ 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,25 +23,23 @@ pub const FRAME_SAMPLES: usize = 960;
// AudioCapture
// ---------------------------------------------------------------------------
/// Captures microphone input via CPAL and writes PCM into a lock-free ring buffer.
/// Captures microphone input and yields 960-sample PCM frames.
///
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
pub struct AudioCapture {
ring: Arc<AudioRing>,
rx: mpsc::Receiver<Vec<i16>>,
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 ring = Arc::new(AudioRing::new());
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
let running = Arc::new(AtomicBool::new(true));
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let ring_cb = ring.clone();
let running_clone = running.clone();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
std::thread::Builder::new()
.name("wzp-audio-capture".into())
.spawn(move || {
@@ -61,51 +59,53 @@ 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 ring = ring_cb.clone();
let buf = buf.clone();
let tx = tx.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;
}
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]);
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);
}
ring.write(&tmp[..n]);
}
},
err_cb,
None,
)?
} else {
let ring = ring_cb.clone();
let buf = buf.clone();
let tx = tx.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;
}
if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[audio] capture callback: {} i16 samples", data.len());
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);
}
}
ring.write(data);
},
err_cb,
None,
@@ -114,6 +114,7 @@ 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.
@@ -134,12 +135,15 @@ impl AudioCapture {
.map_err(|_| anyhow!("capture thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?;
Ok(Self { ring, running })
Ok(Self { rx, running })
}
/// Get a reference to the capture ring buffer for direct polling.
pub fn ring(&self) -> &Arc<AudioRing> {
&self.ring
/// 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()
}
/// Stop capturing.
@@ -148,35 +152,27 @@ impl AudioCapture {
}
}
impl Drop for AudioCapture {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// AudioPlayback
// ---------------------------------------------------------------------------
/// Plays PCM through the default output device, reading from a lock-free ring buffer.
/// Plays PCM frames through the default output device at 48 kHz mono.
///
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
pub struct AudioPlayback {
ring: Arc<AudioRing>,
tx: mpsc::SyncSender<Vec<i16>>,
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 ring = Arc::new(AudioRing::new());
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
let running = Arc::new(AtomicBool::new(true));
let (init_tx, init_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let ring_cb = ring.clone();
let running_clone = running.clone();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
std::thread::Builder::new()
.name("wzp-audio-playback".into())
.spawn(move || {
@@ -196,40 +192,62 @@ 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_cb.clone();
let ring = ring.clone();
device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
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;
}
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,
};
}
},
err_cb,
None,
)?
} else {
let ring = ring_cb.clone();
let ring = ring.clone();
device.build_output_stream(
&config,
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
let read = ring.read(data);
// Fill remainder with silence if ring underran
for sample in &mut data[read..] {
*sample = 0;
let mut lock = ring.lock().unwrap();
for sample in data.iter_mut() {
*sample = lock.pop_front().unwrap_or(0);
}
},
err_cb,
@@ -239,6 +257,7 @@ 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.
@@ -259,12 +278,12 @@ impl AudioPlayback {
.map_err(|_| anyhow!("playback thread exited before signaling"))?
.map_err(|e| anyhow!("{e}"))?;
Ok(Self { ring, running })
Ok(Self { tx, running })
}
/// Get a reference to the playout ring buffer for direct writing.
pub fn ring(&self) -> &Arc<AudioRing> {
&self.ring
/// Write a frame of PCM samples for playback.
pub fn write_frame(&self, pcm: &[i16]) {
let _ = self.tx.try_send(pcm.to_vec());
}
/// Stop playback.
@@ -273,16 +292,11 @@ 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()
@@ -299,6 +313,7 @@ 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

@@ -1,122 +0,0 @@
//! 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

@@ -1,179 +0,0 @@
//! 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,9 +42,6 @@ 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
@@ -66,7 +63,6 @@ impl Default for CallConfig {
noise_suppression: true,
mini_frames_enabled: true,
adaptive_jitter: true,
aec_delay_ms: 40,
}
}
}
@@ -245,7 +241,7 @@ impl CallEncoder {
block_id: 0,
frame_in_block: 0,
timestamp_ms: 0,
aec: EchoCanceller::with_delay(48000, 60, config.aec_delay_ms),
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
agc: AutoGainControl::new(),
silence_detector: SilenceDetector::new(
config.silence_threshold_rms,
@@ -500,49 +496,6 @@ 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.
@@ -557,9 +510,6 @@ 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,23 +14,17 @@
use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{error, info, warn};
use tracing::{error, info};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport;
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
}
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz
/// Generate a sine wave tone.
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)
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)
.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
@@ -51,32 +45,12 @@ 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 persistent file.
///
/// Priority: --seed > --mnemonic > ~/.wzp/identity > generate + save.
/// Resolve the identity seed from --seed, --mnemonic, or generate a new one.
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");
@@ -91,56 +65,15 @@ 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;
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");
info!(fingerprint = %fp, "generated ephemeral 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;
@@ -153,19 +86,8 @@ 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;
@@ -208,27 +130,6 @@ 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());
@@ -267,14 +168,6 @@ 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]");
@@ -286,28 +179,14 @@ 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);
}
@@ -340,19 +219,8 @@ 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,
}
}
@@ -373,30 +241,17 @@ 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"
);
// Compute SNI from room name.
// --raw-room sends the name as-is (for Android compat — Android doesn't hash).
// Default behaviour hashes for privacy.
// Hash room name for SNI privacy (or "default" if none specified)
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");
@@ -432,25 +287,14 @@ async fn main() -> anyhow::Result<()> {
let _crypto_session = wzp_client::handshake::perform_handshake(
&*transport,
&seed.0,
cli.alias.as_deref(),
None, // alias — desktop client doesn't set one yet
).await?;
info!("crypto handshake complete");
if cli.live {
#[cfg(feature = "audio")]
{
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;
return run_live(transport).await;
}
#[cfg(not(feature = "audio"))]
{
@@ -471,23 +315,19 @@ 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, profile).await
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await
} else {
run_silence(transport, profile).await
run_silence(transport).await
}
}
/// Send silence frames (connectivity test).
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);
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
let config = CallConfig::default();
let mut encoder = CallEncoder::new(&config);
let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64);
let pcm = vec![0i16; frame_samples];
let frame_duration = tokio::time::Duration::from_millis(20);
let pcm = vec![0i16; FRAME_SAMPLES];
let mut total_source = 0u64;
let mut total_repair = 0u64;
@@ -503,7 +343,8 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>, profile: Opt
}
total_bytes += pkt.payload.len() as u64;
if let Err(e) = transport.send_media(pkt).await {
warn!("send_media error (dropping packet): {e}");
error!("send error: {e}");
break;
}
}
if (i + 1) % 50 == 0 {
@@ -533,20 +374,13 @@ 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 = 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;
let config = CallConfig::default();
// --- 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) {
@@ -558,14 +392,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) * 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()
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()
} else {
// No sending, just wait
tokio::signal::ctrl_c().await.ok();
@@ -574,7 +408,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(frame_duration_ms);
let frame_duration = tokio::time::Duration::from_millis(20);
let mut total_source = 0u64;
let mut total_repair = 0u64;
@@ -595,7 +429,8 @@ async fn run_file_mode(
total_source += 1;
}
if let Err(e) = send_transport.send_media(pkt).await {
warn!("send_media error (dropping packet): {e}");
error!("send error: {e}");
return;
}
}
if (frame_idx + 1) % 250 == 0 {
@@ -624,13 +459,8 @@ async fn run_file_mode(
}
};
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 decoder = CallDecoder::new(&CallConfig::default());
let mut pcm_buf = vec![0i16; FRAME_SAMPLES];
let mut all_pcm: Vec<i16> = Vec::new();
let mut frames_received = 0u64;
@@ -719,534 +549,78 @@ 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>,
opts: AudioOpts,
) -> anyhow::Result<()> {
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicBool, Ordering};
async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> {
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
use wzp_client::audio_ring::AudioRing;
use wzp_client::call::JitterTelemetry;
// 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())
let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?;
info!("Audio I/O started — press Ctrl+C to stop");
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 recv_transport = transport.clone();
let recv_handle = tokio::spawn(async move {
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);
}
}
}
Ok(None) => {
info!("connection closed");
break;
}
Err(e) => {
error!("recv error: {e}");
break;
}
}
}
#[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");
}
}
tokio::signal::ctrl_c().await?;
info!("Shutting down...");
// --- Send task: poll capture ring → encode → send via async ---
let send_transport = transport.clone();
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_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 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");
}
}
}
}
}
};
// 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;
}
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;
}
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"),
}
recv_handle.abort();
drop(send_handle);
transport.close().await?;
info!("done");
Ok(())
}

View File

@@ -110,7 +110,6 @@ 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
}
}

View File

@@ -8,10 +8,6 @@
#[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,127 +1,53 @@
//! 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.
//! Acoustic Echo Cancellation using NLMS adaptive filter.
//! Processes 480-sample (10ms) sub-frames at 48kHz.
/// Delay-compensated leaky NLMS echo canceller with Geigel DTD.
/// 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.
pub struct EchoCanceller {
// --- Adaptive filter ---
filter: Vec<f32>,
filter_coeffs: Vec<f32>,
filter_len: usize,
/// Circular buffer of far-end reference samples (after delay).
far_buf: Vec<f32>,
far_pos: usize,
/// NLMS step size.
far_end_buf: Vec<f32>,
far_end_pos: usize,
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 (60ms recommended)
/// * `delay_ms` — far-end delay compensation in milliseconds (40ms for laptops)
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
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: vec![0.0; filter_len],
filter_coeffs: vec![0.0f32; filter_len],
filter_len,
far_buf: vec![0.0; filter_len],
far_pos: 0,
far_end_buf: vec![0.0f32; filter_len],
far_end_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) 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.
/// 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.
pub fn feed_farend(&mut self, farend: &[i16]) {
// Write raw samples into the delay ring.
for &s in farend {
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
self.far_end_buf[self.far_end_pos] = s as f32;
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
}
}
/// 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;
@@ -130,96 +56,85 @@ 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;
// 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) ---
// --- 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.
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_buf[fe_idx];
echo_est += self.filter[k] * fe;
let fe = self.far_end_buf[fe_idx];
echo_est += self.filter_coeffs[k] * fe;
power += fe * fe;
}
let error = near_f - echo_est;
// --- 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];
}
// --- 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;
}
let out = error.clamp(-32768.0, 32767.0);
// Clamp output
let out = error.max(-32768.0).min(32767.0);
nearend[i] = out as i16;
sum_near_sq += (near_f as f64).powi(2);
sum_err_sq += (out as f64).powi(2);
sum_near_sq += (near_f as f64) * (near_f as f64);
sum_err_sq += (out as f64) * (out as f64);
}
// ERLE ratio
if sum_err_sq < 1.0 {
100.0
} else {
(sum_near_sq / sum_err_sq).sqrt() as f32
return 100.0; // near-perfect cancellation
}
(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.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;
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;
}
}
@@ -228,40 +143,50 @@ mod tests {
use super::*;
#[test]
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
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);
}
#[test]
fn passthrough_when_disabled() {
let mut aec = EchoCanceller::new(48000, 60);
fn aec_passthrough_when_disabled() {
let mut aec = EchoCanceller::new(48000, 100);
aec.set_enabled(false);
assert!(!aec.is_enabled());
let original: Vec<i16> = (0..960).map(|i| (i * 10) as i16).collect();
let original: Vec<i16> = (0..480).map(|i| (i * 10) as i16).collect();
let mut frame = original.clone();
aec.process_frame(&mut frame);
let erle = aec.process_frame(&mut frame);
assert_eq!(erle, 1.0);
assert_eq!(frame, original);
}
#[test]
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));
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);
}
#[test]
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);
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);
let frame_len = 480;
let make_tone = |offset: usize| -> Vec<i16> {
// Generate a simple repeating pattern.
let frame_len = 480usize;
let make_frame = |offset: usize| -> Vec<i16> {
(0..frame_len)
.map(|i| {
let t = (offset + i) as f64 / 48000.0;
@@ -270,16 +195,18 @@ mod tests {
.collect()
};
// Warm up the adaptive filter with several frames.
let mut last_erle = 1.0f32;
for frame_idx in 0..100 {
let farend = make_tone(frame_idx * frame_len);
for frame_idx in 0..40 {
let farend = make_frame(frame_idx * frame_len);
aec.feed_farend(&farend);
// Near-end = attenuated copy of far-end (echo at ~50% volume).
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
// Near-end = exact copy of far-end (pure echo).
let mut nearend = farend.clone();
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}"
@@ -287,49 +214,15 @@ mod tests {
}
#[test]
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");
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));
}
}

View File

@@ -656,11 +656,6 @@ 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,7 +28,6 @@ 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, warn};
use tracing::{error, info};
use wzp_proto::MediaTransport;
use wzp_relay::config::RelayConfig;
@@ -207,39 +207,8 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
}
// 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
}
};
// Generate ephemeral relay identity for crypto handshake
let relay_seed = wzp_crypto::Seed::generate();
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
@@ -330,13 +299,6 @@ 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

@@ -141,17 +141,6 @@ 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()
}
@@ -266,26 +255,6 @@ 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,
@@ -405,166 +374,141 @@ async fn run_participant_plain(
session_id: &str,
) {
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();
// 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 = %room_name,
participant = participant_id,
%addr,
session = session_id,
"forwarding loop started (plain)"
);
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;
loop {
let recv_start = std::time::Instant::now();
let pkt = match transport.recv_media().await {
Ok(Some(pkt)) => pkt,
Ok(None) => {
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "disconnected (stream ended)");
break;
}
Err(e) => {
let msg = e.to_string();
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
} else {
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
}
Err(e) => {
let msg = e.to_string();
if msg.contains("timed out") || msg.contains("reset") || msg.contains("closed") {
info!(%addr, participant = participant_id, forwarded = packets_forwarded, "connection closed: {e}");
} else {
error!(%addr, participant = participant_id, forwarded = packets_forwarded, "recv error: {e}");
}
break;
}
};
let recv_gap_ms = last_recv_instant.elapsed().as_millis() as u64;
last_recv_instant = std::time::Instant::now();
if recv_gap_ms > max_recv_gap_ms {
max_recv_gap_ms = recv_gap_ms;
}
if recv_gap_ms > 200 {
warn!(
room = %media_room_name,
participant = participant_id,
recv_gap_ms,
seq = pkt.header.seq,
"large recv gap"
);
break;
}
};
if let Some(ref report) = pkt.quality_report {
media_metrics.update_session_quality(&media_session_id, report);
}
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;
}
// Log if recv gap is suspiciously large (>200ms = missed ~10 packets)
if recv_gap_ms > 200 {
warn!(
room = %room_name,
participant = participant_id,
recv_gap_ms,
seq = pkt.header.seq,
"large recv gap"
);
}
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");
}
// 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);
}
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}"
);
}
// Get current list of other participants
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"
);
}
// Forward to all others
let fwd_start = std::time::Instant::now();
let pkt_bytes = pkt.payload.len() as u64;
for other in &others {
match other {
ParticipantSender::Quic(t) => {
if let Err(e) = t.send_media(&pkt).await {
send_errors += 1;
if send_errors <= 5 || send_errors % 100 == 0 {
warn!(
room = %room_name,
participant = participant_id,
peer = %t.connection().remote_address(),
total_send_errors = send_errors,
"send_media error: {e}"
);
}
}
ParticipantSender::WebSocket(_) => {
let _ = other.send_raw(&pkt.payload).await;
}
}
}
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;
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 = %room_name,
participant = participant_id,
fwd_ms,
fan_out = others.len(),
"slow forward"
);
}
// Run both in parallel — exit when either finishes (disconnection)
tokio::select! {
_ = media_task => {}
_ = signal_task => {}
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;
// Periodic stats log every 5 seconds
if last_log_instant.elapsed() >= Duration::from_secs(5) {
let room_size = {
let mgr = room_mgr.lock().await;
mgr.room_size(&room_name)
};
info!(
room = %room_name,
participant = participant_id,
forwarded = packets_forwarded,
room_size,
fan_out,
max_recv_gap_ms,
max_forward_ms,
send_errors,
"participant stats"
);
max_recv_gap_ms = 0;
max_forward_ms = 0;
last_log_instant = std::time::Instant::now();
}
}
// Clean up — leave room and broadcast update to remaining participants

View File

@@ -1,16 +0,0 @@
{
"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/*"
]
}

View File

@@ -1,169 +0,0 @@
/* 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

@@ -1,27 +0,0 @@
/* 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,115 @@
# Incident Report: SIGBUS in ART GC During Audio Thread JNI Calls
**Date:** 2026-04-06
**Severity:** High — app crash (SIGBUS) mid-call
**Status:** Root-caused, fix proposed
**Affects:** Android 16 (API 36) devices with concurrent mark-compact GC
## Summary
The app crashes with SIGBUS (signal 7, BUS_ADRERR) during an active call. The crash occurs in ART's garbage collector or JIT compiler, NOT in our Rust native code or AudioRing buffer. Both `wzp-capture` and `wzp-playout` Kotlin threads are affected.
## Crash Details
### Crash 1: wzp-capture (18:42, after 476s of call)
```
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x720009be38
tid 19697 (wzp-capture), pid 17885 (com.wzp.phone)
```
**Backtrace:**
```
#00 art::StackVisitor::WalkStack
#01 art::Thread::VisitRoots
#02 art::gc::collector::MarkCompact::ThreadFlipVisitor::Run
#03 art::Thread::EnsureFlipFunctionStarted
#04 CheckJNI::ReleasePrimitiveArrayElements ← JNI boundary
#05 android_media_AudioRecord_readInArray ← AudioRecord.read()
#09 com.wzp.audio.AudioPipeline.runCapture
```
**Root cause:** ART's concurrent mark-compact GC (`MarkCompact::ThreadFlipVisitor`) is flipping thread roots while the capture thread is in the middle of a JNI call (`AudioRecord.read()`). The GC's `EnsureFlipFunctionStarted` triggers a stack walk that hits an invalid address.
### Crash 2: wzp-playout (19:17, mid-call)
```
Fatal signal 7 (SIGBUS), code 2 (BUS_ADRERR), fault addr 0x225eb98
tid 32574 (wzp-playout), pid 32479 (com.wzp.phone)
```
**Backtrace:**
```
#00 com.wzp.audio.AudioPipeline.runPlayout ← JIT-compiled code
#01 art_quick_osr_stub ← On-Stack Replacement
#02 art::jit::Jit::MaybeDoOnStackReplacement
#03-#04 art::interpreter::ExecuteSwitchImplCpp
```
**Root cause:** ART's JIT compiler performed On-Stack Replacement (OSR) on the hot playout loop. The OSR stub references a code address (`0x225eb98`) that is no longer valid — likely because the GC moved the compiled code in memory during concurrent compaction.
## Why This Happens
Android 16 introduced a new **concurrent mark-compact GC** (CMC) that moves objects in memory while other threads are running. This is safe for normal Java code because ART uses read barriers. But our audio threads have specific properties that stress this:
1. **`Thread.MAX_PRIORITY`** — audio threads run at the highest priority, starving the GC thread of CPU time. The GC may not complete its thread-flip before the audio thread resumes.
2. **Tight JNI loops**`runCapture()` and `runPlayout()` loop every 20ms calling `AudioRecord.read()` / `AudioTrack.write()` via JNI. Each JNI transition is a GC safepoint, but the thread spends most of its time in native code where the GC can't flip it.
3. **Long-running JIT-compiled code** — the hot loop gets JIT-compiled and may undergo OSR. If the GC compacts memory while OSR is in progress, the stub can reference stale addresses.
4. **Daemon threads that never exit** — our threads are parked with `Thread.sleep(Long.MAX_VALUE)` after the call ends (to avoid the libcrypto TLS destructor crash). These zombie threads accumulate GC root scan work.
## Evidence This Is Not Our Bug
| Component | Evidence |
|-----------|---------|
| **AudioRing** | Not in any backtrace. All crash frames are in `libart.so` (ART runtime) |
| **Rust native code** | `libwzp_android.so` not in any crash frame |
| **JNI bridge** | Crash happens during `ReleasePrimitiveArrayElements` (ART internal), not during our JNI calls |
| **Timing** | Crashes after 476s and mid-call — not during init or teardown |
## Proposed Fix
### Option A: Disable concurrent GC compaction for audio threads (recommended)
Use `dalvik.vm.gctype` or per-thread GC pinning to prevent the mark-compact collector from moving objects referenced by audio threads.
**Not directly controllable from app code.** But we can reduce GC pressure:
### Option B: Reduce JNI transitions in audio threads
Instead of calling `engine.writeAudio(pcm)` / `engine.readAudio(pcm)` via JNI on every 20ms frame, batch multiple frames or use `DirectByteBuffer` to share memory without JNI array copies.
**Implementation:**
- Allocate a `DirectByteBuffer` in Kotlin, share the pointer with Rust via JNI
- Audio threads write/read directly to the buffer (no JNI call per frame)
- Rust reads/writes from the same memory region
- Reduces JNI transitions from 100/sec to 0/sec per audio direction
### Option C: Use Android's Oboe (AAudio) natively from Rust
Skip the Kotlin AudioRecord/AudioTrack entirely. Use Oboe (which we already have as a dependency in `wzp-android/Cargo.toml`) to create native audio streams directly from Rust. The audio callbacks run in native code with no JNI, no GC interaction, no ART.
This is how the project was originally designed (see `audio_android.rs` with Oboe references) before switching to Kotlin AudioRecord for simplicity.
**Pros:** Eliminates the entire JNI audio path. No GC interaction. Lower latency.
**Cons:** Requires rewriting `AudioPipeline.kt` into Rust. Oboe setup is more complex.
### Option D: Pin audio thread objects to prevent GC movement
Use JNI `GetPrimitiveArrayCritical` instead of `GetShortArrayRegion` to pin the array in memory during the operation. This prevents the GC from moving the array while we're using it.
**Implementation:** Change `nativeWriteAudio` / `nativeReadAudio` JNI functions to use critical sections.
### Recommendation
**Short term: Option B** (DirectByteBuffer) — reduces JNI transitions without major refactoring.
**Long term: Option C** (Oboe from Rust) — eliminates the problem entirely. This is the architecturally correct solution and matches the original design intent.
## Data Files
- Logcat from Nothing A059 (Android 16, API 36)
- Two crashes in the same session: 18:42 (capture, after 476s) and 19:17 (playout)
- Both SIGBUS/BUS_ADRERR, both in ART internal frames

View File

@@ -0,0 +1,175 @@
# Incident Report: Native Crash in Capture Thread — Use-After-Free on Engine Handle
**Date:** 2026-04-06
**Severity:** Critical — app crash (SIGSEGV) on call hangup
**Status:** Root-caused, fix pending
**Affects:** Android client only
## Summary
The app crashes with a native SIGSEGV during or shortly after call hangup. The crash occurs in JIT-compiled code inside `AudioPipeline.runCapture()`. The root cause is a use-after-free: the capture thread calls `engine.writeAudio()` via JNI after the engine's native handle has been freed by `teardown()` on the ViewModel thread.
## Crash Stacktrace
```
04-06 13:05:42.707 F DEBUG: #09 pc 000000000250696c /memfd:jit-cache (deleted) (com.wzp.audio.AudioPipeline.runCapture+3228)
04-06 13:05:42.707 F DEBUG: #14 pc 0000000000005270 <anonymous:730900d000> (com.wzp.audio.AudioPipeline.start$lambda$0+0)
04-06 13:05:42.708 F DEBUG: #19 pc 00000000000044cc <anonymous:730900d000> (com.wzp.audio.AudioPipeline.$r8$lambda$0rYcivupwvyN4SgBXhsroKmTlo8+0)
04-06 13:05:42.708 F DEBUG: #24 pc 00000000000042e4 <anonymous:730900d000> (com.wzp.audio.AudioPipeline$$ExternalSyntheticLambda0.run+0)
```
This is a tombstone (signal crash), not a Java exception. The `F DEBUG` tag indicates a native crash handler (debuggerd) captured the signal.
## Root Cause
### The Race Condition
Two threads operate on the engine concurrently without synchronization:
**Thread 1: `wzp-capture` (AudioRecord thread, MAX_PRIORITY)**
```kotlin
// AudioPipeline.runCapture() — runs in a tight loop
while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
engine.writeAudio(pcm) // <-- JNI call to native engine
}
}
```
**Thread 2: ViewModel/UI thread (normal priority)**
```kotlin
// CallViewModel.teardown()
stopAudio() // sets AudioPipeline.running = false
engine?.stopCall() // tells Rust to stop
engine?.destroy() // frees native memory, sets nativeHandle = 0L
engine = null
```
### The Kotlin Guard is Insufficient
`WzpEngine.writeAudio()` has a guard:
```kotlin
fun writeAudio(pcm: ShortArray): Int {
if (nativeHandle == 0L) return 0 // check
return nativeWriteAudio(nativeHandle, pcm) // use
}
```
This is a **TOCTOU (time-of-check/time-of-use) race**:
1. Capture thread checks `nativeHandle != 0L` → true
2. ViewModel thread calls `destroy()`, which calls `nativeDestroy(handle)` then sets `nativeHandle = 0L`
3. Capture thread calls `nativeWriteAudio(handle, pcm)` with the now-freed handle
4. The JNI function dereferences `handle` as a pointer → **SIGSEGV**
The same race exists for `readAudio()` on the `wzp-playout` thread.
### Why `stopAudio()` Doesn't Prevent This
`AudioPipeline.stop()` sets `running = false` but does **NOT join or wait** for the threads:
```kotlin
fun stop() {
running = false
// Don't join — threads are parked as daemons to avoid native TLS crash
captureThread = null
playoutThread = null
}
```
The threads are intentionally not joined because of a separate bug: exiting a JNI-calling thread triggers a `SIGSEGV in OPENSSL_free` due to libcrypto TLS destructors on Android. The threads instead "park" with `Thread.sleep(Long.MAX_VALUE)` after the loop exits.
But the problem is the **window between `running = false` and the thread actually checking it**. The capture thread may be blocked in `recorder.read()` (which blocks for 20ms per frame) or in the middle of `engine.writeAudio()` when `destroy()` is called.
### Timeline of the Crash
```
T=0ms ViewModel: stopAudio() → sets running=false
T=0ms ViewModel: stopStatsPolling()
T=0ms ViewModel: engine.stopCall() — Rust stops internal tasks
T=1ms ViewModel: engine.destroy() — frees native memory
↑ nativeHandle = 0L
T=0-20ms Capture thread: still in recorder.read() or writeAudio()
→ if in writeAudio(), the nativeHandle check passed BEFORE destroy()
→ JNI dereferences freed pointer → SIGSEGV
```
## Affected Code
### Files with the race
| File | Line(s) | Issue |
|------|---------|-------|
| `android/.../WzpEngine.kt` | 107-108, 116-117 | TOCTOU on `nativeHandle` in `writeAudio()` / `readAudio()` |
| `android/.../CallViewModel.kt` | 257-262 | `stopAudio()` + `destroy()` without waiting for audio threads to quiesce |
| `android/.../AudioPipeline.kt` | 80-82 | `stop()` doesn't synchronize with running threads |
### Files with the thread parking workaround
| File | Line(s) | Context |
|------|---------|---------|
| `android/.../AudioPipeline.kt` | 57-58, 69-70 | Threads parked after loop exit to avoid libcrypto TLS crash |
| `android/.../AudioPipeline.kt` | 96-101 | `parkThread()``Thread.sleep(Long.MAX_VALUE)` |
## Constraints for the Fix
1. **Cannot join audio threads** — joining triggers a separate SIGSEGV in `OPENSSL_free` when the thread's TLS destructors fire (documented in `AudioPipeline.kt` comments). The parking workaround must be preserved.
2. **Must guarantee no JNI calls after `destroy()`** — the native handle is a raw pointer; any dereference after free is undefined behavior.
3. **Must not add blocking waits on the UI thread**`teardown()` runs on the ViewModel thread which must remain responsive.
4. **The `@Volatile running` flag is necessary but not sufficient** — it prevents new loop iterations but doesn't help with in-flight JNI calls.
5. **Both `writeAudio` and `readAudio` have the same race** — the fix must cover both the capture and playout paths.
## Reproduction
The crash is timing-dependent. It's most likely to occur when:
- The capture thread is in the middle of a `writeAudio()` JNI call when `destroy()` is called
- More likely on slower devices or under CPU pressure (GC, thermal throttling)
- Can happen on every hangup, but only crashes ~10-30% of the time due to the timing window
## Analysis of Possible Fix Approaches
### Approach A: Add a synchronization gate in the JNI bridge
Use a `ReentrantReadWriteLock` or `AtomicBoolean` in `WzpEngine.kt`:
- Audio threads acquire a read lock / check the flag before JNI calls
- `destroy()` acquires a write lock / sets the flag and waits for in-flight calls to drain
**Pro:** Clean, solves the race directly.
**Con:** Adding a lock to the audio hot path (every 20ms). `ReentrantReadWriteLock` is not lock-free. However, the read-lock path is uncontended 99.99% of the time (write-lock only during destroy), so contention is negligible.
### Approach B: Defer `destroy()` until audio threads have stopped
Instead of calling `destroy()` in `teardown()`, set a flag and have the audio threads call `destroy()` after they exit the loop (before parking).
**Pro:** No locks on hot path.
**Con:** Complex lifecycle — which thread calls destroy? What if both threads race to destroy? Need a `CountDownLatch` or similar.
### Approach C: Make the JNI handle atomically invalidated
Use `AtomicLong` for `nativeHandle` and use `compareAndExchange` in `destroy()` + `getAndCheck` pattern in audio calls.
**Pro:** Lock-free.
**Con:** Still has a TOCTOU window — the thread can load the handle, then it gets CAS'd to 0, then the thread uses the stale handle. Doesn't fully solve the race without combining with a reference count or epoch.
### Approach D: Introduce a destroy latch
Add a `CountDownLatch(1)` that audio threads wait on before parking. `teardown()` sets `running=false`, then `await`s the latch (with timeout), then calls `destroy()`. Each audio thread counts down the latch after exiting the loop.
Actually this needs a `CountDownLatch(2)` — one for each thread (capture + playout).
**Pro:** Guarantees no in-flight JNI calls at destroy time. No locks on hot path.
**Con:** `teardown()` blocks for up to one frame duration (~20ms) waiting for threads to exit their loops. Acceptable for a hangup path.
### Recommendation
**Approach D (destroy latch)** is the cleanest. The 20ms worst-case wait is imperceptible on the hangup path, and it provides a hard guarantee that no JNI calls are in flight when `destroy()` runs. Combined with the existing `running` volatile flag, the audio threads exit their loops within one frame and count down the latch.
If the latch times out (e.g., AudioRecord.read() is stuck), `destroy()` proceeds anyway — the `panic::catch_unwind` in the JNI bridge will catch the invalid access as a panic rather than a SIGSEGV (though this is best-effort; a true SIGSEGV from freed memory is not catchable).
## Data Files
The crash was captured from the Nothing A059 device at 13:05:42 on 2026-04-06. The tombstone is in the device's `/data/tombstones/` directory. The logcat output shows the crash frames.

2
desktop/.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,143 +0,0 @@
<!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

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +0,0 @@
{
"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

@@ -1,36 +0,0 @@
[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

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 B

View File

@@ -1,365 +0,0 @@
//! 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

@@ -1,241 +0,0 @@
#![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

@@ -1,33 +0,0 @@
{
"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"
]
}
}

View File

@@ -1,110 +0,0 @@
/**
* 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;
}

View File

@@ -1,591 +0,0 @@
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();
}
});

View File

@@ -1,653 +0,0 @@
: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); }

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -1,15 +0,0 @@
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,
},
});

View File

@@ -0,0 +1,394 @@
# Fix: AudioRing SPSC Buffer Cursor Desync
## Problem
A critical bug causes 10-16 seconds of bidirectional audio silence mid-call (~25-30s in). Both participants go silent at the exact same moment. The QUIC transport, relay, Opus codec, and FEC are all healthy — the bug is in the lock-free ring buffer that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
**Root cause:** `AudioRing::write()` modifies `read_pos` from the producer thread during overflow handling (lines 68-72 of `audio_ring.rs`). This violates the SPSC invariant — only the consumer should own `read_pos`. When both threads write to `read_pos`, a race corrupts the cursor state, causing the reader to see an empty or stale buffer for 12-16 seconds.
**Full forensics:** `debug/INCIDENT-2026-04-06-playout-ring-desync.md`
---
## Solution: Reader-Detects-Lap Architecture
The writer NEVER touches `read_pos`. On overflow, the writer simply overwrites old buffer data and advances `write_pos`. The reader detects it was lapped and self-corrects by snapping its own `read_pos` forward.
---
## Implementation Steps
### Step 1: Rewrite `AudioRing`
**File:** `crates/wzp-android/src/audio_ring.rs`
Replace the entire implementation with:
**Constants:**
```rust
/// Ring buffer capacity — must be a power of 2 for bitmask indexing.
/// 16384 samples = 341.3ms at 48kHz mono. Provides 70% more headroom
/// than the previous 9600 (200ms) for surviving Android GC pauses.
const RING_CAPACITY: usize = 16384; // 2^14
const RING_MASK: usize = RING_CAPACITY - 1;
```
**Struct:**
```rust
pub struct AudioRing {
buf: Box<[i16; RING_CAPACITY]>,
write_pos: AtomicUsize, // monotonically increasing, ONLY written by producer
read_pos: AtomicUsize, // monotonically increasing, ONLY written by consumer
overflow_count: AtomicU64, // incremented by reader when it detects a lap
underrun_count: AtomicU64, // incremented by reader when ring is empty
}
```
**`write()` — producer. Does NOT touch `read_pos`:**
```rust
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()` — consumer. Detects lap, self-corrects:**
```rust
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.
// Snap read_pos forward to oldest valid data in the buffer.
// Safe because we (the reader) are the sole owner of read_pos.
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
}
```
**`available()` — clamped for external callers:**
```rust
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)
}
```
**`free_space()` — keep for API compat:**
```rust
pub fn free_space(&self) -> usize {
RING_CAPACITY.saturating_sub(self.available())
}
```
**Diagnostic accessors:**
```rust
pub fn overflow_count(&self) -> u64 {
self.overflow_count.load(Ordering::Relaxed)
}
pub fn underrun_count(&self) -> u64 {
self.underrun_count.load(Ordering::Relaxed)
}
```
**Constructor:**
```rust
pub fn new() -> Self {
debug_assert!(RING_CAPACITY.is_power_of_two());
Self {
buf: Box::new([0i16; RING_CAPACITY]),
write_pos: AtomicUsize::new(0),
read_pos: AtomicUsize::new(0),
overflow_count: AtomicU64::new(0),
underrun_count: AtomicU64::new(0),
}
}
```
**Imports to add:** `use std::sync::atomic::AtomicU64;`
**Safety comment update:**
```rust
// 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.
```
---
### Step 2: Add counter fields to `CallStats`
**File:** `crates/wzp-android/src/stats.rs`
Add three fields to the `CallStats` struct (after `fec_recovered`):
```rust
/// Playout ring overflow count (reader was lapped by writer).
pub playout_overflows: u64,
/// Playout ring underrun count (reader found empty buffer).
pub playout_underruns: u64,
/// Capture ring overflow count.
pub capture_overflows: u64,
```
These derive `Default` (= 0) automatically via the existing `#[derive(Default)]`.
---
### Step 3: Wire ring diagnostics into engine stats + logging
**File:** `crates/wzp-android/src/engine.rs`
**3a.** In `get_stats()` (~line 181), populate the new fields:
```rust
stats.playout_overflows = self.state.playout_ring.overflow_count();
stats.playout_underruns = self.state.playout_ring.underrun_count();
stats.capture_overflows = self.state.capture_ring.overflow_count();
```
**3b.** In the recv task periodic stats log, add ring health:
```rust
info!(
frames_decoded,
fec_recovered,
recv_errors,
max_recv_gap_ms,
playout_avail = state.playout_ring.available(),
playout_overflows = state.playout_ring.overflow_count(),
playout_underruns = state.playout_ring.underrun_count(),
"recv stats"
);
```
**3c.** In the send task periodic stats log, add capture ring health:
```rust
info!(
seq = s,
block_id,
frames_sent,
frames_dropped,
send_errors,
ring_avail = state.capture_ring.available(),
capture_overflows = state.capture_ring.overflow_count(),
"send stats"
);
```
---
### Step 4: Parse new stats in Kotlin
**File:** `android/app/src/main/java/com/wzp/engine/CallStats.kt`
Add fields to the data class:
```kotlin
val playoutOverflows: Long = 0,
val playoutUnderruns: Long = 0,
val captureOverflows: Long = 0,
```
Add parsing in `fromJson()`:
```kotlin
playoutOverflows = obj.optLong("playout_overflows", 0),
playoutUnderruns = obj.optLong("playout_underruns", 0),
captureOverflows = obj.optLong("capture_overflows", 0),
```
No UI changes needed — these fields will appear in debug report JSON automatically.
---
### Step 5: Unit tests
**File:** `crates/wzp-android/src/audio_ring.rs` — add `#[cfg(test)] mod tests`
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capacity_is_power_of_two() {
assert!(RING_CAPACITY.is_power_of_two());
}
#[test]
fn basic_write_read() {
let ring = AudioRing::new();
let input: Vec<i16> = (0..960).map(|i| i as i16).collect();
ring.write(&input);
assert_eq!(ring.available(), 960);
let mut output = vec![0i16; 960];
let read = ring.read(&mut output);
assert_eq!(read, 960);
assert_eq!(output, input);
assert_eq!(ring.available(), 0);
}
#[test]
fn wraparound() {
let ring = AudioRing::new();
let frame = vec![42i16; 960];
// Write enough to wrap the buffer multiple times
for _ in 0..20 {
ring.write(&frame);
let mut out = vec![0i16; 960];
ring.read(&mut out);
assert!(out.iter().all(|&s| s == 42));
}
}
#[test]
fn overflow_detected_by_reader() {
let ring = AudioRing::new();
// Write more than RING_CAPACITY without reading
let big = vec![7i16; RING_CAPACITY + 960];
ring.write(&big[..RING_CAPACITY]);
ring.write(&big[RING_CAPACITY..]);
// Reader should detect lap
let mut out = vec![0i16; 960];
let read = ring.read(&mut out);
assert!(read > 0);
assert_eq!(ring.overflow_count(), 1);
// Data should be from the most recent writes
assert!(out.iter().all(|&s| s == 7));
}
#[test]
fn writer_never_modifies_read_pos() {
let ring = AudioRing::new();
// Read pos should stay at 0 until read() is called
let data = vec![1i16; RING_CAPACITY + 960];
ring.write(&data);
// read_pos is private, but we can check available() > CAPACITY
// which proves write() didn't advance read_pos
let w = ring.write_pos.load(std::sync::atomic::Ordering::Relaxed);
let r = ring.read_pos.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(r, 0, "write() must not modify read_pos");
assert!(w.wrapping_sub(r) > RING_CAPACITY);
}
#[test]
fn underrun_counted() {
let ring = AudioRing::new();
let mut out = vec![0i16; 960];
let read = ring.read(&mut out);
assert_eq!(read, 0);
assert_eq!(ring.underrun_count(), 1);
}
#[test]
fn overflow_recovery_reads_recent_data() {
let ring = AudioRing::new();
// Fill with old data
let old = vec![1i16; RING_CAPACITY];
ring.write(&old);
// Overwrite with new data (lapping the reader)
let new_data = vec![99i16; 960];
ring.write(&new_data);
// Reader should snap forward and get recent data
let mut out = vec![0i16; RING_CAPACITY];
let read = ring.read(&mut out);
assert_eq!(read, RING_CAPACITY);
// The last 960 samples should be 99
assert!(out[RING_CAPACITY - 960..].iter().all(|&s| s == 99));
assert_eq!(ring.overflow_count(), 1);
}
}
```
---
## Memory Ordering Reference
| Operation | Ordering | Rationale |
|-----------|----------|-----------|
| `write_pos.store` in `write()` | Release | Buffer writes visible before cursor advances |
| `write_pos.load` in `read()` | Acquire | Pairs with Release above — sees all buffer writes |
| `write_pos.load` in `write()` | Relaxed | Writer is sole owner of write_pos |
| `read_pos.load` in `read()` | Relaxed | Reader is sole owner of read_pos |
| `read_pos.store` in `read()` | Release | Makes available() consistent from any thread |
| `read_pos.load` in `available()` | Relaxed | Informational only, slight staleness OK |
| All counters | Relaxed | Diagnostic only |
---
## Capacity Tradeoff
| Capacity | Duration | Memory | Verdict |
|----------|----------|--------|---------|
| 8192 (2^13) | 170ms | 16KB | Less than current 200ms — risky |
| **16384 (2^14)** | **341ms** | **32KB** | **70% more headroom, bitmask indexing** |
| 32768 (2^15) | 682ms | 64KB | Excessive latency on overflow recovery |
---
## Verification
1. `cargo test -p wzp-android` — new unit tests pass
2. `cargo ndk -t arm64-v8a build --release -p wzp-android` — ARM cross-compile succeeds
3. Build APK, install on both test devices (Nothing A059 + Pixel 6)
4. 2+ minute call — verify no audio gaps
5. Check debug report JSON: `playout_overflows` should be 0 or very small
6. Check logcat `wzp_android` tag: send/recv stats show healthy ring state
7. Stress test: play music through one device speaker while on call — forces high ring throughput
---
## Files to Modify
| File | What changes |
|------|-------------|
| `crates/wzp-android/src/audio_ring.rs` | Complete rewrite — the core fix |
| `crates/wzp-android/src/stats.rs` | Add 3 counter fields |
| `crates/wzp-android/src/engine.rs` | Wire counters into get_stats() + periodic logs |
| `android/app/src/main/java/com/wzp/engine/CallStats.kt` | Parse 3 new JSON fields |
## What Does NOT Change
- `AudioPipeline.kt` — calls `readAudio()`/`writeAudio()` unchanged; ring fix is transparent
- `jni_bridge.rs` — JNI bridge passes through unchanged
- `audio_android.rs` — separate Oboe-based ring, currently unused, different design
- Relay code — relay is confirmed healthy
- Desktop client — uses `Mutex + mpsc`, not `AudioRing`

View File

@@ -0,0 +1,149 @@
# Fix: Capture/Playout Thread Use-After-Free on Hangup
## Problem
App crashes (SIGSEGV) when hanging up a call. The capture thread (`wzp-capture`) calls `engine.writeAudio()` via JNI after `teardown()` has freed the native engine handle. Same race exists for the playout thread's `readAudio()`.
**Root cause:** TOCTOU race between the `nativeHandle == 0L` check in `WzpEngine.writeAudio()`/`readAudio()` and `destroy()` freeing the native memory on the ViewModel thread. Audio threads can't be joined (libcrypto TLS destructor crash), so there's no synchronization between `stopAudio()` and `destroy()`.
**Full forensics:** `debug/INCIDENT-2026-04-06-capture-thread-use-after-free.md`
---
## Solution: Destroy Latch
Add a `CountDownLatch(2)` that both audio threads count down after exiting their loops. `teardown()` awaits the latch (with timeout) before calling `destroy()`, guaranteeing no in-flight JNI calls.
---
## Implementation Steps
### Step 1: Add a drain latch to `AudioPipeline`
**File:** `android/app/src/main/java/com/wzp/audio/AudioPipeline.kt`
Add a `CountDownLatch` field:
```kotlin
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class AudioPipeline(private val context: Context) {
// ... existing fields ...
/** Latch counted down by each audio thread after exiting its loop.
* stop() does NOT wait on this — teardown waits via awaitDrain(). */
private var drainLatch: CountDownLatch? = null
```
In `start()`, create the latch before spawning threads:
```kotlin
fun start(engine: WzpEngine) {
if (running) return
running = true
drainLatch = CountDownLatch(2) // one for capture, one for playout
captureThread = Thread({
runCapture(engine)
drainLatch?.countDown() // signal: capture loop exited
parkThread()
}, "wzp-capture").apply { ... }
playoutThread = Thread({
runPlayout(engine)
drainLatch?.countDown() // signal: playout loop exited
parkThread()
}, "wzp-playout").apply { ... }
// ...
}
```
Add `awaitDrain()` — called by ViewModel before `destroy()`:
```kotlin
/** Block until both audio threads have exited their loops (max 200ms).
* After this returns, no more JNI calls to the engine will be made. */
fun awaitDrain(): Boolean {
return drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
}
```
`stop()` remains unchanged (non-blocking, sets `running = false`).
### Step 2: Update `CallViewModel.teardown()` to await drain
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
Change teardown to wait for audio threads before destroying:
```kotlin
private fun teardown(stopService: Boolean = true) {
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
val hadCall = audioStarted
CallService.onStopFromNotification = null
stopAudio() // sets running=false (non-blocking)
stopStatsPolling()
// Wait for audio threads to exit their loops before destroying the engine.
// This guarantees no in-flight JNI calls to writeAudio/readAudio.
val drained = audioPipeline?.awaitDrain() ?: true
if (!drained) {
Log.w(TAG, "teardown: audio threads did not drain in time")
}
audioPipeline = null
Log.i(TAG, "teardown: stopping engine")
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
engine = null
engineInitialized = false
// ... rest unchanged
}
```
**Key change:** `awaitDrain()` is called AFTER `stopAudio()` (which sets `running=false`) but BEFORE `engine?.destroy()`. The latch guarantees both threads have exited their `while(running)` loops and will never call `writeAudio`/`readAudio` again.
Also move `audioPipeline = null` to after `awaitDrain()` to keep the reference alive for the latch call.
### Step 3: Move `stopAudio()` pipeline nulling
**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
In `stopAudio()`, do NOT null out the pipeline — let `teardown()` handle it after drain:
```kotlin
private fun stopAudio() {
if (!audioStarted) return
audioPipeline?.stop() // sets running=false
// DON'T null audioPipeline here — teardown() needs it for awaitDrain()
audioRouteManager?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false
audioStarted = false
}
```
---
## Files to Modify
| File | What changes |
|------|-------------|
| `android/.../audio/AudioPipeline.kt` | Add `CountDownLatch`, `countDown()` in threads, `awaitDrain()` method |
| `android/.../ui/call/CallViewModel.kt` | `teardown()` calls `awaitDrain()` before `destroy()`; `stopAudio()` doesn't null pipeline |
## What Does NOT Change
- `WzpEngine.kt` — the `nativeHandle == 0L` guard stays as defense-in-depth
- `jni_bridge.rs``panic::catch_unwind` stays as last resort
- `AudioPipeline.stop()` — remains non-blocking
- Thread parking — still needed to avoid libcrypto TLS crash
## Verification
1. Build APK, install on test device
2. Make a call, hang up — verify no crash in logcat (`adb logcat -s AndroidRuntime:E DEBUG:F`)
3. Rapid call/hangup/call/hangup cycles — stress the teardown path
4. Check logcat for `teardown: audio threads did not drain in time` — should never appear under normal conditions
5. Verify debug report still works after hangup (latch doesn't interfere with report collection)

376
scripts/build-android-cloud.sh Executable file
View File

@@ -0,0 +1,376 @@
#!/usr/bin/env bash
set -euo pipefail
# Build WarzonePhone Android APK using a temporary Hetzner Cloud VPS.
# Creates a VM, builds both debug and release APKs, downloads them, destroys the VM.
#
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
#
# Usage:
# ./scripts/build-android-cloud.sh Full build (create → build → download → destroy)
# ./scripts/build-android-cloud.sh --prepare Create VM and install deps only
# ./scripts/build-android-cloud.sh --build Build on existing VM
# ./scripts/build-android-cloud.sh --transfer Download APKs from VM
# ./scripts/build-android-cloud.sh --destroy Delete the VM
# ./scripts/build-android-cloud.sh --all prepare + build + transfer (VM persists)
# ./scripts/build-android-cloud.sh --upload Re-upload source to existing VM
#
# Environment variables (all optional):
# WZP_BRANCH Branch to build (default: feat/android-voip-client)
# WZP_SERVER_TYPE Hetzner server type (default: cx32 — 4 vCPU, 8GB RAM)
# WZP_KEEP_VM Set to 1 to skip destroy on full build
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="${WZP_SERVER_TYPE:-cx33}"
IMAGE="ubuntu-24.04"
SERVER_NAME="wzp-android-builder"
REMOTE_USER="root"
OUTPUT_DIR="target/android-apk"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
KEEP_VM="${WZP_KEEP_VM:-0}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR"
# NDK 26.1 — NDK 27 crashes scudo on Android 16 MTE devices
NDK_VERSION="26.1.10909125"
ANDROID_API="34"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
die() { err "$@"; do_destroy_quiet; exit 1; }
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
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found. Run --prepare first."
ssh $SSH_OPTS -A -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
}
scp_down() {
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found."
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2"
}
do_destroy_quiet() {
local name
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -n "$name" ]; then
echo ""
err "Cleaning up — destroying VM $name"
hcloud server delete "$name" 2>/dev/null || true
fi
}
# ---------------------------------------------------------------------------
# --prepare: Create VM, install all build dependencies
# ---------------------------------------------------------------------------
do_prepare() {
# Check if VM already exists
local existing
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -n "$existing" ]; then
log "VM already exists: $existing — reusing"
do_upload
return
fi
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 \
|| die "Failed to create VM"
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "VM created but no IP found"
echo " VM: $SERVER_NAME @ $ip"
# Wait for SSH
log "Waiting for SSH..."
local ok=0
for i in $(seq 1 30); do
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
ok=1
break
fi
sleep 2
done
[ "$ok" -eq 1 ] || die "SSH timeout after 60s"
# System packages
log "Installing system packages (cmake, JDK 17, build tools)..."
ssh_cmd "export DEBIAN_FRONTEND=noninteractive && \
apt-get update -qq && \
apt-get install -y -qq \
build-essential cmake curl git libssl-dev pkg-config \
unzip wget zip openjdk-17-jdk-headless \
> /dev/null 2>&1" \
|| die "Failed to install system packages"
# Verify cmake version (must be <= 3.30)
local cmake_ver
cmake_ver=$(ssh_cmd "cmake --version | head -1")
echo " cmake: $cmake_ver"
echo " java: $(ssh_cmd "java -version 2>&1 | head -1")"
# Rust
log "Installing Rust toolchain..."
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" \
|| die "Failed to install Rust"
ssh_cmd "source \$HOME/.cargo/env && rustup target add aarch64-linux-android > /dev/null 2>&1"
ssh_cmd "source \$HOME/.cargo/env && cargo install cargo-ndk > /dev/null 2>&1" \
|| die "Failed to install cargo-ndk"
echo " rust: $(ssh_cmd "source \$HOME/.cargo/env && rustc --version")"
# Android SDK + NDK
log "Installing Android SDK + NDK $NDK_VERSION..."
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
mkdir -p \$HOME/android-sdk/cmdline-tools && \
cd /tmp && \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip && \
unzip -qo cmdtools.zip -d \$HOME/android-sdk/cmdline-tools && \
mv \$HOME/android-sdk/cmdline-tools/cmdline-tools \$HOME/android-sdk/cmdline-tools/latest 2>/dev/null; \
yes | \$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null 2>&1; \
\$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager --install \
'platforms;android-${ANDROID_API}' \
'build-tools;${ANDROID_API}.0.0' \
'ndk;${NDK_VERSION}' \
'platform-tools' \
2>&1 | grep -v '^\[' > /dev/null" \
|| die "Failed to install Android SDK/NDK"
ssh_cmd "[ -d \$HOME/android-sdk/ndk/$NDK_VERSION ]" \
|| die "NDK not found after install"
echo " NDK: $NDK_VERSION"
# Upload source
do_upload
log "VM ready!"
echo " IP: $ip"
echo " SSH: ssh -A -i $SSH_KEY_PATH root@$ip"
}
# ---------------------------------------------------------------------------
# --upload: Upload source code to VM
# ---------------------------------------------------------------------------
do_upload() {
log "Uploading source code (rsync)..."
local ip
ip=$(get_vm_ip)
[ -n "$ip" ] || die "No VM found."
rsync -az --delete \
--exclude='target' \
--exclude='.git' \
--exclude='.claude' \
--exclude='node_modules' \
--exclude='dist' \
--exclude='desktop/src-tauri/gen' \
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
"$PROJECT_DIR/" "$REMOTE_USER@$ip:/root/wzp-build/"
echo " Source uploaded."
}
# ---------------------------------------------------------------------------
# --build: Build native .so + debug & release APKs
# ---------------------------------------------------------------------------
do_build() {
log "Building Rust native library (arm64-v8a, release)..."
# Clean Rust release target to force full rebuild.
# cargo-ndk only copies libc++_shared.so when it actually links — a partial
# clean that skips relinking leaves libc++_shared.so missing from jniLibs.
ssh_cmd "rm -rf /root/wzp-build/target/aarch64-linux-android/release \
/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a"
# ANDROID_NDK must be set (not just ANDROID_NDK_HOME) — cmake checks it
ssh_cmd "source \$HOME/.cargo/env && \
export ANDROID_HOME=\$HOME/android-sdk && \
export ANDROID_NDK_HOME=\$ANDROID_HOME/ndk/$NDK_VERSION && \
export ANDROID_NDK=\$ANDROID_NDK_HOME && \
cd /root/wzp-build && \
cargo ndk -t arm64-v8a \
-o android/app/src/main/jniLibs \
build --release -p wzp-android 2>&1" | tail -5 \
|| die "Rust native build failed"
ssh_cmd "[ -f /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so ]" \
|| die "libwzp_android.so not found after build"
local so_size
so_size=$(ssh_cmd "du -h /root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so | cut -f1")
echo " .so: $so_size"
# Generate debug keystore if missing
ssh_cmd "[ -f /root/wzp-build/android/keystore/wzp-debug.jks ] || \
(mkdir -p /root/wzp-build/android/keystore && \
keytool -genkey -v \
-keystore /root/wzp-build/android/keystore/wzp-debug.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias wzp-debug -storepass android -keypass android \
-dname 'CN=WZP Debug' > /dev/null 2>&1)"
# Build debug APK
log "Building debug APK..."
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
export ANDROID_HOME=\$HOME/android-sdk && \
cd /root/wzp-build/android && \
chmod +x ./gradlew && \
./gradlew assembleDebug --no-daemon --warning-mode=none 2>&1" | tail -3 \
|| die "Debug APK build failed"
# Build release APK (uses debug keystore for now)
log "Building release APK..."
# Copy debug keystore as release keystore (same password in build.gradle)
ssh_cmd "cp /root/wzp-build/android/keystore/wzp-debug.jks /root/wzp-build/android/keystore/wzp-release.jks 2>/dev/null; true"
ssh_cmd "export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 && \
export ANDROID_HOME=\$HOME/android-sdk && \
cd /root/wzp-build/android && \
./gradlew assembleRelease --no-daemon --warning-mode=none 2>&1" | tail -3 \
|| echo " (release APK failed — debug APK still available)"
log "Build complete!"
ssh_cmd "find /root/wzp-build/android -name '*.apk' -path '*/outputs/apk/*' -exec ls -lh {} \;"
}
# ---------------------------------------------------------------------------
# --transfer: Download APKs to local machine
# ---------------------------------------------------------------------------
do_transfer() {
log "Downloading APKs..."
mkdir -p "$OUTPUT_DIR"
local ip
ip=$(get_vm_ip)
# Debug APK
local debug_apk
debug_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-debug*.apk' -path '*/outputs/apk/*' | head -1")
if [ -n "$debug_apk" ]; then
scp_down "$debug_apk" "$OUTPUT_DIR/wzp-debug.apk"
echo " debug: $OUTPUT_DIR/wzp-debug.apk ($(du -h "$OUTPUT_DIR/wzp-debug.apk" | cut -f1))"
fi
# Release APK
local release_apk
release_apk=$(ssh_cmd "find /root/wzp-build/android -name 'app-release*.apk' -path '*/outputs/apk/*' | head -1" || true)
if [ -n "$release_apk" ]; then
scp_down "$release_apk" "$OUTPUT_DIR/wzp-release.apk"
echo " release: $OUTPUT_DIR/wzp-release.apk ($(du -h "$OUTPUT_DIR/wzp-release.apk" | cut -f1))"
fi
# Also copy the .so for inspection
scp_down "/root/wzp-build/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so" "$OUTPUT_DIR/libwzp_android.so"
echo " .so: $OUTPUT_DIR/libwzp_android.so"
log "Transfer complete!"
echo ""
echo " Install debug: adb install -r $OUTPUT_DIR/wzp-debug.apk"
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && echo " Install release: adb install -r $OUTPUT_DIR/wzp-release.apk"
}
# ---------------------------------------------------------------------------
# --destroy: Delete the VM
# ---------------------------------------------------------------------------
do_destroy() {
local name
name=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -z "$name" ]; then
echo "No VM to destroy."
return
fi
log "Deleting VM: $name"
hcloud server delete "$name"
echo " Done."
}
# ---------------------------------------------------------------------------
# Full build: create → build → transfer → destroy
# ---------------------------------------------------------------------------
do_full() {
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
do_prepare
# Disable trap during build — release APK failure is non-fatal
trap - ERR
do_build
do_transfer
trap 'err "Build failed!"; do_destroy_quiet; exit 1' ERR
if [ "$KEEP_VM" = "1" ]; then
log "VM kept alive (WZP_KEEP_VM=1). Destroy with: $0 --destroy"
else
do_destroy
fi
log "All done!"
echo ""
echo " ┌──────────────────────────────────────────────────┐"
echo " │ Debug APK: $OUTPUT_DIR/wzp-debug.apk"
[ -f "$OUTPUT_DIR/wzp-release.apk" ] && \
echo " │ Release APK: $OUTPUT_DIR/wzp-release.apk"
echo " │"
echo " │ Install: adb install -r $OUTPUT_DIR/wzp-debug.apk"
echo " └──────────────────────────────────────────────────┘"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
--prepare) do_prepare ;;
--build) do_build ;;
--transfer) do_transfer ;;
--destroy) do_destroy ;;
--upload) do_upload ;;
--all)
do_prepare
do_build
do_transfer
log "VM still running. Destroy with: $0 --destroy"
;;
"")
do_full
;;
*)
echo "Usage: $0 [--prepare|--build|--transfer|--destroy|--all|--upload]"
echo ""
echo " (no args) Full build: create VM → build → download → destroy VM"
echo " --prepare Create VM and install deps"
echo " --build Build on existing VM"
echo " --transfer Download APKs from VM"
echo " --destroy Delete the VM"
echo " --all prepare + build + transfer (VM persists)"
echo " --upload Re-upload source to existing VM"
echo ""
echo "Environment:"
echo " WZP_BRANCH=$BRANCH"
echo " WZP_SERVER_TYPE=$SERVER_TYPE"
echo " WZP_KEEP_VM=$KEEP_VM (set to 1 to skip auto-destroy)"
exit 1
;;
esac

240
scripts/build-android.sh Executable file
View File

@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# =============================================================================
# WZ Phone — Android APK build script for Debian 12 (Bookworm)
#
# Sets up a complete build environment from scratch and produces a debug APK.
# Idempotent — safe to run multiple times (skips already-installed components).
#
# Tested on: Debian 12 x86_64, cross-compiling to aarch64-linux-android
#
# Why these specific versions:
#
# cmake 3.25-3.28 (system package from apt)
# cmake 3.25 (Debian 12) and 3.28 (Ubuntu 24.04) both work.
# cmake 3.31+ has armv7/aarch64 flag conflicts in Android-Determine.cmake.
# cmake 4.x drops cmake_minimum_required < 3.5.
# Do NOT use pip cmake — it bundles its own modules with different bugs.
# CRITICAL: must set ANDROID_NDK=$ANDROID_NDK_HOME (cmake checks ANDROID_NDK).
#
# NDK 26.1.10909125 (r26b)
# NDK 27+ ships a newer libc++_shared.so with different scudo allocator
# defaults. On Android 16 devices with MTE (Memory Tagging Extension)
# enabled (e.g. Nothing A059), NDK 27's scudo crashes during malloc/calloc.
# NDK 26.1 is the last stable version for these devices.
# Matches build.gradle.kts: ndkVersion = "26.1.10909125"
#
# JDK 17 (openjdk-17-jdk-headless)
# Gradle 8.5 + AGP 8.2.0 officially support JDK 17.
# JDK 21 works for compilation but has Gradle daemon compat issues.
#
# Rust stable (currently 1.94.1)
# Edition 2024, MSRV 1.85. Stable channel is fine.
#
# ANDROID_NDK=$ANDROID_NDK_HOME (BOTH must be set)
# cmake's Android platform module checks ANDROID_NDK (no _HOME suffix).
# cargo-ndk sets ANDROID_NDK_HOME. Both must point to the same path.
#
# Usage:
# chmod +x scripts/build-android.sh
# ./scripts/build-android.sh # build from current tree
# WZP_CLONE=1 ./scripts/build-android.sh # clone fresh from git
# WZP_COMMIT=2092245 ./scripts/build-android.sh # pin to specific commit
#
# Environment variables (all optional):
# WZP_CLONE Set to 1 to clone from git instead of using current dir
# WZP_REPO Git clone URL (default: ssh://git@git.manko.yoga:222/manawenuz/wz-phone)
# WZP_BRANCH Branch to checkout (default: feat/android-voip-client)
# WZP_COMMIT Commit to pin to (default: HEAD)
# WZP_WORKDIR Build directory (default: /tmp/wzp-build)
# ANDROID_API SDK platform level (default: 34)
# NDK_VERSION NDK version string (default: 26.1.10909125)
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
CLONE="${WZP_CLONE:-0}"
REPO="${WZP_REPO:-ssh://git@git.manko.yoga:222/manawenuz/wz-phone}"
BRANCH="${WZP_BRANCH:-feat/android-voip-client}"
COMMIT="${WZP_COMMIT:-}"
WORKDIR="${WZP_WORKDIR:-/tmp/wzp-build}"
ANDROID_API="${ANDROID_API:-34}"
NDK_VERSION="${NDK_VERSION:-26.1.10909125}"
ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
# cmake checks ANDROID_NDK (not _HOME) — both must be set
ANDROID_NDK="$ANDROID_NDK_HOME"
JAVA_HOME="/usr/lib/jvm/java-17-openjdk-$(dpkg --print-architecture)"
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
export ANDROID_HOME ANDROID_NDK_HOME ANDROID_NDK JAVA_HOME
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; exit 1; }
# ---------------------------------------------------------------------------
# Step 1: System packages (cmake 3.25, JDK 17, make, git, etc.)
# ---------------------------------------------------------------------------
log "Installing system packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
build-essential \
cmake \
curl \
git \
libssl-dev \
pkg-config \
unzip \
wget \
zip \
openjdk-17-jdk-headless \
2>/dev/null
# Verify critical versions
log "Verifying build environment"
echo " cmake: $(cmake --version | head -1)"
echo " java: $(java -version 2>&1 | head -1)"
echo " make: $(make --version | head -1)"
CMAKE_MAJOR=$(cmake --version | head -1 | grep -oP '\d+' | head -1)
CMAKE_MINOR=$(cmake --version | head -1 | grep -oP '\d+' | sed -n '2p')
if [ "$CMAKE_MAJOR" -gt 3 ] || { [ "$CMAKE_MAJOR" -eq 3 ] && [ "$CMAKE_MINOR" -gt 30 ]; }; then
err "cmake $(cmake --version | head -1) is too new! Need cmake <= 3.28.x. cmake 3.31+ has Android cross-compilation bugs."
fi
# ---------------------------------------------------------------------------
# Step 2: Rust toolchain
# ---------------------------------------------------------------------------
log "Setting up Rust toolchain"
if ! command -v rustup &>/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
source "$HOME/.cargo/env"
fi
rustup default stable
rustup target add aarch64-linux-android
echo " rustc: $(rustc --version)"
echo " cargo: $(cargo --version)"
if ! command -v cargo-ndk &>/dev/null; then
log "Installing cargo-ndk"
cargo install cargo-ndk
fi
echo " ndk: $(cargo ndk --version)"
# ---------------------------------------------------------------------------
# Step 3: Android SDK + NDK 26.1
# ---------------------------------------------------------------------------
log "Setting up Android SDK + NDK $NDK_VERSION"
if [ ! -f "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
log "Downloading Android command-line tools"
mkdir -p "$ANDROID_HOME/cmdline-tools"
TMPZIP=$(mktemp /tmp/cmdline-tools-XXXXX.zip)
wget -q -O "$TMPZIP" "$CMDLINE_TOOLS_URL"
unzip -qo "$TMPZIP" -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" 2>/dev/null || true
rm -f "$TMPZIP"
fi
yes | sdkmanager --licenses >/dev/null 2>&1 || true
if [ ! -d "$ANDROID_NDK_HOME" ]; then
log "Installing NDK $NDK_VERSION (this takes a few minutes)"
sdkmanager --install \
"platforms;android-${ANDROID_API}" \
"build-tools;${ANDROID_API}.0.0" \
"ndk;${NDK_VERSION}" \
"platform-tools" \
2>&1 | grep -v "^\[" || true
fi
[ -d "$ANDROID_NDK_HOME" ] || err "NDK not found at $ANDROID_NDK_HOME"
echo " NDK: $ANDROID_NDK_HOME"
echo " SDK: $ANDROID_HOME"
# ---------------------------------------------------------------------------
# Step 4: Source code
# ---------------------------------------------------------------------------
if [ "$CLONE" = "1" ]; then
log "Cloning $REPO (branch: $BRANCH)"
if [ -d "$WORKDIR/.git" ]; then
cd "$WORKDIR"
git fetch origin
else
rm -rf "$WORKDIR"
git clone --branch "$BRANCH" --recurse-submodules "$REPO" "$WORKDIR"
cd "$WORKDIR"
fi
git checkout "$BRANCH"
git pull origin "$BRANCH" || true
git submodule update --init --recursive
if [ -n "$COMMIT" ]; then
log "Pinning to commit $COMMIT"
git checkout "$COMMIT"
fi
else
# Use current directory (assume we're in the repo root)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WORKDIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$WORKDIR"
[ -f "Cargo.toml" ] || err "Not in repo root. Run from repo root or set WZP_CLONE=1"
fi
echo " HEAD: $(git log --oneline -1)"
# ---------------------------------------------------------------------------
# Step 5: Build native Rust library (.so)
# ---------------------------------------------------------------------------
log "Building Rust native library (arm64-v8a, release)"
cargo ndk -t arm64-v8a \
-o "$WORKDIR/android/app/src/main/jniLibs" \
build --release -p wzp-android
SO="$WORKDIR/android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so"
[ -f "$SO" ] || err ".so not found at $SO"
echo " Built: $SO ($(du -h "$SO" | cut -f1))"
# ---------------------------------------------------------------------------
# Step 6: Generate debug keystore (if missing)
# ---------------------------------------------------------------------------
KEYSTORE="$WORKDIR/android/keystore/wzp-debug.jks"
if [ ! -f "$KEYSTORE" ]; then
log "Generating debug keystore"
mkdir -p "$(dirname "$KEYSTORE")"
keytool -genkey -v \
-keystore "$KEYSTORE" \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias wzp-debug \
-storepass android -keypass android \
-dname "CN=WZP Debug" 2>&1 | tail -1
fi
# ---------------------------------------------------------------------------
# Step 7: Build Android APK
# ---------------------------------------------------------------------------
log "Building APK (debug)"
cd "$WORKDIR/android"
chmod +x ./gradlew
./gradlew assembleDebug --no-daemon --warning-mode=none
APK=$(find . -name "app-debug*.apk" -path "*/outputs/apk/*" | head -1)
[ -n "$APK" ] || err "APK not found"
APK_ABS="$(cd "$(dirname "$APK")" && pwd)/$(basename "$APK")"
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
log "Build complete!"
echo ""
echo " ┌──────────────────────────────────────────────────────────┐"
echo " │ APK: $APK_ABS"
echo " │ Size: $(du -h "$APK_ABS" | cut -f1)"
echo " │ SHA256: $(sha256sum "$APK_ABS" | cut -d' ' -f1)"
echo " └──────────────────────────────────────────────────────────┘"
echo ""
echo " Install: adb install -r $APK_ABS"
echo ""

View File

@@ -1,122 +0,0 @@
#!/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/"