Compare commits
22 Commits
2288c1ae07
...
android-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75bc72a884 | ||
|
|
6aa52accef | ||
|
|
d0c17317ea | ||
|
|
5799d18aee | ||
|
|
46c9ee1be3 | ||
|
|
b53eae9192 | ||
|
|
a3f54566d4 | ||
|
|
76e9fe5e43 | ||
|
|
b0a89d4f39 | ||
|
|
abc96e8887 | ||
|
|
3a6ae61f8d | ||
|
|
4c536d256b | ||
|
|
b0ec9ff4ab | ||
|
|
5855533a39 | ||
|
|
ed09c2e8cc | ||
|
|
f44306cc17 | ||
|
|
0b821585ab | ||
|
|
faec332a8c | ||
|
|
fe9ae276dc | ||
|
|
4fbf6770c4 | ||
|
|
30a893a73f | ||
|
|
d46f3b1deb |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4370,6 +4370,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.7.9",
|
"axum 0.7.9",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
|
|||||||
22
Cargo.toml
22
Cargo.toml
@@ -10,7 +10,6 @@ members = [
|
|||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-android",
|
"crates/wzp-android",
|
||||||
"desktop/src-tauri",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -54,24 +53,3 @@ wzp-fec = { path = "crates/wzp-fec" }
|
|||||||
wzp-crypto = { path = "crates/wzp-crypto" }
|
wzp-crypto = { path = "crates/wzp-crypto" }
|
||||||
wzp-transport = { path = "crates/wzp-transport" }
|
wzp-transport = { path = "crates/wzp-transport" }
|
||||||
wzp-client = { path = "crates/wzp-client" }
|
wzp-client = { path = "crates/wzp-client" }
|
||||||
|
|
||||||
# Fast dev profile: optimized but with debug info and incremental compilation.
|
|
||||||
# Use with: cargo run --profile dev-fast
|
|
||||||
[profile.dev-fast]
|
|
||||||
inherits = "dev"
|
|
||||||
opt-level = 2
|
|
||||||
|
|
||||||
# Optimize heavy compute deps even in debug builds —
|
|
||||||
# real-time audio needs < 20ms per frame, impossible unoptimized.
|
|
||||||
[profile.dev.package.nnnoiseless]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.audiopus_sys]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.audiopus]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.raptorq]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.wzp-codec]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.wzp-fec]
|
|
||||||
opt-level = 3
|
|
||||||
|
|||||||
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
97
android/app/src/main/java/com/wzp/engine/SignalManager.kt
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package com.wzp.engine
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent signal connection for direct 1:1 calls.
|
||||||
|
* Separate from WzpEngine — survives across calls.
|
||||||
|
*
|
||||||
|
* Lifecycle: connect() → [placeCall/answerCall] → destroy()
|
||||||
|
*/
|
||||||
|
class SignalManager {
|
||||||
|
|
||||||
|
private var handle: Long = 0L
|
||||||
|
|
||||||
|
val isConnected: Boolean get() = handle != 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to relay and register for direct calls.
|
||||||
|
* MUST be called from a thread with sufficient stack (8MB).
|
||||||
|
* Blocks briefly during QUIC connect + register, then returns.
|
||||||
|
*/
|
||||||
|
fun connect(relay: String, seedHex: String): Boolean {
|
||||||
|
if (handle != 0L) return true // already connected
|
||||||
|
handle = nativeSignalConnect(relay, seedHex)
|
||||||
|
return handle != 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current signal state as parsed object. Non-blocking. */
|
||||||
|
fun getState(): SignalState {
|
||||||
|
if (handle == 0L) return SignalState()
|
||||||
|
val json = nativeSignalGetState(handle) ?: return SignalState()
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
SignalState(
|
||||||
|
status = obj.optString("status", "idle"),
|
||||||
|
fingerprint = obj.optString("fingerprint", ""),
|
||||||
|
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id"),
|
||||||
|
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp"),
|
||||||
|
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias"),
|
||||||
|
callSetupRelay = if (obj.isNull("call_setup_relay")) null else obj.optString("call_setup_relay"),
|
||||||
|
callSetupRoom = if (obj.isNull("call_setup_room")) null else obj.optString("call_setup_room"),
|
||||||
|
callSetupId = if (obj.isNull("call_setup_id")) null else obj.optString("call_setup_id"),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SignalState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place a direct call to a target fingerprint. */
|
||||||
|
fun placeCall(targetFp: String): Int {
|
||||||
|
if (handle == 0L) return -1
|
||||||
|
return nativeSignalPlaceCall(handle, targetFp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer an incoming call. mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric */
|
||||||
|
fun answerCall(callId: String, mode: Int = 2): Int {
|
||||||
|
if (handle == 0L) return -1
|
||||||
|
return nativeSignalAnswerCall(handle, callId, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send hangup signal. */
|
||||||
|
fun hangup() {
|
||||||
|
if (handle != 0L) nativeSignalHangup(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy the signal manager. */
|
||||||
|
fun destroy() {
|
||||||
|
if (handle != 0L) {
|
||||||
|
nativeSignalDestroy(handle)
|
||||||
|
handle = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JNI native methods
|
||||||
|
private external fun nativeSignalConnect(relay: String, seed: String): Long
|
||||||
|
private external fun nativeSignalGetState(handle: Long): String?
|
||||||
|
private external fun nativeSignalPlaceCall(handle: Long, targetFp: String): Int
|
||||||
|
private external fun nativeSignalAnswerCall(handle: Long, callId: String, mode: Int): Int
|
||||||
|
private external fun nativeSignalHangup(handle: Long)
|
||||||
|
private external fun nativeSignalDestroy(handle: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
init { System.loadLibrary("wzp_android") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signal connection state. */
|
||||||
|
data class SignalState(
|
||||||
|
val status: String = "idle",
|
||||||
|
val fingerprint: String = "",
|
||||||
|
val incomingCallId: String? = null,
|
||||||
|
val incomingCallerFp: String? = null,
|
||||||
|
val incomingCallerAlias: String? = null,
|
||||||
|
val callSetupRelay: String? = null,
|
||||||
|
val callSetupRoom: String? = null,
|
||||||
|
val callSetupId: String? = null,
|
||||||
|
)
|
||||||
@@ -159,6 +159,18 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
private external fun nativeWriteAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, sampleCount: Int): 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 nativeReadAudioDirect(handle: Long, buffer: java.nio.ByteBuffer, maxSamples: Int): Int
|
||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
init { System.loadLibrary("wzp_android") }
|
||||||
|
|
||||||
|
/** Get the identity fingerprint for a seed hex. No engine needed. */
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativeGetFingerprint(seedHex: String): String?
|
||||||
|
|
||||||
|
/** Compute the full identity fingerprint (xxxx:xxxx:...) from a seed hex string. */
|
||||||
|
@JvmStatic
|
||||||
|
fun getFingerprint(seedHex: String): String = nativeGetFingerprint(seedHex) ?: ""
|
||||||
|
}
|
||||||
private external fun nativePingRelay(handle: Long, relay: String): String?
|
private external fun nativePingRelay(handle: Long, relay: String): String?
|
||||||
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
|
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
|
||||||
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
|
||||||
@@ -208,11 +220,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
return nativeAnswerCall(nativeHandle, callId, mode)
|
return nativeAnswerCall(nativeHandle, callId, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
init {
|
|
||||||
System.loadLibrary("wzp_android")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
/** Integer constants matching the Rust [CallState] enum ordinals. */
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _targetFingerprint = MutableStateFlow("")
|
private val _targetFingerprint = MutableStateFlow("")
|
||||||
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
||||||
|
|
||||||
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
/** Signal state string: "idle", "registered", "ringing", "incoming", "setup" */
|
||||||
private val _signalState = MutableStateFlow(0)
|
private val _signalState = MutableStateFlow("idle")
|
||||||
val signalState: StateFlow<Int> = _signalState.asStateFlow()
|
val signalState: StateFlow<String> = _signalState.asStateFlow()
|
||||||
|
|
||||||
/** Incoming call info */
|
/** Incoming call info */
|
||||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
private val _incomingCallId = MutableStateFlow<String?>(null)
|
||||||
@@ -155,34 +155,82 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
||||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
||||||
|
|
||||||
|
/** Separate signal manager (persistent, survives calls) */
|
||||||
|
private var signalManager: com.wzp.engine.SignalManager? = null
|
||||||
|
private var signalPollJob: Job? = null
|
||||||
|
|
||||||
fun setCallMode(mode: Int) { _callMode.value = mode }
|
fun setCallMode(mode: Int) { _callMode.value = mode }
|
||||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
||||||
|
|
||||||
/** Register on relay for direct calls */
|
/** Register on relay for direct calls */
|
||||||
fun registerForCalls() {
|
fun registerForCalls() {
|
||||||
if (engine == null) {
|
|
||||||
engine = WzpEngine(this).also { it.init() }
|
|
||||||
}
|
|
||||||
val serverIdx = _selectedServer.value
|
val serverIdx = _selectedServer.value
|
||||||
val serverList = _servers.value
|
val serverList = _servers.value
|
||||||
if (serverIdx >= serverList.size) return
|
if (serverIdx >= serverList.size) return
|
||||||
|
|
||||||
val relay = serverList[serverIdx].address
|
val relay = serverList[serverIdx].address
|
||||||
val seed = _seedHex.value
|
var seed = _seedHex.value
|
||||||
val alias = _alias.value
|
// Generate seed if empty (fresh install or cleared storage)
|
||||||
|
if (seed.isEmpty()) {
|
||||||
|
val newSeed = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) }
|
||||||
|
seed = newSeed.joinToString("") { "%02x".format(it) }
|
||||||
|
_seedHex.value = seed
|
||||||
|
settings?.saveSeedHex(seed)
|
||||||
|
Log.i(TAG, "generated new identity seed")
|
||||||
|
}
|
||||||
|
val resolvedRelay = resolveToIp(relay) ?: relay
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
// nativeSignalConnect has JNI overhead — must be on a thread with enough stack.
|
||||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
// Dispatchers.IO threads overflow. Use explicit Java Thread.
|
||||||
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
Thread(null, {
|
||||||
if (result == 0) {
|
try {
|
||||||
_signalState.value = 5 // Registered
|
val mgr = com.wzp.engine.SignalManager()
|
||||||
startStatsPolling()
|
val ok = mgr.connect(resolvedRelay, seed)
|
||||||
} else {
|
viewModelScope.launch {
|
||||||
_errorMessage.value = "Failed to register on relay"
|
if (ok) {
|
||||||
|
signalManager = mgr
|
||||||
|
startSignalPolling()
|
||||||
|
} else {
|
||||||
|
_errorMessage.value = "Failed to register on relay"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_errorMessage.value = "Register error: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "wzp-signal-init", 8 * 1024 * 1024).start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll signal manager state every 500ms */
|
||||||
|
private fun startSignalPolling() {
|
||||||
|
signalPollJob?.cancel()
|
||||||
|
signalPollJob = viewModelScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val mgr = signalManager
|
||||||
|
if (mgr != null && mgr.isConnected) {
|
||||||
|
val state = mgr.getState()
|
||||||
|
_signalState.value = state.status
|
||||||
|
_incomingCallId.value = state.incomingCallId
|
||||||
|
_incomingCallerFp.value = state.incomingCallerFp
|
||||||
|
_incomingCallerAlias.value = state.incomingCallerAlias
|
||||||
|
|
||||||
|
// Auto-connect to media room when call is set up
|
||||||
|
if (state.status == "setup" && state.callSetupRelay != null && state.callSetupRoom != null) {
|
||||||
|
Log.i(TAG, "CallSetup: connecting to ${state.callSetupRelay} room ${state.callSetupRoom}")
|
||||||
|
startCallInternal(state.callSetupRelay, state.callSetupRoom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(500L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun stopSignalPolling() {
|
||||||
|
signalPollJob?.cancel()
|
||||||
|
signalPollJob = null
|
||||||
|
}
|
||||||
|
|
||||||
/** Place a direct call to the target fingerprint */
|
/** Place a direct call to the target fingerprint */
|
||||||
fun placeDirectCall() {
|
fun placeDirectCall() {
|
||||||
val target = _targetFingerprint.value.trim()
|
val target = _targetFingerprint.value.trim()
|
||||||
@@ -190,24 +238,28 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
_errorMessage.value = "Enter a fingerprint to call"
|
_errorMessage.value = "Enter a fingerprint to call"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
engine?.placeCall(target)
|
signalManager?.placeCall(target)
|
||||||
_signalState.value = 6 // Ringing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Answer an incoming direct call */
|
/** Answer an incoming direct call */
|
||||||
fun answerIncomingCall(mode: Int = 2) {
|
fun answerIncomingCall(mode: Int = 2) {
|
||||||
val callId = _incomingCallId.value ?: return
|
val callId = _incomingCallId.value ?: return
|
||||||
engine?.answerCall(callId, mode)
|
signalManager?.answerCall(callId, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reject an incoming direct call */
|
/** Reject an incoming direct call */
|
||||||
fun rejectIncomingCall() {
|
fun rejectIncomingCall() {
|
||||||
val callId = _incomingCallId.value ?: return
|
val callId = _incomingCallId.value ?: return
|
||||||
engine?.answerCall(callId, 0) // 0 = Reject
|
signalManager?.answerCall(callId, 0)
|
||||||
_signalState.value = 5 // Back to registered
|
}
|
||||||
_incomingCallId.value = null
|
|
||||||
_incomingCallerFp.value = null
|
/** Hang up direct call — media ends, signal stays alive */
|
||||||
_incomingCallerAlias.value = null
|
fun hangupDirectCall() {
|
||||||
|
signalManager?.hangup()
|
||||||
|
engine?.stopCall()
|
||||||
|
engine?.destroy()
|
||||||
|
engine = null
|
||||||
|
engineInitialized = false
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -685,30 +737,10 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
val s = CallStats.fromJson(json)
|
val s = CallStats.fromJson(json)
|
||||||
lastCallDuration = s.durationSecs
|
lastCallDuration = s.durationSecs
|
||||||
_stats.value = s
|
_stats.value = s
|
||||||
|
// Only update callState from media engine stats (not signal)
|
||||||
if (s.state != 0) {
|
if (s.state != 0) {
|
||||||
_callState.value = s.state
|
_callState.value = s.state
|
||||||
}
|
}
|
||||||
// Track signal state changes for direct calling
|
|
||||||
if (s.state in 5..7) {
|
|
||||||
_signalState.value = s.state
|
|
||||||
}
|
|
||||||
// Incoming call detection
|
|
||||||
if (s.state == 7) { // IncomingCall
|
|
||||||
_incomingCallId.value = s.incomingCallId
|
|
||||||
_incomingCallerFp.value = s.incomingCallerFp
|
|
||||||
_incomingCallerAlias.value = s.incomingCallerAlias
|
|
||||||
}
|
|
||||||
// CallSetup: auto-connect to media room
|
|
||||||
if (s.state == 1 && s.incomingCallId != null && s.incomingCallId.contains("|")) {
|
|
||||||
// Format: "relay_addr|room_name"
|
|
||||||
val parts = s.incomingCallId.split("|", limit = 2)
|
|
||||||
if (parts.size == 2) {
|
|
||||||
val mediaRelay = parts[0]
|
|
||||||
val mediaRoom = parts[1]
|
|
||||||
Log.i(TAG, "CallSetup: connecting to $mediaRelay room $mediaRoom")
|
|
||||||
startCallInternal(mediaRelay, mediaRoom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (s.state == 2 && !audioStarted) {
|
if (s.state == 2 && !audioStarted) {
|
||||||
startAudio()
|
startAudio()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.wzp.ui.call
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -166,7 +165,7 @@ fun InCallScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "ENCRYPTED VOICE",
|
text = "ENCRYPTED VOICE \u2022 direct-call-v1",
|
||||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp),
|
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 3.sp),
|
||||||
color = TextDim
|
color = TextDim
|
||||||
)
|
)
|
||||||
@@ -220,7 +219,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
// Mode toggle: Room vs Direct Call
|
// Mode toggle: Room vs Direct Call
|
||||||
val callMode by viewModel.callMode.collectAsState()
|
val callMode by viewModel.callMode.collectAsState()
|
||||||
val signalState by viewModel.signalState.collectAsState()
|
val signalState by viewModel.signalState.collectAsState() // "idle"/"registered"/"ringing"/etc
|
||||||
val targetFp by viewModel.targetFingerprint.collectAsState()
|
val targetFp by viewModel.targetFingerprint.collectAsState()
|
||||||
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
||||||
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
||||||
@@ -310,7 +309,7 @@ fun InCallScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ── Direct call mode ──
|
// ── Direct call mode ──
|
||||||
if (signalState < 5) {
|
if (signalState == "idle") {
|
||||||
// Not registered yet
|
// Not registered yet
|
||||||
SectionLabel("ALIAS")
|
SectionLabel("ALIAS")
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -334,7 +333,7 @@ fun InCallScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (signalState == 5) {
|
} else if (signalState == "registered" || signalState == "incoming") {
|
||||||
// Registered — show dial pad
|
// Registered — show dial pad
|
||||||
Text(
|
Text(
|
||||||
"\u2705 Registered — waiting for calls",
|
"\u2705 Registered — waiting for calls",
|
||||||
@@ -404,8 +403,7 @@ fun InCallScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (signalState == 6) {
|
} else if (signalState == "ringing") {
|
||||||
// Ringing
|
|
||||||
Text(
|
Text(
|
||||||
"\uD83D\uDD14 Ringing...",
|
"\uD83D\uDD14 Ringing...",
|
||||||
color = Yellow,
|
color = Yellow,
|
||||||
@@ -413,11 +411,10 @@ fun InCallScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
} else if (signalState == 7) {
|
} else if (signalState == "setup") {
|
||||||
// Incoming call (state 7 also handled above in registered view)
|
|
||||||
Text(
|
Text(
|
||||||
"\uD83D\uDCDE Incoming call...",
|
"Connecting to call...",
|
||||||
color = Green,
|
color = Accent,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@@ -432,14 +429,16 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
// Identity
|
// Identity — compute real fingerprint from seed
|
||||||
val fp = if (seedHex.length >= 16) seedHex.take(16) else ""
|
val fullFp = remember(seedHex) {
|
||||||
|
if (seedHex.length >= 64) com.wzp.engine.WzpEngine.getFingerprint(seedHex) else ""
|
||||||
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (fp.isNotEmpty()) {
|
if (fullFp.isNotEmpty()) {
|
||||||
Identicon(fingerprint = seedHex, size = 28.dp)
|
Identicon(fingerprint = fullFp, size = 28.dp)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
CopyableFingerprint(
|
CopyableFingerprint(
|
||||||
fingerprint = fp.chunked(4).joinToString(":"),
|
fingerprint = fullFp,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
color = TextDim
|
color = TextDim
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ impl WzpEngine {
|
|||||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
||||||
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
||||||
let addr: SocketAddr = address.parse()?;
|
let addr: SocketAddr = address.parse()?;
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -245,154 +244,7 @@ impl WzpEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start persistent signaling connection for direct calls.
|
/// Start persistent signaling connection for direct calls.
|
||||||
/// Spawns a background task that maintains the `_signal` connection.
|
// Signal methods (start_signaling, place_call, answer_call) moved to signal_mgr.rs
|
||||||
pub fn start_signaling(
|
|
||||||
&mut self,
|
|
||||||
relay_addr: &str,
|
|
||||||
seed_hex: &str,
|
|
||||||
token: Option<&str>,
|
|
||||||
alias: Option<&str>,
|
|
||||||
) -> Result<(), anyhow::Error> {
|
|
||||||
use wzp_proto::{MediaTransport, SignalMessage};
|
|
||||||
|
|
||||||
let addr: SocketAddr = relay_addr.parse()?;
|
|
||||||
let seed = if seed_hex.is_empty() {
|
|
||||||
wzp_crypto::Seed::generate()
|
|
||||||
} else {
|
|
||||||
wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))?
|
|
||||||
};
|
|
||||||
let identity = seed.derive_identity();
|
|
||||||
let pub_id = identity.public_identity();
|
|
||||||
let identity_pub = *pub_id.signing.as_bytes();
|
|
||||||
let fp = pub_id.fingerprint.to_string();
|
|
||||||
let token = token.map(|s| s.to_string());
|
|
||||||
let alias = alias.map(|s| s.to_string());
|
|
||||||
let state = self.state.clone();
|
|
||||||
let seed_bytes = seed.0;
|
|
||||||
|
|
||||||
info!(fingerprint = %fp, relay = %addr, "starting signaling");
|
|
||||||
|
|
||||||
// Create runtime for signaling (separate from call runtime)
|
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
||||||
.worker_threads(1)
|
|
||||||
.enable_all()
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let signal_state = state.clone();
|
|
||||||
rt.spawn(async move {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
|
||||||
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => { error!("signal endpoint: {e}"); return; }
|
|
||||||
};
|
|
||||||
let client_cfg = wzp_transport::client_config();
|
|
||||||
let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => { error!("signal connect: {e}"); return; }
|
|
||||||
};
|
|
||||||
let transport = std::sync::Arc::new(wzp_transport::QuinnTransport::new(conn));
|
|
||||||
|
|
||||||
// Auth if token provided
|
|
||||||
if let Some(ref tok) = token {
|
|
||||||
let _ = transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register presence
|
|
||||||
let _ = transport.send_signal(&SignalMessage::RegisterPresence {
|
|
||||||
identity_pub,
|
|
||||||
signature: vec![],
|
|
||||||
alias: alias.clone(),
|
|
||||||
}).await;
|
|
||||||
|
|
||||||
// Wait for ack
|
|
||||||
match transport.recv_signal().await {
|
|
||||||
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
|
|
||||||
info!(fingerprint = %fp, "signal: registered");
|
|
||||||
let mut stats = signal_state.stats.lock().unwrap();
|
|
||||||
stats.state = crate::stats::CallState::Registered;
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
error!("signal registration failed: {other:?}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal recv loop
|
|
||||||
loop {
|
|
||||||
if !signal_state.running.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match transport.recv_signal().await {
|
|
||||||
Ok(Some(SignalMessage::CallRinging { call_id })) => {
|
|
||||||
info!(call_id = %call_id, "signal: ringing");
|
|
||||||
let mut stats = signal_state.stats.lock().unwrap();
|
|
||||||
stats.state = crate::stats::CallState::Ringing;
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
|
||||||
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
|
|
||||||
let mut stats = signal_state.stats.lock().unwrap();
|
|
||||||
stats.state = crate::stats::CallState::IncomingCall;
|
|
||||||
stats.incoming_call_id = Some(call_id);
|
|
||||||
stats.incoming_caller_fp = Some(caller_fingerprint);
|
|
||||||
stats.incoming_caller_alias = caller_alias;
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
|
|
||||||
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
|
|
||||||
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
|
|
||||||
// Connect to media room via the existing start_call mechanism
|
|
||||||
// Store the room info so Kotlin can call startCall with it
|
|
||||||
let mut stats = signal_state.stats.lock().unwrap();
|
|
||||||
stats.state = crate::stats::CallState::Connecting;
|
|
||||||
// Store call setup info for Kotlin to pick up
|
|
||||||
stats.incoming_call_id = Some(format!("{relay_addr}|{room}"));
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::Hangup { reason })) => {
|
|
||||||
info!(reason = ?reason, "signal: call ended by remote");
|
|
||||||
let mut stats = signal_state.stats.lock().unwrap();
|
|
||||||
stats.state = crate::stats::CallState::Closed;
|
|
||||||
stats.incoming_call_id = None;
|
|
||||||
stats.incoming_caller_fp = None;
|
|
||||||
stats.incoming_caller_alias = None;
|
|
||||||
}
|
|
||||||
Ok(Some(_)) => {}
|
|
||||||
Ok(None) => {
|
|
||||||
info!("signal: connection closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("signal recv error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stats = signal_state.stats.lock().unwrap();
|
|
||||||
stats.state = crate::stats::CallState::Closed;
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tokio_runtime = Some(rt);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Place a direct call to a target fingerprint via the signal connection.
|
|
||||||
pub fn place_call(&self, target_fingerprint: &str) -> Result<(), anyhow::Error> {
|
|
||||||
let _ = self.state.command_tx.send(EngineCommand::PlaceCall {
|
|
||||||
target_fingerprint: target_fingerprint.to_string(),
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Answer an incoming direct call.
|
|
||||||
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
|
|
||||||
let _ = self.state.command_tx.send(EngineCommand::AnswerCall {
|
|
||||||
call_id: call_id.to_string(),
|
|
||||||
accept_mode: mode,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_mute(&self, muted: bool) {
|
pub fn set_mute(&self, muted: bool) {
|
||||||
self.state.muted.store(muted, Ordering::Relaxed);
|
self.state.muted.store(muted, Ordering::Relaxed);
|
||||||
@@ -456,7 +308,6 @@ async fn run_call(
|
|||||||
alias: Option<&str>,
|
alias: Option<&str>,
|
||||||
state: Arc<EngineState>,
|
state: Arc<EngineState>,
|
||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
|
|||||||
) -> jlong {
|
) -> jlong {
|
||||||
let result = panic::catch_unwind(|| {
|
let result = panic::catch_unwind(|| {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
// Install rustls crypto provider ONCE on the main thread.
|
||||||
|
// Must not be called per-thread — conflicts with Android's system libcrypto.so TLS keys.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let handle = Box::new(EngineHandle {
|
let handle = Box::new(EngineHandle {
|
||||||
engine: WzpEngine::new(),
|
engine: WzpEngine::new(),
|
||||||
});
|
});
|
||||||
@@ -360,88 +363,149 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
|||||||
.unwrap_or(JObject::null().into_raw())
|
.unwrap_or(JObject::null().into_raw())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the identity fingerprint for a seed hex string.
|
||||||
|
/// Returns the full fingerprint (xxxx:xxxx:...) or empty string on error.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetFingerprint<'a>(
|
||||||
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
seed_hex_j: JString,
|
||||||
|
) -> jstring {
|
||||||
|
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
let fp = if seed_hex.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
match wzp_crypto::Seed::from_hex(&seed_hex) {
|
||||||
|
Ok(seed) => {
|
||||||
|
let id = seed.derive_identity();
|
||||||
|
id.public_identity().fingerprint.to_string()
|
||||||
|
}
|
||||||
|
Err(_) => String::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
env.new_string(&fp)
|
||||||
|
.map(|s| s.into_raw())
|
||||||
|
.unwrap_or(JObject::null().into_raw())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Direct calling JNI functions ──
|
// ── Direct calling JNI functions ──
|
||||||
|
|
||||||
/// Start persistent signaling connection to relay for direct calls.
|
// ── SignalManager JNI functions ──
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
|
/// Opaque handle for SignalManager (separate from EngineHandle).
|
||||||
|
struct SignalHandle {
|
||||||
|
mgr: crate::signal_mgr::SignalManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn signal_ref(handle: jlong) -> &'static SignalHandle {
|
||||||
|
unsafe { &*(handle as *const SignalHandle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to relay for signaling. Returns handle (jlong) or 0 on error.
|
||||||
|
/// Blocks up to 10s waiting for the internal signal thread to connect.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalConnect<'a>(
|
||||||
mut env: JNIEnv<'a>,
|
mut env: JNIEnv<'a>,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
handle: jlong,
|
relay_j: JString,
|
||||||
relay_addr_j: JString,
|
seed_j: JString,
|
||||||
seed_hex_j: JString,
|
) -> jlong {
|
||||||
token_j: JString,
|
info!("nativeSignalConnect: entered");
|
||||||
alias_j: JString,
|
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||||
) -> jint {
|
let seed: String = env.get_string(&seed_j).map(|s| s.into()).unwrap_or_default();
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
info!(relay = %relay, seed_len = seed.len(), "nativeSignalConnect: parsed strings");
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
|
||||||
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default();
|
|
||||||
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default();
|
|
||||||
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default();
|
|
||||||
|
|
||||||
h.engine.start_signaling(
|
// start() spawns an internal thread (connect+register+recv, ONE runtime, never dropped).
|
||||||
&relay_addr,
|
// Blocks up to 10s waiting for the connect+register to complete.
|
||||||
&seed_hex,
|
match crate::signal_mgr::SignalManager::start(&relay, &seed) {
|
||||||
if token.is_empty() { None } else { Some(&token) },
|
Ok(mgr) => {
|
||||||
if alias.is_empty() { None } else { Some(&alias) },
|
let handle = Box::new(SignalHandle { mgr });
|
||||||
)
|
Box::into_raw(handle) as jlong
|
||||||
}));
|
}
|
||||||
|
Err(e) => {
|
||||||
match result {
|
error!("signal connect failed: {e}");
|
||||||
Ok(Ok(())) => 0,
|
0
|
||||||
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 }
|
}
|
||||||
Err(_) => { error!("start_signaling panicked"); -1 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Place a direct call to a target fingerprint.
|
/// Get signal state as JSON string.
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalGetState<'a>(
|
||||||
mut env: JNIEnv<'a>,
|
mut env: JNIEnv<'a>,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
target_fp_j: JString,
|
) -> jstring {
|
||||||
) -> jint {
|
if handle == 0 { return JObject::null().into_raw(); }
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
let h = signal_ref(handle);
|
||||||
let h = unsafe { handle_ref(handle) };
|
let json = h.mgr.get_state_json();
|
||||||
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default();
|
env.new_string(&json)
|
||||||
h.engine.place_call(&target)
|
.map(|s| s.into_raw())
|
||||||
}));
|
.unwrap_or(JObject::null().into_raw())
|
||||||
|
}
|
||||||
|
|
||||||
match result {
|
/// Place a direct call.
|
||||||
Ok(Ok(())) => 0,
|
#[unsafe(no_mangle)]
|
||||||
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 }
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalPlaceCall<'a>(
|
||||||
Err(_) => { error!("place_call panicked"); -1 }
|
mut env: JNIEnv<'a>,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
target_j: JString,
|
||||||
|
) -> jint {
|
||||||
|
if handle == 0 { return -1; }
|
||||||
|
let h = signal_ref(handle);
|
||||||
|
let target: String = env.get_string(&target_j).map(|s| s.into()).unwrap_or_default();
|
||||||
|
match h.mgr.place_call(&target) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(e) => { error!("place_call: {e}"); -1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Answer an incoming direct call.
|
/// Answer an incoming call.
|
||||||
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalAnswerCall<'a>(
|
||||||
mut env: JNIEnv<'a>,
|
mut env: JNIEnv<'a>,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
handle: jlong,
|
handle: jlong,
|
||||||
call_id_j: JString,
|
call_id_j: JString,
|
||||||
mode: jint,
|
mode: jint,
|
||||||
) -> jint {
|
) -> jint {
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
if handle == 0 { return -1; }
|
||||||
let h = unsafe { handle_ref(handle) };
|
let h = signal_ref(handle);
|
||||||
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
|
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
|
||||||
let accept_mode = match mode {
|
let accept_mode = match mode {
|
||||||
0 => wzp_proto::CallAcceptMode::Reject,
|
0 => wzp_proto::CallAcceptMode::Reject,
|
||||||
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
||||||
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
||||||
};
|
};
|
||||||
h.engine.answer_call(&call_id, accept_mode)
|
match h.mgr.answer_call(&call_id, accept_mode) {
|
||||||
}));
|
Ok(()) => 0,
|
||||||
|
Err(e) => { error!("answer_call: {e}"); -1 }
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("answer_call panicked"); -1 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send hangup signal.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalHangup(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
if handle == 0 { return; }
|
||||||
|
let h = signal_ref(handle);
|
||||||
|
h.mgr.hangup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy the signal manager and free resources.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "system" fn Java_com_wzp_engine_SignalManager_nativeSignalDestroy(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
handle: jlong,
|
||||||
|
) {
|
||||||
|
if handle == 0 { return; }
|
||||||
|
let h = signal_ref(handle);
|
||||||
|
h.mgr.stop();
|
||||||
|
// Reclaim the Box
|
||||||
|
let _ = unsafe { Box::from_raw(handle as *mut SignalHandle) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ pub mod audio_ring;
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod signal_mgr;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod jni_bridge;
|
pub mod jni_bridge;
|
||||||
|
|||||||
288
crates/wzp-android/src/signal_mgr.rs
Normal file
288
crates/wzp-android/src/signal_mgr.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
//! Persistent signal connection manager for direct 1:1 calls.
|
||||||
|
//!
|
||||||
|
//! Separate from the media engine — survives across calls.
|
||||||
|
//! Connects to relay via `_signal` SNI, registers presence,
|
||||||
|
//! and handles call signaling (offer/answer/setup/hangup).
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use wzp_proto::{MediaTransport, SignalMessage};
|
||||||
|
|
||||||
|
/// Signal connection status.
|
||||||
|
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||||
|
pub struct SignalState {
|
||||||
|
pub status: String, // "idle", "registered", "ringing", "incoming", "setup"
|
||||||
|
pub fingerprint: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_call_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_caller_fp: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incoming_caller_alias: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub call_setup_relay: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub call_setup_room: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub call_setup_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages a persistent `_signal` QUIC connection to a relay.
|
||||||
|
pub struct SignalManager {
|
||||||
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
|
state: Arc<Mutex<SignalState>>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalManager {
|
||||||
|
/// Create SignalManager and start connect+register+recv on a background thread.
|
||||||
|
/// Returns immediately. The internal thread runs forever.
|
||||||
|
/// CRITICAL: tokio runtime must never be dropped on Android (libcrypto TLS conflict).
|
||||||
|
pub fn start(relay_addr: &str, seed_hex: &str) -> Result<Self, anyhow::Error> {
|
||||||
|
let addr: SocketAddr = relay_addr.parse()?;
|
||||||
|
let seed = if seed_hex.is_empty() {
|
||||||
|
wzp_crypto::Seed::generate()
|
||||||
|
} else {
|
||||||
|
wzp_crypto::Seed::from_hex(seed_hex).map_err(|e| anyhow::anyhow!(e))?
|
||||||
|
};
|
||||||
|
let identity = seed.derive_identity();
|
||||||
|
let pub_id = identity.public_identity();
|
||||||
|
let identity_pub = *pub_id.signing.as_bytes();
|
||||||
|
let fp = pub_id.fingerprint.to_string();
|
||||||
|
|
||||||
|
let state = Arc::new(Mutex::new(SignalState {
|
||||||
|
status: "connecting".into(),
|
||||||
|
fingerprint: fp.clone(),
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
// Channel to receive transport after connect succeeds
|
||||||
|
let (transport_tx, transport_rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
let bg_state = Arc::clone(&state);
|
||||||
|
let bg_running = Arc::clone(&running);
|
||||||
|
let ret_state = Arc::clone(&state);
|
||||||
|
let ret_running = Arc::clone(&running);
|
||||||
|
|
||||||
|
// ONE thread, ONE runtime, NEVER dropped.
|
||||||
|
// Connect + register + recv loop all happen here.
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-signal".into())
|
||||||
|
.stack_size(4 * 1024 * 1024)
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio runtime");
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
info!(fingerprint = %fp, relay = %addr, "signal: connecting");
|
||||||
|
|
||||||
|
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
error!("signal endpoint: {e}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
let conn = match wzp_transport::connect(&endpoint, addr, "_signal", client_cfg).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!("signal connect: {e}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||||
|
|
||||||
|
// Register
|
||||||
|
if let Err(e) = transport.send_signal(&SignalMessage::RegisterPresence {
|
||||||
|
identity_pub, signature: vec![], alias: None,
|
||||||
|
}).await {
|
||||||
|
error!("signal register: {e}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(SignalMessage::RegisterPresenceAck { success: true, .. })) => {
|
||||||
|
info!(fingerprint = %fp, "signal: registered");
|
||||||
|
bg_state.lock().unwrap().status = "registered".into();
|
||||||
|
// Send transport to caller
|
||||||
|
let _ = transport_tx.send(transport.clone());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
error!("signal registration failed: {other:?}");
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recv loop — runs forever
|
||||||
|
loop {
|
||||||
|
if !running.load(Ordering::Relaxed) { break; }
|
||||||
|
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(SignalMessage::CallRinging { call_id })) => {
|
||||||
|
info!(call_id = %call_id, "signal: ringing");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "ringing".into();
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
||||||
|
info!(from = %caller_fingerprint, call_id = %call_id, "signal: incoming call");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "incoming".into();
|
||||||
|
s.incoming_call_id = Some(call_id);
|
||||||
|
s.incoming_caller_fp = Some(caller_fingerprint);
|
||||||
|
s.incoming_caller_alias = caller_alias;
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
|
||||||
|
info!(call_id = %call_id, mode = ?accept_mode, "signal: call answered");
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
|
||||||
|
info!(call_id = %call_id, room = %room, relay = %relay_addr, "signal: call setup");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "setup".into();
|
||||||
|
s.call_setup_relay = Some(relay_addr);
|
||||||
|
s.call_setup_room = Some(room);
|
||||||
|
s.call_setup_id = Some(call_id);
|
||||||
|
}
|
||||||
|
Ok(Some(SignalMessage::Hangup { reason })) => {
|
||||||
|
info!(reason = ?reason, "signal: hangup");
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "registered".into();
|
||||||
|
s.incoming_call_id = None;
|
||||||
|
s.incoming_caller_fp = None;
|
||||||
|
s.incoming_caller_alias = None;
|
||||||
|
s.call_setup_relay = None;
|
||||||
|
s.call_setup_room = None;
|
||||||
|
s.call_setup_id = None;
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => {
|
||||||
|
info!("signal: connection closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("signal recv error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bg_state.lock().unwrap().status = "idle".into();
|
||||||
|
}); // block_on
|
||||||
|
|
||||||
|
// Runtime intentionally NOT dropped — lives until thread exits.
|
||||||
|
// This prevents ring/libcrypto TLS cleanup conflict on Android.
|
||||||
|
// The thread is parked here forever (block_on returned = connection lost).
|
||||||
|
std::thread::park();
|
||||||
|
})?; // thread spawn
|
||||||
|
|
||||||
|
// Wait for transport (up to 10s)
|
||||||
|
let transport = transport_rx.recv_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.map_err(|_| anyhow::anyhow!("signal connect timeout — check relay address"))?;
|
||||||
|
|
||||||
|
Ok(Self { transport, state: ret_state, running: ret_running })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current state (non-blocking).
|
||||||
|
pub fn get_state(&self) -> SignalState {
|
||||||
|
self.state.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get state as JSON string.
|
||||||
|
pub fn get_state_json(&self) -> String {
|
||||||
|
serde_json::to_string(&self.get_state()).unwrap_or_else(|_| "{}".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place a direct call.
|
||||||
|
pub fn place_call(&self, target_fp: &str) -> Result<(), anyhow::Error> {
|
||||||
|
let fp = self.state.lock().unwrap().fingerprint.clone();
|
||||||
|
let target = target_fp.to_string();
|
||||||
|
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
||||||
|
let transport = self.transport.clone();
|
||||||
|
|
||||||
|
// Send on a small thread (async send needs a runtime)
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-call-send".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all().build().expect("rt");
|
||||||
|
rt.block_on(async {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::DirectCallOffer {
|
||||||
|
caller_fingerprint: fp,
|
||||||
|
caller_alias: None,
|
||||||
|
target_fingerprint: target,
|
||||||
|
call_id,
|
||||||
|
identity_pub: [0u8; 32],
|
||||||
|
ephemeral_pub: [0u8; 32],
|
||||||
|
signature: vec![],
|
||||||
|
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
||||||
|
}).await;
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer an incoming call.
|
||||||
|
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> {
|
||||||
|
let call_id = call_id.to_string();
|
||||||
|
let transport = self.transport.clone();
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-answer-send".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all().build().expect("rt");
|
||||||
|
rt.block_on(async {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::DirectCallAnswer {
|
||||||
|
call_id,
|
||||||
|
accept_mode: mode,
|
||||||
|
identity_pub: None,
|
||||||
|
ephemeral_pub: None,
|
||||||
|
signature: None,
|
||||||
|
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
||||||
|
}).await;
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send hangup.
|
||||||
|
pub fn hangup(&self) {
|
||||||
|
let transport = self.transport.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all().build().expect("rt");
|
||||||
|
rt.block_on(async {
|
||||||
|
let _ = transport.send_signal(&SignalMessage::Hangup {
|
||||||
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
|
}).await;
|
||||||
|
});
|
||||||
|
let mut s = state.lock().unwrap();
|
||||||
|
s.status = "registered".into();
|
||||||
|
s.incoming_call_id = None;
|
||||||
|
s.incoming_caller_fp = None;
|
||||||
|
s.incoming_caller_alias = None;
|
||||||
|
s.call_setup_relay = None;
|
||||||
|
s.call_setup_room = None;
|
||||||
|
s.call_setup_id = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the signal connection.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.running.store(false, Ordering::Release);
|
||||||
|
self.transport.connection().close(0u32.into(), b"shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,13 +23,10 @@ serde_json = "1"
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
cpal = { version = "0.15", optional = true }
|
cpal = { version = "0.15", optional = true }
|
||||||
coreaudio-rs = { version = "0.11", optional = true }
|
|
||||||
libc = "0.2"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
audio = ["cpal"]
|
audio = ["cpal"]
|
||||||
vpio = ["coreaudio-rs"]
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-client"
|
name = "wzp-client"
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
//! Both structs use 48 kHz, mono, i16 format to match the WarzonePhone codec
|
||||||
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
//! pipeline. Frames are 960 samples (20 ms at 48 kHz).
|
||||||
//!
|
//!
|
||||||
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
|
//! The cpal `Stream` type is not `Send`, so each struct spawns a dedicated OS
|
||||||
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
|
//! thread that owns the stream. The public API exposes only `Send + Sync`
|
||||||
|
//! channel handles.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
@@ -14,8 +16,6 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// Number of samples per 20 ms frame at 48 kHz mono.
|
/// Number of samples per 20 ms frame at 48 kHz mono.
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
pub const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
@@ -23,25 +23,23 @@ pub const FRAME_SAMPLES: usize = 960;
|
|||||||
// AudioCapture
|
// 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`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioCapture {
|
pub struct AudioCapture {
|
||||||
ring: Arc<AudioRing>,
|
rx: mpsc::Receiver<Vec<i16>>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioCapture {
|
impl AudioCapture {
|
||||||
/// Create and start capturing from the default input device at 48 kHz mono.
|
/// Create and start capturing from the default input device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let ring = Arc::new(AudioRing::new());
|
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
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 running_clone = running.clone();
|
||||||
|
|
||||||
|
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-capture".into())
|
.name("wzp-audio-capture".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -61,51 +59,53 @@ impl AudioCapture {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_input(&device)?;
|
let use_f32 = !supports_i16_input(&device)?;
|
||||||
|
|
||||||
|
let buf = Arc::new(std::sync::Mutex::new(
|
||||||
|
Vec::<i16>::with_capacity(FRAME_SAMPLES),
|
||||||
|
));
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("input stream error: {e}");
|
warn!("input stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let logged_cb_size = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let ring = ring_cb.clone();
|
let buf = buf.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
let logged = logged_cb_size.clone();
|
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
let mut lock = buf.lock().unwrap();
|
||||||
eprintln!("[audio] capture callback: {} f32 samples", data.len());
|
for &s in data {
|
||||||
}
|
lock.push(f32_to_i16(s));
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
if lock.len() == FRAME_SAMPLES {
|
||||||
for chunk in data.chunks(FRAME_SAMPLES) {
|
let frame = lock.drain(..).collect();
|
||||||
let n = chunk.len();
|
let _ = tx.try_send(frame);
|
||||||
for i in 0..n {
|
|
||||||
tmp[i] = f32_to_i16(chunk[i]);
|
|
||||||
}
|
}
|
||||||
ring.write(&tmp[..n]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let ring = ring_cb.clone();
|
let buf = buf.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
let running = running_clone.clone();
|
let running = running_clone.clone();
|
||||||
let logged = logged_cb_size.clone();
|
|
||||||
device.build_input_stream(
|
device.build_input_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
||||||
if !running.load(Ordering::Relaxed) {
|
if !running.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !logged.swap(true, Ordering::Relaxed) {
|
let mut lock = buf.lock().unwrap();
|
||||||
eprintln!("[audio] capture callback: {} i16 samples", data.len());
|
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,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
@@ -114,6 +114,7 @@ impl AudioCapture {
|
|||||||
|
|
||||||
stream.play().context("failed to start input stream")?;
|
stream.play().context("failed to start input stream")?;
|
||||||
|
|
||||||
|
// Signal success to the caller before parking.
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -134,12 +135,15 @@ impl AudioCapture {
|
|||||||
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
.map_err(|_| anyhow!("capture thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
Ok(Self { rx, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the capture ring buffer for direct polling.
|
/// Read the next frame of 960 PCM samples (blocking until available).
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
///
|
||||||
&self.ring
|
/// 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.
|
/// Stop capturing.
|
||||||
@@ -148,35 +152,27 @@ impl AudioCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for AudioCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AudioPlayback
|
// 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`.
|
/// The cpal stream lives on a dedicated OS thread; this handle is `Send + Sync`.
|
||||||
pub struct AudioPlayback {
|
pub struct AudioPlayback {
|
||||||
ring: Arc<AudioRing>,
|
tx: mpsc::SyncSender<Vec<i16>>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayback {
|
impl AudioPlayback {
|
||||||
/// Create and start playback on the default output device at 48 kHz mono.
|
/// Create and start playback on the default output device at 48 kHz mono.
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
pub fn start() -> Result<Self, anyhow::Error> {
|
||||||
let ring = Arc::new(AudioRing::new());
|
let (tx, rx) = mpsc::sync_channel::<Vec<i16>>(64);
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
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 running_clone = running.clone();
|
||||||
|
|
||||||
|
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("wzp-audio-playback".into())
|
.name("wzp-audio-playback".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -196,40 +192,62 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
let use_f32 = !supports_i16_output(&device)?;
|
let use_f32 = !supports_i16_output(&device)?;
|
||||||
|
|
||||||
|
// Shared ring of samples the cpal callback drains from.
|
||||||
|
let ring = Arc::new(std::sync::Mutex::new(
|
||||||
|
std::collections::VecDeque::<i16>::with_capacity(FRAME_SAMPLES * 8),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Background drainer: moves frames from the mpsc channel into the ring.
|
||||||
|
{
|
||||||
|
let ring = ring.clone();
|
||||||
|
let running = running_clone.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("wzp-playback-drain".into())
|
||||||
|
.spawn(move || {
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||||
|
Ok(frame) => {
|
||||||
|
let mut lock = ring.lock().unwrap();
|
||||||
|
lock.extend(frame);
|
||||||
|
while lock.len() > FRAME_SAMPLES * 16 {
|
||||||
|
lock.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
let err_cb = |e: cpal::StreamError| {
|
||||||
warn!("output stream error: {e}");
|
warn!("output stream error: {e}");
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = if use_f32 {
|
let stream = if use_f32 {
|
||||||
let ring = ring_cb.clone();
|
let ring = ring.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
let mut lock = ring.lock().unwrap();
|
||||||
for chunk in data.chunks_mut(FRAME_SAMPLES) {
|
for sample in data.iter_mut() {
|
||||||
let n = chunk.len();
|
*sample = match lock.pop_front() {
|
||||||
let read = ring.read(&mut tmp[..n]);
|
Some(s) => i16_to_f32(s),
|
||||||
for i in 0..read {
|
None => 0.0,
|
||||||
chunk[i] = i16_to_f32(tmp[i]);
|
};
|
||||||
}
|
|
||||||
// Fill remainder with silence if ring underran
|
|
||||||
for i in read..n {
|
|
||||||
chunk[i] = 0.0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
None,
|
None,
|
||||||
)?
|
)?
|
||||||
} else {
|
} else {
|
||||||
let ring = ring_cb.clone();
|
let ring = ring.clone();
|
||||||
device.build_output_stream(
|
device.build_output_stream(
|
||||||
&config,
|
&config,
|
||||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||||
let read = ring.read(data);
|
let mut lock = ring.lock().unwrap();
|
||||||
// Fill remainder with silence if ring underran
|
for sample in data.iter_mut() {
|
||||||
for sample in &mut data[read..] {
|
*sample = lock.pop_front().unwrap_or(0);
|
||||||
*sample = 0;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
err_cb,
|
||||||
@@ -239,6 +257,7 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
stream.play().context("failed to start output stream")?;
|
stream.play().context("failed to start output stream")?;
|
||||||
|
|
||||||
|
// Signal success to the caller before parking.
|
||||||
let _ = init_tx.send(Ok(()));
|
let _ = init_tx.send(Ok(()));
|
||||||
|
|
||||||
// Keep stream alive until stopped.
|
// Keep stream alive until stopped.
|
||||||
@@ -259,12 +278,12 @@ impl AudioPlayback {
|
|||||||
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
.map_err(|_| anyhow!("playback thread exited before signaling"))?
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
Ok(Self { tx, running })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the playout ring buffer for direct writing.
|
/// Write a frame of PCM samples for playback.
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
pub fn write_frame(&self, pcm: &[i16]) {
|
||||||
&self.ring
|
let _ = self.tx.try_send(pcm.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop playback.
|
/// Stop playback.
|
||||||
@@ -273,16 +292,11 @@ impl AudioPlayback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for AudioPlayback {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Check if the input device supports i16 at 48 kHz mono.
|
||||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_input_configs()
|
.supported_input_configs()
|
||||||
@@ -299,6 +313,7 @@ fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the output device supports i16 at 48 kHz mono.
|
||||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
||||||
let supported = device
|
let supported = device
|
||||||
.supported_output_configs()
|
.supported_output_configs()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,9 +42,6 @@ pub struct CallConfig {
|
|||||||
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
/// When enabled, only every 50th frame carries a full 12-byte MediaHeader;
|
||||||
/// intermediate frames use a compact 4-byte MiniHeader.
|
/// intermediate frames use a compact 4-byte MiniHeader.
|
||||||
pub mini_frames_enabled: bool,
|
pub mini_frames_enabled: bool,
|
||||||
/// AEC far-end delay compensation in milliseconds (default: 40).
|
|
||||||
/// Compensates for the round-trip audio latency from playout to mic capture.
|
|
||||||
pub aec_delay_ms: u32,
|
|
||||||
/// Enable adaptive jitter buffer (default: true).
|
/// Enable adaptive jitter buffer (default: true).
|
||||||
///
|
///
|
||||||
/// When true, the jitter buffer target depth is automatically adjusted
|
/// When true, the jitter buffer target depth is automatically adjusted
|
||||||
@@ -66,7 +63,6 @@ impl Default for CallConfig {
|
|||||||
noise_suppression: true,
|
noise_suppression: true,
|
||||||
mini_frames_enabled: true,
|
mini_frames_enabled: true,
|
||||||
adaptive_jitter: true,
|
adaptive_jitter: true,
|
||||||
aec_delay_ms: 40,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +241,7 @@ impl CallEncoder {
|
|||||||
block_id: 0,
|
block_id: 0,
|
||||||
frame_in_block: 0,
|
frame_in_block: 0,
|
||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
aec: EchoCanceller::with_delay(48000, 60, config.aec_delay_ms),
|
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
|
||||||
agc: AutoGainControl::new(),
|
agc: AutoGainControl::new(),
|
||||||
silence_detector: SilenceDetector::new(
|
silence_detector: SilenceDetector::new(
|
||||||
config.silence_threshold_rms,
|
config.silence_threshold_rms,
|
||||||
@@ -500,52 +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::Opus32k => QualityProfile::STUDIO_32K,
|
|
||||||
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
|
||||||
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
|
||||||
CodecId::Codec2_3200 => QualityProfile {
|
|
||||||
codec: CodecId::Codec2_3200,
|
|
||||||
fec_ratio: 0.5,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
},
|
|
||||||
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
|
||||||
CodecId::ComfortNoise => QualityProfile::GOOD,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the next audio frame from the jitter buffer.
|
/// Decode the next audio frame from the jitter buffer.
|
||||||
///
|
///
|
||||||
/// Returns PCM samples (48kHz mono) or None if not ready.
|
/// Returns PCM samples (48kHz mono) or None if not ready.
|
||||||
@@ -560,9 +510,6 @@ impl CallDecoder {
|
|||||||
return Some(pcm.len());
|
return Some(pcm.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-switch decoder if incoming codec differs from current.
|
|
||||||
self.switch_decoder_if_needed(pkt.header.codec_id);
|
|
||||||
|
|
||||||
self.last_was_cn = false;
|
self.last_was_cn = false;
|
||||||
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
let result = match self.audio_dec.decode(&pkt.payload, pcm) {
|
||||||
Ok(n) => Some(n),
|
Ok(n) => Some(n),
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
pub mod audio_io;
|
pub mod audio_io;
|
||||||
#[cfg(feature = "audio")]
|
|
||||||
pub mod audio_ring;
|
|
||||||
#[cfg(feature = "vpio")]
|
|
||||||
pub mod audio_vpio;
|
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
pub mod call;
|
pub mod call;
|
||||||
pub mod drift_test;
|
pub mod drift_test;
|
||||||
|
|||||||
@@ -1,127 +1,53 @@
|
|||||||
//! Acoustic Echo Cancellation — delay-compensated leaky NLMS with
|
//! Acoustic Echo Cancellation using NLMS adaptive filter.
|
||||||
//! Geigel double-talk detection.
|
//! Processes 480-sample (10ms) sub-frames at 48kHz.
|
||||||
//!
|
|
||||||
//! Key insight: on a laptop, the round-trip audio latency (playout → speaker
|
|
||||||
//! → air → mic → capture) is 30–50ms. The far-end reference must be delayed
|
|
||||||
//! by this amount so the adaptive filter models the *echo path*, not the
|
|
||||||
//! *system delay + echo path*.
|
|
||||||
//!
|
|
||||||
//! The leaky coefficient decay prevents the filter from diverging when the
|
|
||||||
//! echo path changes (e.g. hand near laptop) or when the delay estimate
|
|
||||||
//! is slightly off.
|
|
||||||
|
|
||||||
/// 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 {
|
pub struct EchoCanceller {
|
||||||
// --- Adaptive filter ---
|
filter_coeffs: Vec<f32>,
|
||||||
filter: Vec<f32>,
|
|
||||||
filter_len: usize,
|
filter_len: usize,
|
||||||
/// Circular buffer of far-end reference samples (after delay).
|
far_end_buf: Vec<f32>,
|
||||||
far_buf: Vec<f32>,
|
far_end_pos: usize,
|
||||||
far_pos: usize,
|
|
||||||
/// NLMS step size.
|
|
||||||
mu: f32,
|
mu: f32,
|
||||||
/// Leakage factor: coefficients are multiplied by (1 - leak) each frame.
|
|
||||||
/// Prevents unbounded growth / divergence. 0.0001 is gentle.
|
|
||||||
leak: f32,
|
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
|
||||||
// --- Delay buffer ---
|
|
||||||
/// Raw far-end samples before delay compensation.
|
|
||||||
delay_ring: Vec<f32>,
|
|
||||||
delay_write: usize,
|
|
||||||
delay_read: usize,
|
|
||||||
/// Delay in samples (e.g. 1920 = 40ms at 48kHz).
|
|
||||||
delay_samples: usize,
|
|
||||||
/// Capacity of the delay ring.
|
|
||||||
delay_cap: usize,
|
|
||||||
|
|
||||||
// --- Double-talk detection (Geigel) ---
|
|
||||||
/// Peak far-end level over the last filter_len samples.
|
|
||||||
far_peak: f32,
|
|
||||||
/// Geigel threshold: if |near| > threshold * far_peak, assume double-talk.
|
|
||||||
geigel_threshold: f32,
|
|
||||||
/// Holdover counter: keep DTD active for a few frames after detection.
|
|
||||||
dtd_holdover: u32,
|
|
||||||
dtd_hold_frames: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EchoCanceller {
|
impl EchoCanceller {
|
||||||
/// Create a new echo canceller.
|
/// Create a new echo canceller.
|
||||||
///
|
///
|
||||||
/// * `sample_rate` — typically 48000
|
/// * `sample_rate` — typically 48000
|
||||||
/// * `filter_ms` — echo-tail length in milliseconds (60ms recommended)
|
/// * `filter_ms` — echo-tail length in milliseconds (e.g. 100 for 100 ms)
|
||||||
/// * `delay_ms` — far-end delay compensation in milliseconds (40ms for laptops)
|
|
||||||
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
pub fn new(sample_rate: u32, filter_ms: u32) -> Self {
|
||||||
Self::with_delay(sample_rate, filter_ms, 40)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_delay(sample_rate: u32, filter_ms: u32, delay_ms: u32) -> Self {
|
|
||||||
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
let filter_len = (sample_rate as usize) * (filter_ms as usize) / 1000;
|
||||||
let delay_samples = (sample_rate as usize) * (delay_ms as usize) / 1000;
|
|
||||||
// Delay ring must hold at least delay_samples + one frame (960) of headroom.
|
|
||||||
let delay_cap = delay_samples + (sample_rate as usize / 10); // +100ms headroom
|
|
||||||
Self {
|
Self {
|
||||||
filter: vec![0.0; filter_len],
|
filter_coeffs: vec![0.0f32; filter_len],
|
||||||
filter_len,
|
filter_len,
|
||||||
far_buf: vec![0.0; filter_len],
|
far_end_buf: vec![0.0f32; filter_len],
|
||||||
far_pos: 0,
|
far_end_pos: 0,
|
||||||
mu: 0.01,
|
mu: 0.01,
|
||||||
leak: 0.0001,
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
||||||
delay_ring: vec![0.0; delay_cap],
|
|
||||||
delay_write: 0,
|
|
||||||
delay_read: 0,
|
|
||||||
delay_samples,
|
|
||||||
delay_cap,
|
|
||||||
|
|
||||||
far_peak: 0.0,
|
|
||||||
geigel_threshold: 0.7,
|
|
||||||
dtd_holdover: 0,
|
|
||||||
dtd_hold_frames: 5,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed far-end (speaker) samples. These go into the delay buffer first;
|
/// Feed far-end (speaker/playback) samples into the circular buffer.
|
||||||
/// once enough samples have accumulated, they are released to the filter's
|
///
|
||||||
/// circular buffer with the correct delay offset.
|
/// 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]) {
|
pub fn feed_farend(&mut self, farend: &[i16]) {
|
||||||
// Write raw samples into the delay ring.
|
|
||||||
for &s in farend {
|
for &s in farend {
|
||||||
self.delay_ring[self.delay_write % self.delay_cap] = s as f32;
|
self.far_end_buf[self.far_end_pos] = s as f32;
|
||||||
self.delay_write += 1;
|
self.far_end_pos = (self.far_end_pos + 1) % self.filter_len;
|
||||||
}
|
|
||||||
|
|
||||||
// Release delayed samples to the filter's far-end buffer.
|
|
||||||
while self.delay_available() >= 1 {
|
|
||||||
let sample = self.delay_ring[self.delay_read % self.delay_cap];
|
|
||||||
self.delay_read += 1;
|
|
||||||
|
|
||||||
self.far_buf[self.far_pos] = sample;
|
|
||||||
self.far_pos = (self.far_pos + 1) % self.filter_len;
|
|
||||||
|
|
||||||
// Track peak far-end level for Geigel DTD.
|
|
||||||
let abs_s = sample.abs();
|
|
||||||
if abs_s > self.far_peak {
|
|
||||||
self.far_peak = abs_s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decay far_peak slowly (avoids stale peak from a loud burst long ago).
|
|
||||||
self.far_peak *= 0.9995;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of delayed samples available to release.
|
|
||||||
fn delay_available(&self) -> usize {
|
|
||||||
let buffered = self.delay_write - self.delay_read;
|
|
||||||
if buffered > self.delay_samples {
|
|
||||||
buffered - self.delay_samples
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a near-end (microphone) frame, removing the estimated echo.
|
/// Process a near-end (microphone) frame, removing the estimated echo.
|
||||||
|
///
|
||||||
|
/// Returns the echo-return-loss enhancement (ERLE) as a ratio: the RMS of
|
||||||
|
/// the original near-end divided by the RMS of the residual. Values > 1.0
|
||||||
|
/// mean echo was reduced.
|
||||||
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
pub fn process_frame(&mut self, nearend: &mut [i16]) -> f32 {
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
@@ -130,96 +56,85 @@ impl EchoCanceller {
|
|||||||
let n = nearend.len();
|
let n = nearend.len();
|
||||||
let fl = self.filter_len;
|
let fl = self.filter_len;
|
||||||
|
|
||||||
// --- Geigel double-talk detection ---
|
|
||||||
// If any near-end sample exceeds threshold * far_peak, assume
|
|
||||||
// the local speaker is active and freeze adaptation.
|
|
||||||
let mut is_doubletalk = self.dtd_holdover > 0;
|
|
||||||
if !is_doubletalk {
|
|
||||||
let threshold_level = self.geigel_threshold * self.far_peak;
|
|
||||||
for &s in nearend.iter() {
|
|
||||||
if (s as f32).abs() > threshold_level && self.far_peak > 100.0 {
|
|
||||||
is_doubletalk = true;
|
|
||||||
self.dtd_holdover = self.dtd_hold_frames;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.dtd_holdover > 0 {
|
|
||||||
self.dtd_holdover -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if far-end is active (otherwise nothing to cancel).
|
|
||||||
let far_active = self.far_peak > 100.0;
|
|
||||||
|
|
||||||
// --- Leaky coefficient decay ---
|
|
||||||
// Applied once per frame for efficiency.
|
|
||||||
let decay = 1.0 - self.leak;
|
|
||||||
for c in self.filter.iter_mut() {
|
|
||||||
*c *= decay;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sum_near_sq: f64 = 0.0;
|
let mut sum_near_sq: f64 = 0.0;
|
||||||
let mut sum_err_sq: f64 = 0.0;
|
let mut sum_err_sq: f64 = 0.0;
|
||||||
|
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let near_f = nearend[i] as f32;
|
let near_f = nearend[i] as f32;
|
||||||
|
|
||||||
// Position of far-end "now" for this near-end sample.
|
// --- estimate echo as dot(coeffs, farend_window) ---
|
||||||
let base = (self.far_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
// The far-end window for this sample starts at
|
||||||
|
// (far_end_pos - 1 - i) mod filter_len (most recent)
|
||||||
// --- Echo estimation: dot(filter, far_end_window) ---
|
// and goes back filter_len samples.
|
||||||
let mut echo_est: f32 = 0.0;
|
let mut echo_est: f32 = 0.0;
|
||||||
let mut power: f32 = 0.0;
|
let mut power: f32 = 0.0;
|
||||||
|
|
||||||
|
// Position of the most-recent far-end sample for this near-end sample.
|
||||||
|
// far_end_pos points to the *next write* position, so the most-recent
|
||||||
|
// sample written is at far_end_pos - 1. We have already called
|
||||||
|
// feed_farend for this block, so the relevant samples are the last
|
||||||
|
// filter_len entries ending just before the current write position,
|
||||||
|
// offset by how far we are into this near-end frame.
|
||||||
|
//
|
||||||
|
// For sample i of the near-end frame, the corresponding far-end
|
||||||
|
// "now" is far_end_pos - n + i (wrapping).
|
||||||
|
// far_end_pos points to next-write, so most recent sample is at
|
||||||
|
// far_end_pos - 1. For the i-th near-end sample we want the
|
||||||
|
// far-end "now" to be at (far_end_pos - n + i). We add fl
|
||||||
|
// repeatedly to avoid underflow on the usize subtraction.
|
||||||
|
let base = (self.far_end_pos + fl * ((n / fl) + 2) + i - n) % fl;
|
||||||
|
|
||||||
for k in 0..fl {
|
for k in 0..fl {
|
||||||
let fe_idx = (base + fl - k) % fl;
|
let fe_idx = (base + fl - k) % fl;
|
||||||
let fe = self.far_buf[fe_idx];
|
let fe = self.far_end_buf[fe_idx];
|
||||||
echo_est += self.filter[k] * fe;
|
echo_est += self.filter_coeffs[k] * fe;
|
||||||
power += fe * fe;
|
power += fe * fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
let error = near_f - echo_est;
|
let error = near_f - echo_est;
|
||||||
|
|
||||||
// --- NLMS adaptation (only when far-end active & no double-talk) ---
|
// --- NLMS coefficient update ---
|
||||||
if far_active && !is_doubletalk && power > 10.0 {
|
let norm = power + 1.0; // +1 regularisation to avoid div-by-zero
|
||||||
let step = self.mu * error / (power + 1.0);
|
let step = self.mu * error / norm;
|
||||||
for k in 0..fl {
|
|
||||||
let fe_idx = (base + fl - k) % fl;
|
for k in 0..fl {
|
||||||
self.filter[k] += step * self.far_buf[fe_idx];
|
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;
|
nearend[i] = out as i16;
|
||||||
|
|
||||||
sum_near_sq += (near_f as f64).powi(2);
|
sum_near_sq += (near_f as f64) * (near_f as f64);
|
||||||
sum_err_sq += (out as f64).powi(2);
|
sum_err_sq += (out as f64) * (out as f64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERLE ratio
|
||||||
if sum_err_sq < 1.0 {
|
if sum_err_sq < 1.0 {
|
||||||
100.0
|
return 100.0; // near-perfect cancellation
|
||||||
} else {
|
|
||||||
(sum_near_sq / sum_err_sq).sqrt() as f32
|
|
||||||
}
|
}
|
||||||
|
(sum_near_sq / sum_err_sq).sqrt() as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable or disable echo cancellation.
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
pub fn set_enabled(&mut self, enabled: bool) {
|
||||||
self.enabled = enabled;
|
self.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns whether echo cancellation is currently enabled.
|
||||||
pub fn is_enabled(&self) -> bool {
|
pub fn is_enabled(&self) -> bool {
|
||||||
self.enabled
|
self.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset the adaptive filter to its initial state.
|
||||||
|
///
|
||||||
|
/// Zeroes out all filter coefficients and the far-end circular buffer.
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.filter.iter_mut().for_each(|c| *c = 0.0);
|
self.filter_coeffs.iter_mut().for_each(|c| *c = 0.0);
|
||||||
self.far_buf.iter_mut().for_each(|s| *s = 0.0);
|
self.far_end_buf.iter_mut().for_each(|s| *s = 0.0);
|
||||||
self.far_pos = 0;
|
self.far_end_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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,40 +143,50 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creates_with_correct_sizes() {
|
fn aec_creates_with_correct_filter_len() {
|
||||||
let aec = EchoCanceller::with_delay(48000, 60, 40);
|
let aec = EchoCanceller::new(48000, 100);
|
||||||
assert_eq!(aec.filter_len, 2880); // 60ms @ 48kHz
|
assert_eq!(aec.filter_len, 4800);
|
||||||
assert_eq!(aec.delay_samples, 1920); // 40ms @ 48kHz
|
assert_eq!(aec.filter_coeffs.len(), 4800);
|
||||||
|
assert_eq!(aec.far_end_buf.len(), 4800);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn passthrough_when_disabled() {
|
fn aec_passthrough_when_disabled() {
|
||||||
let mut aec = EchoCanceller::new(48000, 60);
|
let mut aec = EchoCanceller::new(48000, 100);
|
||||||
aec.set_enabled(false);
|
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();
|
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);
|
assert_eq!(frame, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn silence_passthrough() {
|
fn aec_reset_zeroes_state() {
|
||||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
let mut aec = EchoCanceller::new(48000, 10); // short for test speed
|
||||||
aec.feed_farend(&vec![0i16; 960]);
|
let farend: Vec<i16> = (0..480).map(|i| ((i * 37) % 1000) as i16).collect();
|
||||||
let mut frame = vec![0i16; 960];
|
aec.feed_farend(&farend);
|
||||||
aec.process_frame(&mut frame);
|
|
||||||
assert!(frame.iter().all(|&s| s == 0));
|
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]
|
#[test]
|
||||||
fn reduces_echo_with_no_delay() {
|
fn aec_reduces_echo_of_known_signal() {
|
||||||
// Simulate: far-end plays, echo arrives at mic attenuated by ~50%
|
// Use a small filter for speed. Feed a known far-end signal, then
|
||||||
// (realistic — speaker to mic on laptop loses volume).
|
// present the *same* signal as near-end (perfect echo, no room).
|
||||||
let mut aec = EchoCanceller::with_delay(48000, 10, 0);
|
// 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;
|
// Generate a simple repeating pattern.
|
||||||
let make_tone = |offset: usize| -> Vec<i16> {
|
let frame_len = 480usize;
|
||||||
|
let make_frame = |offset: usize| -> Vec<i16> {
|
||||||
(0..frame_len)
|
(0..frame_len)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let t = (offset + i) as f64 / 48000.0;
|
let t = (offset + i) as f64 / 48000.0;
|
||||||
@@ -270,16 +195,18 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Warm up the adaptive filter with several frames.
|
||||||
let mut last_erle = 1.0f32;
|
let mut last_erle = 1.0f32;
|
||||||
for frame_idx in 0..100 {
|
for frame_idx in 0..40 {
|
||||||
let farend = make_tone(frame_idx * frame_len);
|
let farend = make_frame(frame_idx * frame_len);
|
||||||
aec.feed_farend(&farend);
|
aec.feed_farend(&farend);
|
||||||
|
|
||||||
// Near-end = attenuated copy of far-end (echo at ~50% volume).
|
// Near-end = exact copy of far-end (pure echo).
|
||||||
let mut nearend: Vec<i16> = farend.iter().map(|&s| s / 2).collect();
|
let mut nearend = farend.clone();
|
||||||
last_erle = aec.process_frame(&mut nearend);
|
last_erle = aec.process_frame(&mut nearend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After 40 frames the ERLE should be meaningfully > 1.
|
||||||
assert!(
|
assert!(
|
||||||
last_erle > 1.0,
|
last_erle > 1.0,
|
||||||
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
"expected ERLE > 1.0 after adaptation, got {last_erle}"
|
||||||
@@ -287,49 +214,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_nearend_during_doubletalk() {
|
fn aec_silence_passthrough() {
|
||||||
let mut aec = EchoCanceller::with_delay(48000, 30, 0);
|
let mut aec = EchoCanceller::new(48000, 10);
|
||||||
|
// Feed silence far-end
|
||||||
let frame_len = 960;
|
aec.feed_farend(&vec![0i16; 480]);
|
||||||
let nearend: Vec<i16> = (0..frame_len)
|
// Near-end is silence too
|
||||||
.map(|i| {
|
let mut frame = vec![0i16; 480];
|
||||||
let t = i as f64 / 48000.0;
|
let erle = aec.process_frame(&mut frame);
|
||||||
(10000.0 * (2.0 * std::f64::consts::PI * 440.0 * t).sin()) as i16
|
assert!(erle >= 1.0);
|
||||||
})
|
// Output should still be silence
|
||||||
.collect();
|
assert!(frame.iter().all(|&s| s == 0));
|
||||||
|
|
||||||
// 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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
169
crates/wzp-web/static/wasm/wzp_wasm.d.ts
vendored
169
crates/wzp-web/static/wasm/wzp_wasm.d.ts
vendored
@@ -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>;
|
|
||||||
27
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm.d.ts
vendored
27
crates/wzp-web/static/wasm/wzp_wasm_bg.wasm.d.ts
vendored
@@ -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;
|
|
||||||
2
desktop/.gitignore
vendored
2
desktop/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"hash": "9046c0bf",
|
|
||||||
"configHash": "ef0fc96f",
|
|
||||||
"lockfileHash": "d66891b1",
|
|
||||||
"browserHash": "8171ed59",
|
|
||||||
"optimized": {},
|
|
||||||
"chunks": {}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
@@ -1,213 +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">⚙</span>
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
<label>Room
|
|
||||||
<input id="room" type="text" value="android" />
|
|
||||||
</label>
|
|
||||||
<label>Alias
|
|
||||||
<input id="alias" type="text" placeholder="your name" />
|
|
||||||
</label>
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input id="os-aec" type="checkbox" checked />
|
|
||||||
OS Echo Cancel
|
|
||||||
</label>
|
|
||||||
<button id="settings-btn-home" class="icon-btn" title="Settings (Cmd+,)">⚙</button>
|
|
||||||
</div>
|
|
||||||
<!-- Mode toggle -->
|
|
||||||
<div class="mode-toggle" style="display:flex;gap:8px;margin-bottom:8px;">
|
|
||||||
<button id="mode-room" class="mode-btn active" style="flex:1">Room</button>
|
|
||||||
<button id="mode-direct" class="mode-btn" style="flex:1">Direct Call</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Room mode (default) -->
|
|
||||||
<div id="room-mode">
|
|
||||||
<button id="connect-btn" class="primary">Connect</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Direct call mode -->
|
|
||||||
<div id="direct-mode" class="hidden">
|
|
||||||
<button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button>
|
|
||||||
<div id="direct-registered" class="hidden" style="margin-top:12px">
|
|
||||||
<p style="color:var(--green);font-size:13px">✅ Registered — waiting for calls</p>
|
|
||||||
<div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0">
|
|
||||||
<p style="font-weight:bold;margin:0 0 4px 0">Incoming Call</p>
|
|
||||||
<p id="incoming-caller" style="font-size:12px;opacity:0.8;margin:0 0 8px 0">From: unknown</p>
|
|
||||||
<div style="display:flex;gap:8px">
|
|
||||||
<button id="accept-call-btn" style="flex:1;background:var(--green);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Accept</button>
|
|
||||||
<button id="reject-call-btn" style="flex:1;background:var(--red);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Reject</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label style="margin-top:8px">Call by fingerprint
|
|
||||||
<input id="target-fp" type="text" placeholder="xxxx:xxxx:xxxx:..." />
|
|
||||||
</label>
|
|
||||||
<button id="call-btn" class="primary" style="margin-top:8px">Call</button>
|
|
||||||
<p id="call-status-text" style="color:var(--yellow);font-size:13px;margin-top:4px"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="connect-error" class="error"></p>
|
|
||||||
</div>
|
|
||||||
<div class="identity-info">
|
|
||||||
<span id="my-identicon"></span>
|
|
||||||
<span id="my-fingerprint" class="fp-display"></span>
|
|
||||||
</div>
|
|
||||||
<div class="recent-rooms" id="recent-rooms"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- In-call screen -->
|
|
||||||
<div id="call-screen" class="hidden">
|
|
||||||
<div class="call-header">
|
|
||||||
<div class="call-header-row">
|
|
||||||
<div id="room-name" class="room-name"></div>
|
|
||||||
<button id="settings-btn-call" class="icon-btn small" title="Settings (Cmd+,)">⚙</button>
|
|
||||||
</div>
|
|
||||||
<div class="call-meta">
|
|
||||||
<span id="call-status" class="status-dot"></span>
|
|
||||||
<span id="call-timer" class="call-timer">0:00</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="level-meter">
|
|
||||||
<div id="level-bar" class="level-bar-fill"></div>
|
|
||||||
</div>
|
|
||||||
<div id="participants" class="participants"></div>
|
|
||||||
<div class="controls">
|
|
||||||
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
|
|
||||||
<span class="icon" id="mic-icon">Mic</span>
|
|
||||||
</button>
|
|
||||||
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
|
|
||||||
<span class="icon">End</span>
|
|
||||||
</button>
|
|
||||||
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
|
|
||||||
<span class="icon" id="spk-icon">Spk</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="stats" class="stats"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings panel -->
|
|
||||||
<div id="settings-panel" class="hidden">
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-header">
|
|
||||||
<h2>Settings</h2>
|
|
||||||
<button id="settings-close" class="icon-btn">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Connection</h3>
|
|
||||||
<label>Default Room
|
|
||||||
<input id="s-room" type="text" />
|
|
||||||
</label>
|
|
||||||
<label>Alias
|
|
||||||
<input id="s-alias" type="text" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Audio</h3>
|
|
||||||
<div class="quality-control">
|
|
||||||
<div class="quality-header">
|
|
||||||
<span class="setting-label">QUALITY</span>
|
|
||||||
<span id="s-quality-label" class="quality-label">Auto</span>
|
|
||||||
</div>
|
|
||||||
<input id="s-quality" type="range" min="0" max="7" step="1" value="3" class="quality-slider" />
|
|
||||||
<div class="quality-ticks">
|
|
||||||
<span>64k</span>
|
|
||||||
<span>48k</span>
|
|
||||||
<span>32k</span>
|
|
||||||
<span>Auto</span>
|
|
||||||
<span>24k</span>
|
|
||||||
<span>6k</span>
|
|
||||||
<span>C2</span>
|
|
||||||
<span>1.2k</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="checkbox">
|
|
||||||
<input id="s-os-aec" type="checkbox" />
|
|
||||||
OS Echo Cancellation (macOS VoiceProcessingIO)
|
|
||||||
</label>
|
|
||||||
<label class="checkbox">
|
|
||||||
<input id="s-agc" type="checkbox" checked />
|
|
||||||
Automatic Gain Control
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Identity</h3>
|
|
||||||
<div class="setting-row">
|
|
||||||
<span class="setting-label">Fingerprint</span>
|
|
||||||
<span id="s-fingerprint" class="fp-display-large"></span>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<span class="setting-label">Identity file</span>
|
|
||||||
<span class="fp-display">~/.wzp/identity</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-section">
|
|
||||||
<h3>Recent Rooms</h3>
|
|
||||||
<div id="s-recent-rooms" class="recent-rooms-list"></div>
|
|
||||||
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
|
|
||||||
</div>
|
|
||||||
<button id="settings-save" class="primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manage Relays dialog -->
|
|
||||||
<div id="relay-dialog" class="hidden">
|
|
||||||
<div class="settings-card relay-dialog-card">
|
|
||||||
<div class="settings-header">
|
|
||||||
<h2>Manage Relays</h2>
|
|
||||||
<button id="relay-dialog-close" class="icon-btn">×</button>
|
|
||||||
</div>
|
|
||||||
<div id="relay-dialog-list" class="relay-dialog-list"></div>
|
|
||||||
<div class="relay-add-row">
|
|
||||||
<div class="relay-add-inputs">
|
|
||||||
<input id="relay-add-name" type="text" placeholder="Name" />
|
|
||||||
<input id="relay-add-addr" type="text" placeholder="host:port" />
|
|
||||||
</div>
|
|
||||||
<button id="relay-add-btn" class="primary">Add Relay</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Key changed warning dialog -->
|
|
||||||
<div id="key-warning" class="hidden">
|
|
||||||
<div class="settings-card key-warning-card">
|
|
||||||
<div class="key-warning-icon">⚠</div>
|
|
||||||
<h2>Server Key Changed</h2>
|
|
||||||
<p class="key-warning-text">The relay's identity has changed since you last connected. This usually happens when the server was restarted, but could also indicate a security issue.</p>
|
|
||||||
<div class="key-warning-fps">
|
|
||||||
<div class="key-fp-row">
|
|
||||||
<span class="key-fp-label">Previously known</span>
|
|
||||||
<code id="kw-old-fp" class="key-fp"></code>
|
|
||||||
</div>
|
|
||||||
<div class="key-fp-row">
|
|
||||||
<span class="key-fp-label">New key</span>
|
|
||||||
<code id="kw-new-fp" class="key-fp"></code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="key-warning-actions">
|
|
||||||
<button id="kw-accept" class="primary">Accept New Key</button>
|
|
||||||
<button id="kw-cancel" class="secondary-btn">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
1350
desktop/package-lock.json
generated
1350
desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -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 |
@@ -1,439 +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::{CodecId, MediaTransport, QualityProfile};
|
|
||||||
|
|
||||||
const FRAME_SAMPLES_40MS: usize = 1920;
|
|
||||||
|
|
||||||
/// Resolve a quality string from the UI to a QualityProfile.
|
|
||||||
/// Returns None for "auto" (use default adaptive behavior).
|
|
||||||
fn resolve_quality(quality: &str) -> Option<QualityProfile> {
|
|
||||||
match quality {
|
|
||||||
"good" | "opus" => Some(QualityProfile::GOOD),
|
|
||||||
"degraded" | "opus6k" => Some(QualityProfile::DEGRADED),
|
|
||||||
"catastrophic" | "codec2-1200" => Some(QualityProfile::CATASTROPHIC),
|
|
||||||
"codec2-3200" => Some(QualityProfile {
|
|
||||||
codec: CodecId::Codec2_3200,
|
|
||||||
fec_ratio: 0.5,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
}),
|
|
||||||
"studio-32k" => Some(QualityProfile::STUDIO_32K),
|
|
||||||
"studio-48k" => Some(QualityProfile::STUDIO_48K),
|
|
||||||
"studio-64k" => Some(QualityProfile::STUDIO_64K),
|
|
||||||
_ => None, // "auto" or unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrapper to make non-Sync audio handles safe to store in shared state.
|
|
||||||
/// The audio handle is only accessed from the thread that created it (drop),
|
|
||||||
/// never shared across threads — Sync is safe.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct SyncWrapper(Box<dyn std::any::Any + Send>);
|
|
||||||
unsafe impl Sync for SyncWrapper {}
|
|
||||||
|
|
||||||
pub struct ParticipantInfo {
|
|
||||||
pub fingerprint: String,
|
|
||||||
pub alias: Option<String>,
|
|
||||||
pub relay_label: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct EngineStatus {
|
|
||||||
pub mic_muted: bool,
|
|
||||||
pub spk_muted: bool,
|
|
||||||
pub participants: Vec<ParticipantInfo>,
|
|
||||||
pub frames_sent: u64,
|
|
||||||
pub frames_received: u64,
|
|
||||||
pub audio_level: u32,
|
|
||||||
pub call_duration_secs: f64,
|
|
||||||
pub fingerprint: String,
|
|
||||||
pub tx_codec: String,
|
|
||||||
pub rx_codec: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CallEngine {
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
mic_muted: Arc<AtomicBool>,
|
|
||||||
spk_muted: Arc<AtomicBool>,
|
|
||||||
participants: Arc<Mutex<Vec<ParticipantInfo>>>,
|
|
||||||
frames_sent: Arc<AtomicU64>,
|
|
||||||
frames_received: Arc<AtomicU64>,
|
|
||||||
audio_level: Arc<AtomicU32>,
|
|
||||||
tx_codec: Arc<Mutex<String>>,
|
|
||||||
rx_codec: Arc<Mutex<String>>,
|
|
||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
|
||||||
start_time: Instant,
|
|
||||||
fingerprint: String,
|
|
||||||
/// Keep audio handles alive for the duration of the call.
|
|
||||||
/// Wrapped in SyncWrapper because AudioUnit isn't Sync.
|
|
||||||
_audio_handle: SyncWrapper,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CallEngine {
|
|
||||||
pub async fn start<F>(
|
|
||||||
relay: String,
|
|
||||||
room: String,
|
|
||||||
alias: String,
|
|
||||||
_os_aec: bool,
|
|
||||||
quality: String,
|
|
||||||
event_cb: F,
|
|
||||||
) -> Result<Self, anyhow::Error>
|
|
||||||
where
|
|
||||||
F: Fn(&str, &str) + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let relay_addr: SocketAddr = relay.parse()?;
|
|
||||||
|
|
||||||
// Load or generate identity
|
|
||||||
let seed = {
|
|
||||||
let path = {
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
|
||||||
std::path::PathBuf::from(home).join(".wzp").join("identity")
|
|
||||||
};
|
|
||||||
if path.exists() {
|
|
||||||
if let Ok(hex) = std::fs::read_to_string(&path) {
|
|
||||||
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
|
||||||
s
|
|
||||||
} else {
|
|
||||||
wzp_crypto::Seed::generate()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wzp_crypto::Seed::generate()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let s = wzp_crypto::Seed::generate();
|
|
||||||
if let Some(p) = path.parent() {
|
|
||||||
std::fs::create_dir_all(p).ok();
|
|
||||||
}
|
|
||||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
std::fs::write(&path, hex).ok();
|
|
||||||
s
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let fp = seed.derive_identity().public_identity().fingerprint;
|
|
||||||
let fingerprint = fp.to_string();
|
|
||||||
info!(%fp, "identity loaded");
|
|
||||||
|
|
||||||
// Connect
|
|
||||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
|
||||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
|
||||||
let client_config = wzp_transport::client_config();
|
|
||||||
let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config).await?;
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
|
||||||
|
|
||||||
// Handshake
|
|
||||||
let _session = wzp_client::handshake::perform_handshake(
|
|
||||||
&*transport,
|
|
||||||
&seed.0,
|
|
||||||
Some(&alias),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!("connected to relay, handshake complete");
|
|
||||||
event_cb("connected", &format!("joined room {room}"));
|
|
||||||
|
|
||||||
// Audio I/O — VPIO (OS AEC) on macOS, plain CPAL otherwise.
|
|
||||||
// The audio handle must be stored in CallEngine to keep streams alive.
|
|
||||||
let (capture_ring, playout_ring, audio_handle): (_, _, Box<dyn std::any::Any + Send>) =
|
|
||||||
if _os_aec {
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
match wzp_client::audio_vpio::VpioAudio::start() {
|
|
||||||
Ok(v) => {
|
|
||||||
let cr = v.capture_ring().clone();
|
|
||||||
let pr = v.playout_ring().clone();
|
|
||||||
info!("using VoiceProcessingIO (OS AEC)");
|
|
||||||
(cr, pr, Box::new(v))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
info!("VPIO failed ({e}), falling back to CPAL");
|
|
||||||
let capture = AudioCapture::start()?;
|
|
||||||
let playback = AudioPlayback::start()?;
|
|
||||||
let cr = capture.ring().clone();
|
|
||||||
let pr = playback.ring().clone();
|
|
||||||
(cr, pr, Box::new((capture, playback)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
{
|
|
||||||
info!("OS AEC not available on this platform, using CPAL");
|
|
||||||
let capture = AudioCapture::start()?;
|
|
||||||
let playback = AudioPlayback::start()?;
|
|
||||||
let cr = capture.ring().clone();
|
|
||||||
let pr = playback.ring().clone();
|
|
||||||
(cr, pr, Box::new((capture, playback)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let capture = AudioCapture::start()?;
|
|
||||||
let playback = AudioPlayback::start()?;
|
|
||||||
let cr = capture.ring().clone();
|
|
||||||
let pr = playback.ring().clone();
|
|
||||||
(cr, pr, Box::new((capture, playback)))
|
|
||||||
};
|
|
||||||
|
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
|
||||||
let mic_muted = Arc::new(AtomicBool::new(false));
|
|
||||||
let spk_muted = Arc::new(AtomicBool::new(false));
|
|
||||||
let participants: Arc<Mutex<Vec<ParticipantInfo>>> = Arc::new(Mutex::new(vec![]));
|
|
||||||
let frames_sent = Arc::new(AtomicU64::new(0));
|
|
||||||
let frames_received = Arc::new(AtomicU64::new(0));
|
|
||||||
let audio_level = Arc::new(AtomicU32::new(0));
|
|
||||||
let tx_codec = Arc::new(Mutex::new(String::new()));
|
|
||||||
let rx_codec = Arc::new(Mutex::new(String::new()));
|
|
||||||
|
|
||||||
// Send task
|
|
||||||
let send_t = transport.clone();
|
|
||||||
let send_r = running.clone();
|
|
||||||
let send_mic = mic_muted.clone();
|
|
||||||
let send_fs = frames_sent.clone();
|
|
||||||
let send_level = audio_level.clone();
|
|
||||||
let send_drops = Arc::new(AtomicU64::new(0));
|
|
||||||
let send_quality = quality.clone();
|
|
||||||
let send_tx_codec = tx_codec.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let profile = resolve_quality(&send_quality);
|
|
||||||
let config = match profile {
|
|
||||||
Some(p) => CallConfig {
|
|
||||||
noise_suppression: false,
|
|
||||||
suppression_enabled: false,
|
|
||||||
..CallConfig::from_profile(p)
|
|
||||||
},
|
|
||||||
None => CallConfig {
|
|
||||||
noise_suppression: false,
|
|
||||||
suppression_enabled: false,
|
|
||||||
..CallConfig::default()
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let frame_samples = (config.profile.frame_duration_ms as usize) * 48;
|
|
||||||
info!(codec = ?config.profile.codec, frame_samples, "send task starting");
|
|
||||||
*send_tx_codec.lock().await = format!("{:?}", config.profile.codec);
|
|
||||||
let mut encoder = CallEncoder::new(&config);
|
|
||||||
encoder.set_aec_enabled(false); // OS AEC or none
|
|
||||||
let mut buf = vec![0i16; frame_samples];
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !send_r.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if capture_ring.available() < frame_samples {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
capture_ring.read(&mut buf);
|
|
||||||
|
|
||||||
// Compute RMS audio level for UI meter
|
|
||||||
if !buf.is_empty() {
|
|
||||||
let sum_sq: f64 = buf.iter().map(|&s| (s as f64) * (s as f64)).sum();
|
|
||||||
let rms = (sum_sq / buf.len() as f64).sqrt() as u32;
|
|
||||||
send_level.store(rms, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if send_mic.load(Ordering::Relaxed) {
|
|
||||||
buf.fill(0);
|
|
||||||
}
|
|
||||||
match encoder.encode_frame(&buf) {
|
|
||||||
Ok(pkts) => {
|
|
||||||
for pkt in &pkts {
|
|
||||||
if let Err(e) = send_t.send_media(pkt).await {
|
|
||||||
// Transient congestion (Blocked) — drop packet, keep going
|
|
||||||
send_drops.fetch_add(1, Ordering::Relaxed);
|
|
||||||
if send_drops.load(Ordering::Relaxed) <= 3 {
|
|
||||||
tracing::warn!("send_media error (dropping packet): {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
send_fs.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Err(e) => error!("encode: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recv task (direct playout with auto codec switch)
|
|
||||||
let recv_t = transport.clone();
|
|
||||||
let recv_r = running.clone();
|
|
||||||
let recv_spk = spk_muted.clone();
|
|
||||||
let recv_fr = frames_received.clone();
|
|
||||||
let recv_rx_codec = rx_codec.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
|
|
||||||
let mut decoder = wzp_codec::create_decoder(initial_profile);
|
|
||||||
let mut current_codec = initial_profile.codec;
|
|
||||||
let mut agc = wzp_codec::AutoGainControl::new();
|
|
||||||
let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !recv_r.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match tokio::time::timeout(
|
|
||||||
std::time::Duration::from_millis(100),
|
|
||||||
recv_t.recv_media(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(Some(pkt))) => {
|
|
||||||
if !pkt.header.is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
|
||||||
// Track RX codec
|
|
||||||
{
|
|
||||||
let mut rx = recv_rx_codec.lock().await;
|
|
||||||
let codec_name = format!("{:?}", pkt.header.codec_id);
|
|
||||||
if *rx != codec_name { *rx = codec_name; }
|
|
||||||
}
|
|
||||||
// Auto-switch decoder if incoming codec differs
|
|
||||||
if pkt.header.codec_id != current_codec {
|
|
||||||
let new_profile = match pkt.header.codec_id {
|
|
||||||
CodecId::Opus24k => QualityProfile::GOOD,
|
|
||||||
CodecId::Opus6k => QualityProfile::DEGRADED,
|
|
||||||
CodecId::Opus32k => QualityProfile::STUDIO_32K,
|
|
||||||
CodecId::Opus48k => QualityProfile::STUDIO_48K,
|
|
||||||
CodecId::Opus64k => QualityProfile::STUDIO_64K,
|
|
||||||
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
|
|
||||||
CodecId::Codec2_3200 => QualityProfile {
|
|
||||||
codec: CodecId::Codec2_3200,
|
|
||||||
fec_ratio: 0.5, frame_duration_ms: 20, frames_per_block: 5,
|
|
||||||
},
|
|
||||||
other => QualityProfile { codec: other, ..QualityProfile::GOOD },
|
|
||||||
};
|
|
||||||
info!(from = ?current_codec, to = ?pkt.header.codec_id, "recv: switching decoder");
|
|
||||||
let _ = decoder.set_profile(new_profile);
|
|
||||||
current_codec = pkt.header.codec_id;
|
|
||||||
}
|
|
||||||
if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) {
|
|
||||||
agc.process_frame(&mut pcm[..n]);
|
|
||||||
if !recv_spk.load(Ordering::Relaxed) {
|
|
||||||
playout_ring.write(&pcm[..n]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recv_fr.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
Ok(Ok(None)) => break,
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
let msg = e.to_string();
|
|
||||||
if msg.contains("closed") || msg.contains("reset") {
|
|
||||||
error!("recv fatal: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Signal task (presence)
|
|
||||||
let sig_t = transport.clone();
|
|
||||||
let sig_r = running.clone();
|
|
||||||
let sig_p = participants.clone();
|
|
||||||
let event_cb = Arc::new(event_cb);
|
|
||||||
let sig_cb = event_cb.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
if !sig_r.load(Ordering::Relaxed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match tokio::time::timeout(
|
|
||||||
std::time::Duration::from_millis(200),
|
|
||||||
sig_t.recv_signal(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate {
|
|
||||||
participants: parts,
|
|
||||||
..
|
|
||||||
}))) => {
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let unique: Vec<ParticipantInfo> = parts
|
|
||||||
.into_iter()
|
|
||||||
.filter(|p| seen.insert((p.fingerprint.clone(), p.alias.clone())))
|
|
||||||
.map(|p| ParticipantInfo {
|
|
||||||
fingerprint: p.fingerprint,
|
|
||||||
alias: p.alias,
|
|
||||||
relay_label: p.relay_label,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let count = unique.len();
|
|
||||||
*sig_p.lock().await = unique;
|
|
||||||
sig_cb("room-update", &format!("{count} participants"));
|
|
||||||
}
|
|
||||||
Ok(Ok(Some(_))) => {}
|
|
||||||
Ok(Ok(None)) => break,
|
|
||||||
Ok(Err(_)) => break,
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
running,
|
|
||||||
mic_muted,
|
|
||||||
spk_muted,
|
|
||||||
participants,
|
|
||||||
frames_sent,
|
|
||||||
frames_received,
|
|
||||||
audio_level,
|
|
||||||
transport,
|
|
||||||
start_time: Instant::now(),
|
|
||||||
fingerprint,
|
|
||||||
tx_codec,
|
|
||||||
rx_codec,
|
|
||||||
_audio_handle: SyncWrapper(audio_handle),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_mic(&self) -> bool {
|
|
||||||
let was = self.mic_muted.load(Ordering::Relaxed);
|
|
||||||
self.mic_muted.store(!was, Ordering::Relaxed);
|
|
||||||
!was
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_speaker(&self) -> bool {
|
|
||||||
let was = self.spk_muted.load(Ordering::Relaxed);
|
|
||||||
self.spk_muted.store(!was, Ordering::Relaxed);
|
|
||||||
!was
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn status(&self) -> EngineStatus {
|
|
||||||
let participants = {
|
|
||||||
let parts = self.participants.lock().await;
|
|
||||||
parts
|
|
||||||
.iter()
|
|
||||||
.map(|p| ParticipantInfo {
|
|
||||||
fingerprint: p.fingerprint.clone(),
|
|
||||||
alias: p.alias.clone(),
|
|
||||||
relay_label: p.relay_label.clone(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}; // lock dropped here
|
|
||||||
EngineStatus {
|
|
||||||
mic_muted: self.mic_muted.load(Ordering::Relaxed),
|
|
||||||
spk_muted: self.spk_muted.load(Ordering::Relaxed),
|
|
||||||
participants,
|
|
||||||
frames_sent: self.frames_sent.load(Ordering::Relaxed),
|
|
||||||
frames_received: self.frames_received.load(Ordering::Relaxed),
|
|
||||||
audio_level: self.audio_level.load(Ordering::Relaxed),
|
|
||||||
call_duration_secs: self.start_time.elapsed().as_secs_f64(),
|
|
||||||
fingerprint: self.fingerprint.clone(),
|
|
||||||
tx_codec: self.tx_codec.lock().await.clone(),
|
|
||||||
rx_codec: self.rx_codec.lock().await.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop(self) {
|
|
||||||
self.running.store(false, Ordering::SeqCst);
|
|
||||||
self.transport.close().await.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +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>,
|
|
||||||
relay_label: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
|
||||||
struct CallStatus {
|
|
||||||
active: bool,
|
|
||||||
mic_muted: bool,
|
|
||||||
spk_muted: bool,
|
|
||||||
participants: Vec<Participant>,
|
|
||||||
encode_fps: u64,
|
|
||||||
recv_fps: u64,
|
|
||||||
audio_level: u32,
|
|
||||||
call_duration_secs: f64,
|
|
||||||
fingerprint: String,
|
|
||||||
tx_codec: String,
|
|
||||||
rx_codec: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppState {
|
|
||||||
engine: Mutex<Option<CallEngine>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ping result with RTT and server identity hash.
|
|
||||||
#[derive(Clone, Serialize)]
|
|
||||||
struct PingResult {
|
|
||||||
rtt_ms: u32,
|
|
||||||
/// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded.
|
|
||||||
server_fingerprint: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ping a relay to check if it's online, measure RTT, and get server identity.
|
|
||||||
#[tauri::command]
|
|
||||||
async fn ping_relay(relay: String) -> Result<PingResult, String> {
|
|
||||||
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
|
||||||
let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?;
|
|
||||||
let client_cfg = wzp_transport::client_config();
|
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let conn_result = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(3),
|
|
||||||
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Always close endpoint to prevent resource leaks
|
|
||||||
endpoint.close(0u32.into(), b"done");
|
|
||||||
|
|
||||||
match conn_result {
|
|
||||||
Ok(Ok(conn)) => {
|
|
||||||
let rtt_ms = start.elapsed().as_millis() as u32;
|
|
||||||
|
|
||||||
let server_fingerprint = conn
|
|
||||||
.peer_identity()
|
|
||||||
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
|
||||||
.and_then(|certs| certs.first().map(|c| {
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
c.as_ref().hash(&mut hasher);
|
|
||||||
let h = hasher.finish();
|
|
||||||
format!("{h:016x}")
|
|
||||||
}))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
|
|
||||||
});
|
|
||||||
|
|
||||||
conn.close(0u32.into(), b"ping");
|
|
||||||
Ok(PingResult { rtt_ms, server_fingerprint })
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => Err(format!("{e}")),
|
|
||||||
Err(_) => Err("timeout (3s)".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read fingerprint from ~/.wzp/identity without connecting.
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_identity() -> Result<String, String> {
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
|
||||||
let path = std::path::PathBuf::from(home).join(".wzp").join("identity");
|
|
||||||
if path.exists() {
|
|
||||||
if let Ok(hex) = std::fs::read_to_string(&path) {
|
|
||||||
if let Ok(seed) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
|
||||||
let fp = seed.derive_identity().public_identity().fingerprint;
|
|
||||||
return Ok(fp.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No identity yet — generate one so we can show the fingerprint
|
|
||||||
let seed = wzp_crypto::Seed::generate();
|
|
||||||
let fp = seed.derive_identity().public_identity().fingerprint;
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).ok();
|
|
||||||
}
|
|
||||||
let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
std::fs::write(&path, hex).ok();
|
|
||||||
Ok(fp.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn connect(
|
|
||||||
state: tauri::State<'_, Arc<AppState>>,
|
|
||||||
app: tauri::AppHandle,
|
|
||||||
relay: String,
|
|
||||||
room: String,
|
|
||||||
alias: String,
|
|
||||||
os_aec: bool,
|
|
||||||
quality: String,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let mut engine_lock = state.engine.lock().await;
|
|
||||||
if engine_lock.is_some() {
|
|
||||||
return Err("already connected".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
match CallEngine::start(relay, room, alias, os_aec, quality, move |event_kind, message| {
|
|
||||||
let _ = app_clone.emit(
|
|
||||||
"call-event",
|
|
||||||
CallEvent {
|
|
||||||
kind: event_kind.to_string(),
|
|
||||||
message: message.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(eng) => {
|
|
||||||
*engine_lock = Some(eng);
|
|
||||||
Ok("connected".into())
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("{e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
|
|
||||||
let mut engine_lock = state.engine.lock().await;
|
|
||||||
if let Some(engine) = engine_lock.take() {
|
|
||||||
engine.stop().await;
|
|
||||||
Ok("disconnected".into())
|
|
||||||
} else {
|
|
||||||
Err("not connected".into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
|
|
||||||
let engine_lock = state.engine.lock().await;
|
|
||||||
if let Some(ref engine) = *engine_lock {
|
|
||||||
Ok(engine.toggle_mic())
|
|
||||||
} else {
|
|
||||||
Err("not connected".into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
|
|
||||||
let engine_lock = state.engine.lock().await;
|
|
||||||
if let Some(ref engine) = *engine_lock {
|
|
||||||
Ok(engine.toggle_speaker())
|
|
||||||
} else {
|
|
||||||
Err("not connected".into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus, String> {
|
|
||||||
let engine_lock = state.engine.lock().await;
|
|
||||||
if let Some(ref engine) = *engine_lock {
|
|
||||||
let status = engine.status().await;
|
|
||||||
Ok(CallStatus {
|
|
||||||
active: true,
|
|
||||||
mic_muted: status.mic_muted,
|
|
||||||
spk_muted: status.spk_muted,
|
|
||||||
participants: status
|
|
||||||
.participants
|
|
||||||
.into_iter()
|
|
||||||
.map(|p| Participant {
|
|
||||||
fingerprint: p.fingerprint,
|
|
||||||
alias: p.alias,
|
|
||||||
relay_label: p.relay_label,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
encode_fps: status.frames_sent,
|
|
||||||
recv_fps: status.frames_received,
|
|
||||||
audio_level: status.audio_level,
|
|
||||||
call_duration_secs: status.call_duration_secs,
|
|
||||||
fingerprint: status.fingerprint,
|
|
||||||
tx_codec: status.tx_codec,
|
|
||||||
rx_codec: status.rx_codec,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(CallStatus {
|
|
||||||
active: false,
|
|
||||||
mic_muted: false,
|
|
||||||
spk_muted: false,
|
|
||||||
participants: vec![],
|
|
||||||
encode_fps: 0,
|
|
||||||
recv_fps: 0,
|
|
||||||
audio_level: 0,
|
|
||||||
call_duration_secs: 0.0,
|
|
||||||
fingerprint: String::new(),
|
|
||||||
tx_codec: String::new(),
|
|
||||||
rx_codec: String::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
tracing_subscriber::fmt().init();
|
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
|
||||||
engine: Mutex::new(None),
|
|
||||||
});
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
|
||||||
.plugin(tauri_plugin_shell::init())
|
|
||||||
.manage(state)
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
ping_relay,
|
|
||||||
get_identity,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
toggle_mic,
|
|
||||||
toggle_speaker,
|
|
||||||
get_status,
|
|
||||||
])
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("error while running WarzonePhone Desktop");
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,789 +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 sQuality = document.getElementById("s-quality") as HTMLInputElement;
|
|
||||||
const sQualityLabel = document.getElementById("s-quality-label")!;
|
|
||||||
|
|
||||||
// Quality slider config — best (left/green) to worst (right/red)
|
|
||||||
const QUALITY_STEPS = ["studio-64k", "studio-48k", "studio-32k", "auto", "good", "degraded", "codec2-3200", "catastrophic"];
|
|
||||||
const QUALITY_LABELS = ["Studio 64k", "Studio 48k", "Studio 32k", "Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"];
|
|
||||||
const QUALITY_COLORS = ["#22c55e", "#4ade80", "#86efac", "#a3e635", "#facc15", "#f59e0b", "#e97320", "#991b1b"];
|
|
||||||
|
|
||||||
function qualityToIndex(q: string): number {
|
|
||||||
const idx = QUALITY_STEPS.indexOf(q);
|
|
||||||
return idx >= 0 ? idx : 3; // default to "auto" (index 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQualityUI(index: number) {
|
|
||||||
sQualityLabel.textContent = QUALITY_LABELS[index];
|
|
||||||
sQualityLabel.style.color = QUALITY_COLORS[index];
|
|
||||||
sQuality.style.background = `linear-gradient(90deg, #22c55e 0%, #86efac 25%, #facc15 50%, #e97320 75%, #991b1b 100%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
sQuality.addEventListener("input", () => {
|
|
||||||
updateQualityUI(parseInt(sQuality.value));
|
|
||||||
});
|
|
||||||
const sFingerprint = document.getElementById("s-fingerprint")!;
|
|
||||||
const sRecentRooms = document.getElementById("s-recent-rooms")!;
|
|
||||||
const sClearRecent = document.getElementById("s-clear-recent")!;
|
|
||||||
|
|
||||||
// Key warning dialog
|
|
||||||
const keyWarning = document.getElementById("key-warning")!;
|
|
||||||
const kwOldFp = document.getElementById("kw-old-fp")!;
|
|
||||||
const kwNewFp = document.getElementById("kw-new-fp")!;
|
|
||||||
const kwAccept = document.getElementById("kw-accept")!;
|
|
||||||
const kwCancel = document.getElementById("kw-cancel")!;
|
|
||||||
|
|
||||||
let statusInterval: number | null = null;
|
|
||||||
let myFingerprint = "";
|
|
||||||
let userDisconnected = false;
|
|
||||||
|
|
||||||
// ── Data types ──
|
|
||||||
interface RelayServer {
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
rtt?: number | null;
|
|
||||||
serverFingerprint?: string | null; // from ping
|
|
||||||
knownFingerprint?: string | null; // saved TOFU fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentRoom { relay: string; room: string; }
|
|
||||||
|
|
||||||
interface Settings {
|
|
||||||
relays: RelayServer[];
|
|
||||||
selectedRelay: number;
|
|
||||||
room: string;
|
|
||||||
alias: string;
|
|
||||||
osAec: boolean;
|
|
||||||
agc: boolean;
|
|
||||||
quality: string;
|
|
||||||
recentRooms: RecentRoom[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSettings(): Settings {
|
|
||||||
const defaults: Settings = {
|
|
||||||
relays: [{ name: "Default", address: "193.180.213.68:4433" }],
|
|
||||||
selectedRelay: 0, room: "android", alias: "",
|
|
||||||
osAec: true, agc: true, quality: "auto", recentRooms: [],
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("wzp-settings");
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed.relay && !parsed.relays) {
|
|
||||||
parsed.relays = [{ name: "Default", address: parsed.relay }];
|
|
||||||
parsed.selectedRelay = 0;
|
|
||||||
delete parsed.relay;
|
|
||||||
}
|
|
||||||
if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") {
|
|
||||||
const addr = parsed.relays?.[0]?.address || defaults.relays[0].address;
|
|
||||||
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r }));
|
|
||||||
}
|
|
||||||
return { ...defaults, ...parsed };
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSettingsObj(s: Settings) {
|
|
||||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectedRelay(): RelayServer | undefined {
|
|
||||||
const s = loadSettings();
|
|
||||||
return s.relays[s.selectedRelay];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
const d = document.createElement("div");
|
|
||||||
d.textContent = s;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lock status ──
|
|
||||||
type LockStatus = "verified" | "new" | "changed" | "offline" | "unknown";
|
|
||||||
|
|
||||||
function lockStatus(relay: RelayServer): LockStatus {
|
|
||||||
if (relay.rtt === undefined || relay.rtt === null) return "unknown";
|
|
||||||
if (relay.rtt < 0) return "offline";
|
|
||||||
if (!relay.serverFingerprint) return "new";
|
|
||||||
if (!relay.knownFingerprint) return "new"; // first time
|
|
||||||
if (relay.serverFingerprint === relay.knownFingerprint) return "verified";
|
|
||||||
return "changed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockIcon(status: LockStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case "verified": return "🔒";
|
|
||||||
case "new": return "🔓";
|
|
||||||
case "changed": return "⚠️";
|
|
||||||
case "offline": return "🔴";
|
|
||||||
case "unknown": return "⚪";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockColor(status: LockStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case "verified": return "var(--green)";
|
|
||||||
case "new": return "var(--yellow)";
|
|
||||||
case "changed": return "var(--red)";
|
|
||||||
case "offline": return "var(--red)";
|
|
||||||
case "unknown": return "var(--text-dim)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Apply settings ──
|
|
||||||
function applySettings() {
|
|
||||||
const s = loadSettings();
|
|
||||||
roomInput.value = s.room;
|
|
||||||
aliasInput.value = s.alias;
|
|
||||||
osAecCheckbox.checked = s.osAec;
|
|
||||||
renderRecentRooms(s.recentRooms);
|
|
||||||
renderRelayButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Relay button ──
|
|
||||||
function renderRelayButton() {
|
|
||||||
const s = loadSettings();
|
|
||||||
const sel = s.relays[s.selectedRelay];
|
|
||||||
if (sel) {
|
|
||||||
const ls = lockStatus(sel);
|
|
||||||
relayDot.textContent = lockIcon(ls);
|
|
||||||
relayDot.className = "relay-lock";
|
|
||||||
relayLabel.textContent = `${sel.name} (${sel.address})`;
|
|
||||||
} else {
|
|
||||||
relayDot.textContent = "⚪";
|
|
||||||
relayDot.className = "relay-lock";
|
|
||||||
relayLabel.textContent = "No relay configured";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
relaySelected.addEventListener("click", () => openRelayDialog());
|
|
||||||
|
|
||||||
// ── Relay dialog ──
|
|
||||||
function openRelayDialog() {
|
|
||||||
renderRelayDialogList();
|
|
||||||
relayAddName.value = "";
|
|
||||||
relayAddAddr.value = "";
|
|
||||||
relayDialog.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRelayDialog() {
|
|
||||||
relayDialog.classList.add("hidden");
|
|
||||||
renderRelayButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRelayDialogList() {
|
|
||||||
const s = loadSettings();
|
|
||||||
relayDialogList.innerHTML = "";
|
|
||||||
s.relays.forEach((r, i) => {
|
|
||||||
const item = document.createElement("div");
|
|
||||||
item.className = `relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}`;
|
|
||||||
|
|
||||||
const ls = lockStatus(r);
|
|
||||||
const fp = r.serverFingerprint || r.address;
|
|
||||||
|
|
||||||
// Identicon
|
|
||||||
const icon = createIdenticonEl(fp, 32, true);
|
|
||||||
icon.title = r.serverFingerprint
|
|
||||||
? `Server: ${r.serverFingerprint}\nClick to copy`
|
|
||||||
: `No fingerprint yet`;
|
|
||||||
item.appendChild(icon);
|
|
||||||
|
|
||||||
// Info
|
|
||||||
const info = document.createElement("div");
|
|
||||||
info.className = "relay-info";
|
|
||||||
info.innerHTML = `
|
|
||||||
<div class="relay-name">${escapeHtml(r.name)}</div>
|
|
||||||
<div class="relay-addr">${escapeHtml(r.address)}</div>
|
|
||||||
`;
|
|
||||||
item.appendChild(info);
|
|
||||||
|
|
||||||
// Lock + RTT
|
|
||||||
const meta = document.createElement("div");
|
|
||||||
meta.className = "relay-meta";
|
|
||||||
const rttStr = r.rtt !== undefined && r.rtt !== null
|
|
||||||
? (r.rtt < 0 ? "offline" : `${r.rtt}ms`)
|
|
||||||
: "";
|
|
||||||
meta.innerHTML = `
|
|
||||||
<span class="relay-lock-icon" style="color:${lockColor(ls)}">${lockIcon(ls)}</span>
|
|
||||||
<span class="relay-rtt">${rttStr}</span>
|
|
||||||
`;
|
|
||||||
item.appendChild(meta);
|
|
||||||
|
|
||||||
// Delete button
|
|
||||||
const del = document.createElement("button");
|
|
||||||
del.className = "remove";
|
|
||||||
del.textContent = "×";
|
|
||||||
del.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const s = loadSettings();
|
|
||||||
s.relays.splice(i, 1);
|
|
||||||
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
|
|
||||||
saveSettingsObj(s);
|
|
||||||
renderRelayDialogList();
|
|
||||||
renderRelayButton();
|
|
||||||
});
|
|
||||||
item.appendChild(del);
|
|
||||||
|
|
||||||
// Click to select
|
|
||||||
item.addEventListener("click", () => {
|
|
||||||
const s = loadSettings();
|
|
||||||
s.selectedRelay = i;
|
|
||||||
|
|
||||||
// TOFU: if first time seeing this server, trust its fingerprint
|
|
||||||
if (r.serverFingerprint && !r.knownFingerprint) {
|
|
||||||
s.relays[i].knownFingerprint = r.serverFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSettingsObj(s);
|
|
||||||
renderRelayDialogList();
|
|
||||||
renderRelayButton();
|
|
||||||
});
|
|
||||||
|
|
||||||
relayDialogList.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
relayAddBtn.addEventListener("click", () => {
|
|
||||||
const name = relayAddName.value.trim();
|
|
||||||
const addr = relayAddAddr.value.trim();
|
|
||||||
if (!addr) return;
|
|
||||||
const s = loadSettings();
|
|
||||||
s.relays.push({ name: name || addr, address: addr });
|
|
||||||
saveSettingsObj(s);
|
|
||||||
relayAddName.value = "";
|
|
||||||
relayAddAddr.value = "";
|
|
||||||
renderRelayDialogList();
|
|
||||||
pingAllRelays();
|
|
||||||
});
|
|
||||||
|
|
||||||
relayDialogClose.addEventListener("click", closeRelayDialog);
|
|
||||||
relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); });
|
|
||||||
|
|
||||||
// ── Ping ──
|
|
||||||
interface PingResult { rtt_ms: number; server_fingerprint: string; }
|
|
||||||
|
|
||||||
async function pingAllRelays() {
|
|
||||||
const s = loadSettings();
|
|
||||||
for (let i = 0; i < s.relays.length; i++) {
|
|
||||||
const r = s.relays[i];
|
|
||||||
try {
|
|
||||||
const result: PingResult = await invoke("ping_relay", { relay: r.address });
|
|
||||||
r.rtt = result.rtt_ms;
|
|
||||||
r.serverFingerprint = result.server_fingerprint;
|
|
||||||
|
|
||||||
// TOFU: auto-save fingerprint on first contact
|
|
||||||
if (!r.knownFingerprint) {
|
|
||||||
r.knownFingerprint = result.server_fingerprint;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
r.rtt = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saveSettingsObj(s);
|
|
||||||
renderRelayButton();
|
|
||||||
if (!relayDialog.classList.contains("hidden")) renderRelayDialogList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recent rooms ──
|
|
||||||
function renderRecentRooms(rooms: RecentRoom[]) {
|
|
||||||
recentRoomsDiv.innerHTML = rooms
|
|
||||||
.map((r) => `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`)
|
|
||||||
.join("");
|
|
||||||
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
|
|
||||||
el.addEventListener("click", () => {
|
|
||||||
const ds = (el as HTMLElement).dataset;
|
|
||||||
roomInput.value = ds.room || "";
|
|
||||||
const s = loadSettings();
|
|
||||||
const idx = s.relays.findIndex((r) => r.address === ds.relay);
|
|
||||||
if (idx >= 0) { s.selectedRelay = idx; saveSettingsObj(s); renderRelayButton(); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Init ──
|
|
||||||
applySettings();
|
|
||||||
setTimeout(pingAllRelays, 300);
|
|
||||||
|
|
||||||
// Load fingerprint + render identicon
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const fp: string = await invoke("get_identity");
|
|
||||||
myFingerprint = fp;
|
|
||||||
myFingerprintEl.textContent = fp;
|
|
||||||
myFingerprintEl.style.cursor = "pointer";
|
|
||||||
myFingerprintEl.addEventListener("click", () => {
|
|
||||||
navigator.clipboard.writeText(fp).then(() => {
|
|
||||||
const orig = myFingerprintEl.textContent;
|
|
||||||
myFingerprintEl.textContent = "Copied!";
|
|
||||||
setTimeout(() => { myFingerprintEl.textContent = orig; }, 1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Identicon next to fingerprint
|
|
||||||
const icon = createIdenticonEl(fp, 28, true);
|
|
||||||
myIdenticonEl.innerHTML = "";
|
|
||||||
myIdenticonEl.appendChild(icon);
|
|
||||||
} catch {}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── Connect ──
|
|
||||||
connectBtn.addEventListener("click", doConnect);
|
|
||||||
[roomInput, aliasInput].forEach((el) =>
|
|
||||||
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
|
|
||||||
);
|
|
||||||
|
|
||||||
function showKeyWarning(oldFp: string, newFp: string): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
kwOldFp.textContent = oldFp;
|
|
||||||
kwNewFp.textContent = newFp;
|
|
||||||
keyWarning.classList.remove("hidden");
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
keyWarning.classList.add("hidden");
|
|
||||||
kwAccept.removeEventListener("click", onAccept);
|
|
||||||
kwCancel.removeEventListener("click", onCancel);
|
|
||||||
keyWarning.removeEventListener("click", onBackdrop);
|
|
||||||
};
|
|
||||||
const onAccept = () => { cleanup(); resolve(true); };
|
|
||||||
const onCancel = () => { cleanup(); resolve(false); };
|
|
||||||
const onBackdrop = (e: Event) => { if (e.target === keyWarning) { cleanup(); resolve(false); } };
|
|
||||||
|
|
||||||
kwAccept.addEventListener("click", onAccept);
|
|
||||||
kwCancel.addEventListener("click", onCancel);
|
|
||||||
keyWarning.addEventListener("click", onBackdrop);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doConnect() {
|
|
||||||
const relay = getSelectedRelay();
|
|
||||||
if (!relay) { connectError.textContent = "No relay selected"; return; }
|
|
||||||
|
|
||||||
// Warn on fingerprint mismatch
|
|
||||||
const ls = lockStatus(relay);
|
|
||||||
if (ls === "changed") {
|
|
||||||
const accepted = await showKeyWarning(relay.knownFingerprint || "", relay.serverFingerprint || "");
|
|
||||||
if (!accepted) return;
|
|
||||||
// User accepted — update known fingerprint
|
|
||||||
const s = loadSettings();
|
|
||||||
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
|
|
||||||
saveSettingsObj(s);
|
|
||||||
renderRelayButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't block connect on offline — ping may have failed transiently
|
|
||||||
|
|
||||||
connectError.textContent = "";
|
|
||||||
connectBtn.disabled = true;
|
|
||||||
connectBtn.textContent = "Connecting...";
|
|
||||||
userDisconnected = false;
|
|
||||||
|
|
||||||
const s = loadSettings();
|
|
||||||
s.room = roomInput.value; s.alias = aliasInput.value; s.osAec = osAecCheckbox.checked;
|
|
||||||
const room = roomInput.value.trim();
|
|
||||||
if (room) {
|
|
||||||
const entry: RecentRoom = { relay: relay.address, room };
|
|
||||||
s.recentRooms = [entry, ...s.recentRooms.filter((r) => !(r.relay === relay.address && r.room === room))].slice(0, 5);
|
|
||||||
}
|
|
||||||
saveSettingsObj(s);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invoke("connect", {
|
|
||||||
relay: relay.address, room: roomInput.value,
|
|
||||||
alias: aliasInput.value, osAec: osAecCheckbox.checked,
|
|
||||||
quality: s.quality || "auto",
|
|
||||||
});
|
|
||||||
showCallScreen();
|
|
||||||
} catch (e: any) {
|
|
||||||
connectError.textContent = String(e);
|
|
||||||
connectBtn.disabled = false;
|
|
||||||
connectBtn.textContent = "Connect";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCallScreen() {
|
|
||||||
connectScreen.classList.add("hidden");
|
|
||||||
callScreen.classList.remove("hidden");
|
|
||||||
roomName.textContent = roomInput.value;
|
|
||||||
callStatus.className = "status-dot";
|
|
||||||
statusInterval = window.setInterval(pollStatus, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showConnectScreen() {
|
|
||||||
callScreen.classList.add("hidden");
|
|
||||||
connectScreen.classList.remove("hidden");
|
|
||||||
connectBtn.disabled = false;
|
|
||||||
connectBtn.textContent = "Connect";
|
|
||||||
levelBar.style.width = "0%";
|
|
||||||
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mute / hangup ──
|
|
||||||
micBtn.addEventListener("click", async () => {
|
|
||||||
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
|
|
||||||
});
|
|
||||||
spkBtn.addEventListener("click", async () => {
|
|
||||||
try { const m: boolean = await invoke("toggle_speaker"); spkBtn.classList.toggle("muted", m); spkIcon.textContent = m ? "Spk Off" : "Spk"; } catch {}
|
|
||||||
});
|
|
||||||
hangupBtn.addEventListener("click", async () => {
|
|
||||||
userDisconnected = true;
|
|
||||||
try { await invoke("disconnect"); } catch {}
|
|
||||||
showConnectScreen();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (callScreen.classList.contains("hidden")) return;
|
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
|
||||||
if (e.key === "m") micBtn.click();
|
|
||||||
if (e.key === "s") spkBtn.click();
|
|
||||||
if (e.key === "q") hangupBtn.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Status polling ──
|
|
||||||
interface CallStatusI {
|
|
||||||
active: boolean; mic_muted: boolean; spk_muted: boolean;
|
|
||||||
participants: { fingerprint: string; alias: string | null }[];
|
|
||||||
encode_fps: number; recv_fps: number; audio_level: number;
|
|
||||||
call_duration_secs: number; fingerprint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(secs: number): string {
|
|
||||||
const m = Math.floor(secs / 60);
|
|
||||||
const s = Math.floor(secs % 60);
|
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
|
|
||||||
async function pollStatus() {
|
|
||||||
try {
|
|
||||||
const st: CallStatusI = await invoke("get_status");
|
|
||||||
if (!st.active) {
|
|
||||||
if (!userDisconnected && reconnectAttempts < 5) {
|
|
||||||
reconnectAttempts++;
|
|
||||||
callStatus.className = "status-dot reconnecting";
|
|
||||||
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/5)...`;
|
|
||||||
const relay = getSelectedRelay();
|
|
||||||
if (relay) {
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await invoke("connect", { relay: relay.address, room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked });
|
|
||||||
reconnectAttempts = 0; callStatus.className = "status-dot";
|
|
||||||
} catch {}
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reconnectAttempts = 0; showConnectScreen(); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
if (st.fingerprint) myFingerprint = st.fingerprint;
|
|
||||||
|
|
||||||
micBtn.classList.toggle("muted", st.mic_muted);
|
|
||||||
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
|
||||||
spkBtn.classList.toggle("muted", st.spk_muted);
|
|
||||||
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
|
|
||||||
callTimer.textContent = formatDuration(st.call_duration_secs);
|
|
||||||
|
|
||||||
const rms = st.audio_level;
|
|
||||||
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
|
||||||
levelBar.style.width = `${pct}%`;
|
|
||||||
|
|
||||||
// Participants grouped by relay
|
|
||||||
if (st.participants.length === 0) {
|
|
||||||
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
|
||||||
} else {
|
|
||||||
participantsDiv.innerHTML = "";
|
|
||||||
// Group by relay_label (null = this relay)
|
|
||||||
const groups: Record<string, typeof st.participants> = {};
|
|
||||||
st.participants.forEach((p: any) => {
|
|
||||||
const relay = p.relay_label || "This Relay";
|
|
||||||
if (!groups[relay]) groups[relay] = [];
|
|
||||||
groups[relay].push(p);
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.entries(groups).forEach(([relay, members]) => {
|
|
||||||
// Relay header
|
|
||||||
const header = document.createElement("div");
|
|
||||||
header.className = "relay-group-header";
|
|
||||||
const isLocal = relay === "This Relay";
|
|
||||||
header.innerHTML = `<span class="relay-dot-small ${isLocal ? "green" : "blue"}"></span> ${escapeHtml(relay)}`;
|
|
||||||
participantsDiv.appendChild(header);
|
|
||||||
|
|
||||||
// Participants under this relay
|
|
||||||
(members as any[]).forEach((p) => {
|
|
||||||
const name = p.alias || "Anonymous";
|
|
||||||
const fp = p.fingerprint || "";
|
|
||||||
const isMe = fp && myFingerprint.includes(fp);
|
|
||||||
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "participant";
|
|
||||||
|
|
||||||
const icon = createIdenticonEl(fp || name, 36, true);
|
|
||||||
if (isMe) icon.style.outline = "2px solid var(--accent)";
|
|
||||||
row.appendChild(icon);
|
|
||||||
|
|
||||||
const info = document.createElement("div");
|
|
||||||
info.className = "info";
|
|
||||||
info.innerHTML = `
|
|
||||||
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
|
||||||
<div class="fp">${escapeHtml(fp ? fp.substring(0, 16) : "")}</div>
|
|
||||||
`;
|
|
||||||
row.appendChild(info);
|
|
||||||
participantsDiv.appendChild(row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats line with codec badges
|
|
||||||
const txBadge = (st as any).tx_codec ? `<span class="codec-badge tx">${escapeHtml((st as any).tx_codec)}</span>` : "";
|
|
||||||
const rxBadge = (st as any).rx_codec ? `<span class="codec-badge rx">${escapeHtml((st as any).rx_codec)}</span>` : "";
|
|
||||||
statsDiv.innerHTML = `${txBadge} ${rxBadge} TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
listen("call-event", (event: any) => {
|
|
||||||
const { kind } = event.payload;
|
|
||||||
if (kind === "room-update") pollStatus();
|
|
||||||
if (kind === "disconnected" && !userDisconnected) pollStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Settings ──
|
|
||||||
function openSettings() {
|
|
||||||
const s = loadSettings();
|
|
||||||
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
|
|
||||||
const qi = qualityToIndex(s.quality || "auto");
|
|
||||||
sQuality.value = String(qi);
|
|
||||||
updateQualityUI(qi);
|
|
||||||
sFingerprint.textContent = myFingerprint || "(loading...)";
|
|
||||||
renderSettingsRecentRooms(s.recentRooms);
|
|
||||||
settingsPanel.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
function closeSettings() { settingsPanel.classList.add("hidden"); }
|
|
||||||
|
|
||||||
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
|
||||||
if (rooms.length === 0) {
|
|
||||||
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sRecentRooms.innerHTML = rooms.map((r, i) => `
|
|
||||||
<div class="recent-room-item">
|
|
||||||
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
|
||||||
<button class="remove" data-idx="${i}">×</button>
|
|
||||||
</div>`).join("");
|
|
||||||
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
|
|
||||||
const s = loadSettings();
|
|
||||||
s.recentRooms.splice(idx, 1);
|
|
||||||
saveSettingsObj(s);
|
|
||||||
renderSettingsRecentRooms(s.recentRooms);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsBtnHome.addEventListener("click", openSettings);
|
|
||||||
settingsBtnCall.addEventListener("click", openSettings);
|
|
||||||
settingsClose.addEventListener("click", closeSettings);
|
|
||||||
settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) closeSettings(); });
|
|
||||||
|
|
||||||
settingsSave.addEventListener("click", () => {
|
|
||||||
const s = loadSettings();
|
|
||||||
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
|
|
||||||
s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
|
|
||||||
saveSettingsObj(s);
|
|
||||||
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
|
|
||||||
renderRecentRooms(s.recentRooms);
|
|
||||||
closeSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
sClearRecent.addEventListener("click", () => {
|
|
||||||
const s = loadSettings();
|
|
||||||
s.recentRooms = [];
|
|
||||||
saveSettingsObj(s);
|
|
||||||
renderSettingsRecentRooms([]);
|
|
||||||
renderRecentRooms([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
|
|
||||||
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Direct Calling UI ──
|
|
||||||
const modeRoom = document.getElementById("mode-room")!;
|
|
||||||
const modeDirect = document.getElementById("mode-direct")!;
|
|
||||||
const roomModeDiv = document.getElementById("room-mode")!;
|
|
||||||
const directModeDiv = document.getElementById("direct-mode")!;
|
|
||||||
const registerBtn = document.getElementById("register-btn") as HTMLButtonElement;
|
|
||||||
const directRegistered = document.getElementById("direct-registered")!;
|
|
||||||
const incomingCallPanel = document.getElementById("incoming-call-panel")!;
|
|
||||||
const incomingCaller = document.getElementById("incoming-caller")!;
|
|
||||||
const acceptCallBtn = document.getElementById("accept-call-btn")!;
|
|
||||||
const rejectCallBtn = document.getElementById("reject-call-btn")!;
|
|
||||||
const targetFpInput = document.getElementById("target-fp") as HTMLInputElement;
|
|
||||||
const callBtn = document.getElementById("call-btn") as HTMLButtonElement;
|
|
||||||
const callStatusText = document.getElementById("call-status-text")!;
|
|
||||||
|
|
||||||
let currentCallMode = "room";
|
|
||||||
|
|
||||||
modeRoom.addEventListener("click", () => {
|
|
||||||
currentCallMode = "room";
|
|
||||||
modeRoom.classList.add("active");
|
|
||||||
modeDirect.classList.remove("active");
|
|
||||||
roomModeDiv.classList.remove("hidden");
|
|
||||||
directModeDiv.classList.add("hidden");
|
|
||||||
// Show room/alias inputs
|
|
||||||
(document.querySelector('label:has(#room)') as HTMLElement)?.classList.remove("hidden");
|
|
||||||
(document.querySelector('label:has(#alias)') as HTMLElement)?.classList.remove("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
modeDirect.addEventListener("click", () => {
|
|
||||||
currentCallMode = "direct";
|
|
||||||
modeDirect.classList.add("active");
|
|
||||||
modeRoom.classList.remove("active");
|
|
||||||
directModeDiv.classList.remove("hidden");
|
|
||||||
roomModeDiv.classList.add("hidden");
|
|
||||||
// Hide room input, keep alias
|
|
||||||
(document.querySelector('label:has(#room)') as HTMLElement)?.classList.add("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
registerBtn.addEventListener("click", async () => {
|
|
||||||
const relay = getSelectedRelay();
|
|
||||||
if (!relay) { connectError.textContent = "No relay selected"; return; }
|
|
||||||
registerBtn.disabled = true;
|
|
||||||
registerBtn.textContent = "Registering...";
|
|
||||||
try {
|
|
||||||
const fp = await invoke<string>("register_signal", { relay: relay.address });
|
|
||||||
registerBtn.classList.add("hidden");
|
|
||||||
directRegistered.classList.remove("hidden");
|
|
||||||
callStatusText.textContent = `Your fingerprint: ${fp}`;
|
|
||||||
} catch (e: any) {
|
|
||||||
connectError.textContent = String(e);
|
|
||||||
registerBtn.disabled = false;
|
|
||||||
registerBtn.textContent = "Register on Relay";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
callBtn.addEventListener("click", async () => {
|
|
||||||
const target = targetFpInput.value.trim();
|
|
||||||
if (!target) return;
|
|
||||||
callStatusText.textContent = "Calling...";
|
|
||||||
try {
|
|
||||||
await invoke("place_call", { targetFp: target });
|
|
||||||
} catch (e: any) {
|
|
||||||
callStatusText.textContent = `Error: ${e}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
acceptCallBtn.addEventListener("click", async () => {
|
|
||||||
const status = await invoke<any>("get_signal_status");
|
|
||||||
if (status.incoming_call_id) {
|
|
||||||
await invoke("answer_call", { callId: status.incoming_call_id, mode: 2 });
|
|
||||||
incomingCallPanel.classList.add("hidden");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rejectCallBtn.addEventListener("click", async () => {
|
|
||||||
const status = await invoke<any>("get_signal_status");
|
|
||||||
if (status.incoming_call_id) {
|
|
||||||
await invoke("answer_call", { callId: status.incoming_call_id, mode: 0 });
|
|
||||||
incomingCallPanel.classList.add("hidden");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for signal events from Rust backend
|
|
||||||
listen("signal-event", (event: any) => {
|
|
||||||
const data = event.payload;
|
|
||||||
switch (data.type) {
|
|
||||||
case "ringing":
|
|
||||||
callStatusText.textContent = "🔔 Ringing...";
|
|
||||||
break;
|
|
||||||
case "incoming":
|
|
||||||
incomingCallPanel.classList.remove("hidden");
|
|
||||||
incomingCaller.textContent = `From: ${data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown"}`;
|
|
||||||
break;
|
|
||||||
case "answered":
|
|
||||||
callStatusText.textContent = `Call answered (${data.mode})`;
|
|
||||||
break;
|
|
||||||
case "setup":
|
|
||||||
callStatusText.textContent = "Connecting to media...";
|
|
||||||
// Auto-connect to the call room
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await invoke("connect", {
|
|
||||||
relay: data.relay_addr,
|
|
||||||
room: data.room,
|
|
||||||
alias: aliasInput.value,
|
|
||||||
osAec: osAecCheckbox.checked,
|
|
||||||
quality: loadSettings().quality || "auto",
|
|
||||||
});
|
|
||||||
showCallScreen();
|
|
||||||
} catch (e: any) {
|
|
||||||
callStatusText.textContent = `Media connect failed: ${e}`;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
break;
|
|
||||||
case "hangup":
|
|
||||||
callStatusText.textContent = "";
|
|
||||||
incomingCallPanel.classList.add("hidden");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,892 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Relay group headers ── */
|
|
||||||
.relay-group-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding: 6px 0 2px;
|
|
||||||
border-top: 1px solid #ffffff08;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-group-header:first-child {
|
|
||||||
border-top: none;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-dot-small {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-dot-small.green { background: var(--green); }
|
|
||||||
.relay-dot-small.blue { background: #60a5fa; }
|
|
||||||
|
|
||||||
/* ── Codec badges ── */
|
|
||||||
.codec-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codec-badge.tx {
|
|
||||||
background: #22c55e30;
|
|
||||||
color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codec-badge.rx {
|
|
||||||
background: #3b82f630;
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Controls ── */
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--surface2);
|
|
||||||
color: var(--text);
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn:hover { background: var(--primary); }
|
|
||||||
|
|
||||||
.control-btn.muted {
|
|
||||||
background: var(--red);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.hangup {
|
|
||||||
background: var(--red);
|
|
||||||
color: white;
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.hangup:hover { opacity: 0.85; }
|
|
||||||
|
|
||||||
/* ── Stats ── */
|
|
||||||
.stats {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: monospace;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Icon button ── */
|
|
||||||
.icon-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 18px;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover { border-color: var(--accent); color: var(--text); }
|
|
||||||
.icon-btn.small { width: 28px; height: 28px; font-size: 14px; }
|
|
||||||
|
|
||||||
.call-header-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Settings panel ── */
|
|
||||||
#settings-panel {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 100;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 380px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-header h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section h3 {
|
|
||||||
font-size: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section input[type="text"] {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section input[type="text"]:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fp-display-large {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-rooms-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-room-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--surface);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-room-item .remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-room-item .remove:hover { color: var(--red); }
|
|
||||||
|
|
||||||
.secondary-btn {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-btn:hover { border-color: var(--accent); color: var(--text); }
|
|
||||||
|
|
||||||
/* ── Key warning dialog ── */
|
|
||||||
#key-warning {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 300;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-card {
|
|
||||||
max-width: 360px;
|
|
||||||
text-align: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
color: var(--yellow);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-card h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-fps {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--surface);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-fp-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-fp-label {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-fp {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
word-break: break-all;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-actions .primary {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--yellow);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-warning-actions .secondary-btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Quality slider ── */
|
|
||||||
.quality-control {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-slider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text);
|
|
||||||
border: 2px solid var(--bg);
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-slider::-webkit-slider-thumb:hover {
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-ticks {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-dim);
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form select {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 15px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form select:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section select {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-section select:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Direct calling mode toggle */
|
|
||||||
.mode-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--surface2);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--dim);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.mode-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.mode-btn:hover:not(.active) {
|
|
||||||
background: var(--surface2);
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -625,3 +625,123 @@ curl -s http://relay-host:9090/metrics | grep wzp_relay_active_sessions
|
|||||||
# Check federation probe health
|
# Check federation probe health
|
||||||
curl -s http://relay-host:9090/metrics | grep wzp_probe_up
|
curl -s http://relay-host:9090/metrics | grep wzp_probe_up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Build Pipelines
|
||||||
|
|
||||||
|
All production artifacts (Android APK, Linux x86_64 binaries, Windows `.exe`) are built on **SepehrHomeserverdk** using Docker, not on developer workstations. The pipelines are fire-and-forget: a local script invokes a `tmux` session on the remote, the build runs in a Docker container, and the artifact is uploaded to `paste.dk.manko.yoga` (rustypaste) with a notification sent to `ntfy.sh/wzp` on start and completion.
|
||||||
|
|
||||||
|
### Docker images
|
||||||
|
|
||||||
|
Two long-lived images live on the remote:
|
||||||
|
|
||||||
|
| Image | Used by | Base | Key contents |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `wzp-android-builder` | Android APK (Tauri mobile + legacy Kotlin), Linux x86_64 relay/CLI | Debian bookworm | Rust stable with Android targets, cargo-ndk, NDK 26.1, Android SDK (API 34 + 35 + 36), JDK 17, Gradle 8.5, Node.js 20, cmake, ninja, tauri-cli 2.x |
|
||||||
|
| `wzp-windows-builder` | Windows x86_64 `.exe` | Debian bookworm | Rust stable with `x86_64-pc-windows-msvc` target, cargo-xwin (with pre-warmed MSVC CRT + Windows SDK cache), Node.js 20, cmake, ninja, clang, lld, nasm |
|
||||||
|
|
||||||
|
Both images are rebuilt rarely — once the base toolchain is stable, rebuilds are only needed to pick up new dependencies or security patches.
|
||||||
|
|
||||||
|
**Rebuilding an image** (fire-and-forget, ~10 min on a warm base):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
./scripts/build-windows-docker.sh --image-build
|
||||||
|
|
||||||
|
# Android (upload and rebuild handled by the Android build script itself — see
|
||||||
|
# its --image-build flag or equivalent)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--image-build` flag uploads the local Dockerfile to the remote, kicks off `docker build` under `nohup`, and returns immediately. Monitor with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh SepehrHomeserverdk 'tail -f /tmp/wzp-windows-image-build.log'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipeline: Android APK (Tauri Mobile)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-tauri-android.sh # Full: pull + build + upload + notify
|
||||||
|
./scripts/build-tauri-android.sh --no-pull # Skip git fetch
|
||||||
|
./scripts/build-tauri-android.sh --clean # Force-clean Rust target
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Branch**: `android-rewrite`
|
||||||
|
- **Image**: `wzp-android-builder`
|
||||||
|
- **Build command**: `cargo tauri android build --release`
|
||||||
|
- **Output**: `wzp-release.apk` → uploaded to rustypaste
|
||||||
|
- **Notifications**: start + completion to `ntfy.sh/wzp`
|
||||||
|
- **Remote artifact path**: `/mnt/storage/manBuilder/data/cache-android/target/…/release/app-release.apk`
|
||||||
|
|
||||||
|
### Pipeline: Linux x86_64 (relay + CLI + bench + web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-linux-docker.sh # Fire-and-forget
|
||||||
|
./scripts/build-linux-docker.sh --no-pull # Skip git fetch
|
||||||
|
./scripts/build-linux-docker.sh --clean # Force-clean target
|
||||||
|
./scripts/build-linux-docker.sh --install # Wait for completion and download locally
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Branch**: `feat/android-voip-client` (script default — override by editing the script or passing an env var)
|
||||||
|
- **Image**: `wzp-android-builder` (shared, not a separate Linux-only image)
|
||||||
|
- **Targets built**: `wzp-relay`, `wzp-client`, `wzp-client-audio` (with `--features audio`), `wzp-web`, `wzp-bench`
|
||||||
|
- **Output**: `wzp-linux-x86_64.tar.gz` with all five binaries → uploaded to rustypaste
|
||||||
|
- **Local landing dir** (with `--install`): `target/linux-x86_64/`
|
||||||
|
|
||||||
|
### Pipeline: Windows x86_64 (`wzp-desktop.exe`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-windows-docker.sh # Full: pull + build + download locally
|
||||||
|
./scripts/build-windows-docker.sh --no-pull # Skip git fetch
|
||||||
|
./scripts/build-windows-docker.sh --rust # Force-clean target-windows cache
|
||||||
|
./scripts/build-windows-docker.sh --image-build # Rebuild the Docker image (fire-and-forget)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Branch**: `feat/desktop-audio-rewrite`
|
||||||
|
- **Image**: `wzp-windows-builder`
|
||||||
|
- **Build command**: `cargo xwin build --release --target x86_64-pc-windows-msvc --bin wzp-desktop`
|
||||||
|
- **Output**: `wzp-desktop.exe` (~16 MB) → downloaded to `target/windows-exe/wzp-desktop.exe`, also uploaded to rustypaste
|
||||||
|
- **Target cache volume**: `target-windows` (separate from the Android target cache to avoid triple cross-contamination)
|
||||||
|
- **Shared cache volumes**: `cargo-registry`, `cargo-git` (shared with Android — both pipelines pull the same crates)
|
||||||
|
|
||||||
|
**A/B-preserving workflow** for testing audio backends: rename the prior `.exe` before re-running the build, so both coexist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preserve prior build as the noAEC baseline
|
||||||
|
mv target/windows-exe/wzp-desktop.exe target/windows-exe/wzp-desktop-noAEC.exe
|
||||||
|
./scripts/build-windows-docker.sh
|
||||||
|
ls -la target/windows-exe/
|
||||||
|
# wzp-desktop-noAEC.exe (previous build)
|
||||||
|
# wzp-desktop.exe (new build)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative pipeline: Windows via Hetzner Cloud VPS
|
||||||
|
|
||||||
|
For situations where Docker image rebuilds would be disruptive, or for one-shot debug builds on a clean machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-windows-cloud.sh # Full: create VM → build → download → destroy
|
||||||
|
./scripts/build-windows-cloud.sh --prepare # Create VM + install deps, don't build
|
||||||
|
./scripts/build-windows-cloud.sh --build # Build on existing VM
|
||||||
|
./scripts/build-windows-cloud.sh --transfer # Download .exe from existing VM
|
||||||
|
./scripts/build-windows-cloud.sh --destroy # Delete the VM
|
||||||
|
WZP_KEEP_VM=1 ./scripts/build-windows-cloud.sh # Don't auto-destroy after successful build
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Provider**: Hetzner Cloud
|
||||||
|
- **Default server type**: `cx33` (8 GB RAM, 8 vCPU — `cx23` with 4 GB OOMs on the tauri+rustls cross-compile)
|
||||||
|
- **Image**: `ubuntu-24.04`
|
||||||
|
- **SSH key**: must be named `wz` in Hetzner and loaded in the local ssh-agent
|
||||||
|
- **Reminder**: set `WZP_KEEP_VM=1` for multi-build sessions, then **remember to `--destroy` at end of day** so the VM isn't left running overnight. This is tracked in the auto-memory as `feedback_keep_windows_builder_vm.md`.
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
All pipelines post to `https://ntfy.sh/wzp`. Subscribe from your phone via the [ntfy.sh app](https://ntfy.sh/) to get push notifications on build start/success/failure. Messages include the short git hash and the rustypaste URL on success:
|
||||||
|
|
||||||
|
```
|
||||||
|
WZP Windows build OK [03a80a3] (16M)
|
||||||
|
https://paste.dk.manko.yoga/<uuid>/wzp-desktop.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rustypaste credentials
|
||||||
|
|
||||||
|
Build pipelines read `rusty_address` and `rusty_auth_token` from the `.env` file at `/mnt/storage/manBuilder/.env` on SepehrHomeserverdk. Local scripts that upload directly (`build-windows-cloud.sh` when run in `--transfer` mode) read from `~/.wzp/rustypaste.env` with the same variable names. Both files must be kept in sync manually if rotated.
|
||||||
|
|||||||
@@ -872,3 +872,71 @@ warzonePhone/
|
|||||||
| wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking |
|
| wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking |
|
||||||
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
|
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
|
||||||
| wzp-web | 2 | Metrics |
|
| wzp-web | 2 | Metrics |
|
||||||
|
|
||||||
|
## Audio Backend Architecture (Platform Matrix)
|
||||||
|
|
||||||
|
WarzonePhone's audio I/O goes through one of four backends depending on the target platform and feature flags. All backends expose the same public API (`AudioCapture::start() → AudioCapture { ring(), stop() }`) via conditional re-exports in `crates/wzp-client/src/lib.rs`, so the `CallEngine` above the audio layer doesn't know or care which backend is running.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ CallEngine (platform-agnostic) │
|
||||||
|
│ reads PCM from AudioCapture::ring() │
|
||||||
|
│ writes PCM to AudioPlayback::ring() │
|
||||||
|
└────────────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼─────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────────┐ ┌────────────────┐ ┌───────────────┐
|
||||||
|
│ audio_io │ │ audio_vpio │ │ audio_wasapi │
|
||||||
|
│ (CPAL) │ │ (Core Audio │ │ (Windows │
|
||||||
|
│ │ │ VoiceProc IO) │ │ IAudioClient2│
|
||||||
|
│ All platforms │ │ macOS only │ │ Windows │
|
||||||
|
│ (baseline) │ │ feature=vpio │ │ feature= │
|
||||||
|
│ │ │ │ │ windows-aec │
|
||||||
|
└───────────────┘ └────────────────┘ └───────────────┘
|
||||||
|
│
|
||||||
|
▼ on Android only
|
||||||
|
┌───────────────┐
|
||||||
|
│ wzp-native │
|
||||||
|
│ (Oboe bridge │
|
||||||
|
│ via dlopen) │
|
||||||
|
│ │
|
||||||
|
│ Android only │
|
||||||
|
│ libloading │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend selection matrix
|
||||||
|
|
||||||
|
| Platform | Capture | Playback | OS AEC | Feature flags |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| macOS | VoiceProcessingIO (native Core Audio) | CPAL | **Yes** — Apple's hardware-accelerated AEC (same AEC as FaceTime, iMessage audio, Voice Memos) | `audio`, `vpio` |
|
||||||
|
| Windows (AEC build) | Direct WASAPI with `AudioCategory_Communications` | CPAL | **Yes** — Windows routes the capture stream through the driver's communications APO chain (AEC + NS + AGC), driver-dependent quality | `audio`, `windows-aec` |
|
||||||
|
| Windows (baseline) | CPAL (WASAPI shared mode) | CPAL | No | `audio` |
|
||||||
|
| Linux | CPAL (ALSA / PulseAudio) | CPAL | No | `audio` |
|
||||||
|
| Android (Tauri Mobile) | Oboe via `wzp-native` cdylib, `Usage::VoiceCommunication` + `MODE_IN_COMMUNICATION` | Same Oboe stream | Depends on device (some Android devices apply AEC to the voice-communication stream, most do not) | none (`wzp-client` compiled with `default-features = false`) |
|
||||||
|
|
||||||
|
### Why `wzp-native` is a standalone cdylib
|
||||||
|
|
||||||
|
On Android, the audio backend lives in a separate cdylib crate (`crates/wzp-native`) that `wzp-desktop`'s lib crate loads at runtime via `libloading`. It is **not** linked as a regular Rust dep.
|
||||||
|
|
||||||
|
This is deliberate. rust-lang/rust#104707 documents that a crate with `crate-type = ["cdylib", "staticlib"]` leaks non-exported symbols from the staticlib into the cdylib. On Android, that caused Bionic's private `__init_tcb` / `pthread_create` symbols to be bound LOCALLY inside our `.so` instead of resolved dynamically against `libc.so` at `dlopen` time — which crashed the app at launch as soon as `tao` tried to `std::thread::spawn()` from the JNI `onCreate` callback.
|
||||||
|
|
||||||
|
Keeping `wzp-native` in its own cdylib and loading it via `libloading` means:
|
||||||
|
|
||||||
|
1. The app's own `.so` has `crate-type = ["cdylib", "rlib"]` only — no `staticlib`, no symbol leak.
|
||||||
|
2. `libwzp_native.so` is loaded via `System.loadLibrary` from the JVM side (or `dlopen` from Rust), which triggers the normal Bionic resolver and binds all private symbols against `libc.so` at load time.
|
||||||
|
3. The C/C++ Oboe bridge is fully isolated inside `libwzp_native.so`'s symbol space — no chance of its archives leaking into `wzp-desktop`'s `.so`.
|
||||||
|
|
||||||
|
See `docs/BRANCH-android-rewrite.md` for the full incident postmortem and `docs/incident-tauri-android-init-tcb.md` for the debug log.
|
||||||
|
|
||||||
|
### Vendored `audiopus_sys` for libopus / clang-cl cross-compile
|
||||||
|
|
||||||
|
The workspace root carries a vendored copy of `audiopus_sys` at `vendor/audiopus_sys/` with a patched `opus/CMakeLists.txt`. This is needed because libopus 1.3.1 gates its per-file `-msse4.1` / `-mssse3` `COMPILE_FLAGS` behind `if(NOT MSVC)`, and under `clang-cl` (used by `cargo-xwin` for Windows cross-compiles) CMake sets `MSVC=1` unconditionally — so the SIMD source files compile without the required target feature and fail to link the intrinsic `always_inline` functions.
|
||||||
|
|
||||||
|
The patch introduces an `MSVC_CL` variable that is true only for real `cl.exe` (distinguished via `CMAKE_C_COMPILER_ID STREQUAL "MSVC"`), and flips the eight `if(NOT MSVC)` SIMD guards to `if(NOT MSVC_CL)` so clang-cl gets the GCC-style per-file flags. Wired in via `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` at the workspace root.
|
||||||
|
|
||||||
|
This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream.
|
||||||
|
|
||||||
|
Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale).
|
||||||
|
|||||||
139
docs/BRANCH-android-rewrite.md
Normal file
139
docs/BRANCH-android-rewrite.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Branch: `android-rewrite`
|
||||||
|
|
||||||
|
Pivot away from the legacy Kotlin + JNI Android client to a pure-Rust **Tauri 2.x Mobile** app that shares the same frontend and backend code as the desktop client.
|
||||||
|
|
||||||
|
## Why this branch exists
|
||||||
|
|
||||||
|
The Kotlin + JNI stack was a crash factory. Every failure mode we hit was at the Kotlin ↔ Rust boundary, and each fix uncovered the next layer of the onion:
|
||||||
|
|
||||||
|
| Symptom | Root cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| App crashed on launch before `onCreate` returned | `__init_tcb` / `pthread_create` bionic private symbols leaking out of `libwzp_android.so` because the Rust crate used `crate-type = ["cdylib", "staticlib"]`. rust-lang/rust#104707 documents that staticlib alongside cdylib leaks non-exported symbols from the staticlib into the cdylib, and Bionic's private internal pthread symbols got bound LOCALLY inside our `.so` instead of resolved against `libc.so` at `dlopen` time | Dropped `staticlib` from the crate-type list. `crate-type = ["cdylib", "rlib"]` only. |
|
||||||
|
| Stack overflow on `place_call` | `Dispatchers.IO` threads have a ~512 KB stack, too small for the Rust signal-connect path that does TLS handshake + quinn setup inside one closure | Launched JNI calls from a dedicated `java.lang.Thread` with an explicit 8 MB stack |
|
||||||
|
| `ring` / `libcrypto` TLS reuse crash on second call | tokio runtime got dropped between calls, but `ring` keeps a TLS-stored SSL context that is invalidated when the runtime thread is reused by a new runtime — `ring` sees stale context and segfaults | Single long-lived tokio runtime for the entire signal client lifetime; split `start()` into an inline `connect+register` path and a `run()` path on a separate thread to avoid the `thread::spawn` closure's stack overflow |
|
||||||
|
| Null dereference on register with fresh install | Identity seed file empty when it existed-but-was-blank, Rust side deref'd the zero-length slice | Generate seed if empty on register |
|
||||||
|
|
||||||
|
Every fix kept the app limping along but the fundamental design problem remained: **state management was split across a Kotlin ViewModel and a Rust engine, with a hand-rolled JNI bridge in between that had to be perfect to not crash**. The working desktop Tauri client (with the same Rust backend) had none of these problems because it spoke to the Rust code via in-process `invoke()` from a WebView, not JNI.
|
||||||
|
|
||||||
|
So: rewrite the Android app as a **Tauri 2.x Mobile app**, reusing the entire desktop codebase verbatim (`main.ts`, `style.css`, `index.html`, `main.rs`, `engine.rs` — everything). Tauri Mobile added Android support in v2, it's production-ready, and it eliminates the JNI boundary entirely.
|
||||||
|
|
||||||
|
The incident postmortem lives at [`docs/incident-tauri-android-init-tcb.md`](incident-tauri-android-init-tcb.md).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Tauri 2.x Mobile │
|
||||||
|
│ │
|
||||||
|
│ Android WebView ────────── HTML/JS/CSS │ ← Shared with desktop
|
||||||
|
│ │ (main.ts) │
|
||||||
|
│ │ │
|
||||||
|
│ invoke() ─────────────── Rust Commands │ ← Shared with desktop
|
||||||
|
│ (main.rs) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────┼────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ SignalMgr CallEngine Identity │ ← Shared crates
|
||||||
|
│ (signal_hub) (wzp-client) (wzp-crypto)│
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ QUIC to relay Oboe audio (Android) │
|
||||||
|
│ via wzp-native cdylib │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**What is reused from desktop verbatim** (zero rewrite):
|
||||||
|
|
||||||
|
- `desktop/src/main.ts` — entire frontend
|
||||||
|
- `desktop/src/style.css` — all styling
|
||||||
|
- `desktop/src/identicon.ts` — identicon rendering
|
||||||
|
- `desktop/index.html` — HTML structure
|
||||||
|
- `desktop/src-tauri/src/main.rs` — all Tauri commands (`connect`, `disconnect`, `register_signal`, `place_call`, …)
|
||||||
|
- `desktop/src-tauri/src/engine.rs` — `CallEngine` wrapper
|
||||||
|
|
||||||
|
**What is Android-specific**:
|
||||||
|
|
||||||
|
- `desktop/src-tauri/src/android_audio.rs` — JVM-side audio routing (`AudioManager.setSpeakerphoneOn` for earpiece/speaker toggle). Runs from Tauri's existing JNI context — no hand-rolled bridge, Tauri owns the JVM hookup.
|
||||||
|
- `desktop/src-tauri/src/wzp_native.rs` — runtime `dlopen` of `libwzp_native.so`, a standalone cdylib crate (`crates/wzp-native`) that owns all C++ (Oboe bridge). Kept in its own crate so its C/C++ static archives never get statically linked into `wzp-desktop`'s `.so`, which would re-trigger the `__init_tcb` / pthread leak.
|
||||||
|
- `crates/wzp-native/` — the standalone C++/Oboe bridge cdylib. Loaded via `libloading` at runtime from `wzp_native.rs`. Provides capture + playout streams using Oboe's `Usage::VoiceCommunication` + `MODE_IN_COMMUNICATION` combo.
|
||||||
|
- Android-specific target dependencies in `desktop/src-tauri/Cargo.toml` (`jni`, `ndk-context`, `libloading`) — no CPAL, no VPIO.
|
||||||
|
|
||||||
|
## Key architectural decisions
|
||||||
|
|
||||||
|
### 1. `wzp-native` as a standalone cdylib loaded via `libloading`
|
||||||
|
|
||||||
|
The alternative — linking `wzp-native` as a regular Rust dep with C++ static archives — would cause the same `__init_tcb` crash that killed the Kotlin version. By making `wzp-native` its own cdylib and `dlopen`-ing it at runtime, Bionic's `libc.so` resolves every symbol at load time the way it's supposed to, and no private TCB symbols leak.
|
||||||
|
|
||||||
|
### 2. `crate-type = ["cdylib", "rlib"]` only (no `staticlib`)
|
||||||
|
|
||||||
|
Same reason. The `rlib` output is needed so the `wzp-desktop` binary target can link against the library; `cdylib` is needed for Android's `System.loadLibrary`; `staticlib` would reintroduce the symbol-leak bug.
|
||||||
|
|
||||||
|
### 3. Oboe audio config
|
||||||
|
|
||||||
|
`Usage::VoiceCommunication` + Java-side `MODE_IN_COMMUNICATION`. **Never** call `setAudioApi(AAudio)` explicitly — on some devices (Nothing Phone in particular) it causes Oboe to open the wrong stream type and audio goes silent. Let Oboe pick the audio API automatically. This is documented in the auto-memory `project_tauri_android_audio.md`.
|
||||||
|
|
||||||
|
### 4. Speaker/earpiece toggle uses `tokio::task::spawn_blocking`
|
||||||
|
|
||||||
|
Oboe's `stop()` + `start()` cycle is synchronous and can block for 50–200 ms. Calling it on the tokio executor stalls every other async task (including the QUIC datagram loop), dropping audio packets. Wrapping the toggle in `spawn_blocking` isolates it to a dedicated thread pool. Fixed in commit `76a4c53`.
|
||||||
|
|
||||||
|
## Build pipeline
|
||||||
|
|
||||||
|
Docker on SepehrHomeserverdk, same pattern as the Android legacy pipeline and the Windows pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/build-tauri-android.sh # Full: pull + build + ntfy + rustypaste
|
||||||
|
./scripts/build-tauri-android.sh --pull # Explicit git pull (default)
|
||||||
|
./scripts/build-tauri-android.sh --clean # Blow away the Rust target cache
|
||||||
|
```
|
||||||
|
|
||||||
|
**Image**: `wzp-android-builder` (shared with the legacy Kotlin pipeline). The Dockerfile was extended to install Node.js 20 LTS, Android API level 36, build-tools 35.0.0, tauri-cli 2.x, and all four Android Rust targets on top of the legacy NDK 26.1 + cargo-ndk + Gradle setup. Both pipelines coexist in the same image.
|
||||||
|
|
||||||
|
**Output**: `wzp-release.apk` uploaded to rustypaste, URL delivered via `ntfy.sh/wzp`.
|
||||||
|
|
||||||
|
## Known quirks (Tauri Mobile specific)
|
||||||
|
|
||||||
|
1. **tauri-cli `android init` writes absolute paths** into `gradle.properties` for the NDK path. Those paths are local to wherever `android init` was run, so they break any cross-machine build unless overridden with `ANDROID_NDK_HOME` at build time. The build script exports `ANDROID_NDK_HOME` explicitly to work around this.
|
||||||
|
|
||||||
|
2. **API 36 vs API 34 coexistence**: the legacy Kotlin pipeline targets API 34, Tauri Mobile 2.x wants compileSdk 36. The shared Docker image installs both SDK levels so neither pipeline needs to reinstall.
|
||||||
|
|
||||||
|
3. **Identity seed lives in Android-specific app data dir**: `/data/data/com.wzp.phone/files/.wzp/identity` instead of `$HOME/.wzp/identity`. The shared `load_or_create_seed()` function in `desktop/src-tauri/src/lib.rs` uses Tauri's `app_data_dir()` which resolves correctly on both Android and desktop — no per-platform code needed.
|
||||||
|
|
||||||
|
4. **Direct calls on macOS previously hit an identity mismatch bug** — the `CallEngine` was using `$HOME/.wzp/identity` directly while `register_signal` used Tauri's `app_data_dir()`. Fixed by routing both through `load_or_create_seed()` (commit `2fd9465`). This was important for cross-platform consistency.
|
||||||
|
|
||||||
|
## Current state (snapshot)
|
||||||
|
|
||||||
|
What works:
|
||||||
|
|
||||||
|
- Tauri Mobile scaffold builds and runs on Android
|
||||||
|
- Signal hub connect + register works
|
||||||
|
- Room mode (SFU group calls) works with Oboe audio
|
||||||
|
- Direct 1:1 calls work with full parity to desktop
|
||||||
|
- Speaker/earpiece toggle works without stalling the audio pipeline
|
||||||
|
- Call history, recent contacts, deregister UI all present (inherited from desktop)
|
||||||
|
|
||||||
|
What remains (task list refs in parens):
|
||||||
|
|
||||||
|
- Background service for keeping signal alive when app is backgrounded (#19)
|
||||||
|
- Proper permission requests (microphone, notifications) on first launch (#19)
|
||||||
|
- Incoming call notification while backgrounded (#19)
|
||||||
|
- App icon + splash screen (#19)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Build**: `./scripts/build-tauri-android.sh` — verify the APK lands on rustypaste and installs on device.
|
||||||
|
- **Smoke test**: Install → open app → Register → Place call → Receive call. No crashes, audio flows both ways.
|
||||||
|
- **Speaker toggle**: During a call, toggle speaker/earpiece several times in rapid succession. Audio should never stop, and the toggle should respond within ~200 ms.
|
||||||
|
- **Stress test**: Call for 10+ minutes continuous. No memory growth, no packet loss beyond what's attributable to the network.
|
||||||
|
|
||||||
|
## Files of interest
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `desktop/src-tauri/src/lib.rs` | Shared Tauri commands (desktop + Android) |
|
||||||
|
| `desktop/src-tauri/src/android_audio.rs` | JVM-side speaker/earpiece routing |
|
||||||
|
| `desktop/src-tauri/src/wzp_native.rs` | Runtime dlopen of libwzp_native.so |
|
||||||
|
| `crates/wzp-native/` | Standalone C++/Oboe cdylib, loaded at runtime |
|
||||||
|
| `scripts/build-tauri-android.sh` | Remote Docker build pipeline |
|
||||||
|
| `scripts/Dockerfile.android-builder` | Shared Android Docker image (legacy + Tauri) |
|
||||||
|
| `docs/incident-tauri-android-init-tcb.md` | Postmortem of the Kotlin+JNI crash cascade |
|
||||||
22
docs/PRD-desktop-direct-calling.md
Normal file
22
docs/PRD-desktop-direct-calling.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# PRD: Desktop Direct Calling — Backport SignalManager
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The desktop Tauri app has the direct calling UI (Room/Direct Call toggle, Register, Call buttons) but the backend uses inline async code in `main.rs` instead of a proper `SignalManager`. This needs to be backported from the Android refactor.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. **Create `signal_mgr.rs` for desktop** — same pattern as Android, or reuse the crate directly
|
||||||
|
2. **Wire into Tauri commands** — `register_signal` should use `SignalManager::connect()` + `run_recv_loop()` on a dedicated thread
|
||||||
|
3. **State polling** — `get_signal_status` should call `SignalManager::get_state_json()`
|
||||||
|
4. **place_call / answer_call** — delegate to SignalManager methods
|
||||||
|
5. **Merge android branch into desktop branch** — resolve the 37 desktop-only + 90 android-only commit divergence
|
||||||
|
6. **Test** — Android calls Desktop, Desktop calls Android
|
||||||
|
|
||||||
|
## UI Fixes
|
||||||
|
|
||||||
|
1. **Default alias** — generate random name on first start (like Android does)
|
||||||
|
2. **Default room** — change from "android" to "general"
|
||||||
|
3. **Fingerprint display** — ensure full `xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx` format (not truncated)
|
||||||
|
4. **Deregister button** — ability to disconnect signal channel
|
||||||
|
5. **Call state reset** — after hangup, return to "Registered" state, not stuck on "Ringing"
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# PRD: Local Recording + Cloud Mixer for Podcast-Quality Interviews
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
WarzonePhone delivers real-time encrypted voice, but the audio quality is limited by network conditions (codec compression, packet loss, jitter). Podcasters and interviewers need pristine, studio-grade recordings of each participant — independent of what the network delivers.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
**Dual-path architecture**: each client simultaneously (1) participates in the live call at whatever codec quality the network supports, and (2) records their own microphone locally as lossless PCM. After the session, all local recordings are uploaded to a self-hosted mixer service that aligns, normalizes, and outputs a final multi-track or mixed file.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐
|
|
||||||
Mic ──┬── Opus/Codec2 ──► Network (live) │ ← real-time call
|
|
||||||
│ └──────────────────┘
|
|
||||||
│
|
|
||||||
└── WAV 48kHz ────► Local File │ ← pristine recording
|
|
||||||
(timestamped)
|
|
||||||
│
|
|
||||||
▼ (after hangup)
|
|
||||||
┌──────────────────┐
|
|
||||||
│ Mixer Service │ ← self-hosted
|
|
||||||
│ (align + mix) │
|
|
||||||
└──────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Final MP3/WAV/FLAC
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Phase 1: Local Recording (MVP)
|
|
||||||
|
|
||||||
**All clients (Desktop, Android, Web):**
|
|
||||||
|
|
||||||
1. **Record toggle**: User can enable "Record this call" before or during a call
|
|
||||||
2. **Recording pipeline**: Tap raw PCM from the microphone capture path *before* it enters the codec encoder
|
|
||||||
3. **File format**: WAV (48kHz, 16-bit, mono) — simple, universally supported, lossless
|
|
||||||
4. **Sync markers**: Embed a monotonic timestamp (ms since call start) at the beginning of the recording, and periodically (every 10s) write a sync marker packet into a sidecar JSON file:
|
|
||||||
```json
|
|
||||||
{"ts_ms": 30000, "seq": 1500, "wall_clock_utc": "2026-04-07T12:00:30Z"}
|
|
||||||
```
|
|
||||||
This allows the mixer to align recordings from different participants even if they join at different times.
|
|
||||||
5. **Storage**:
|
|
||||||
- Desktop: `~/.wzp/recordings/{room}_{timestamp}.wav`
|
|
||||||
- Android: `Documents/WarzonePhone/{room}_{timestamp}.wav`
|
|
||||||
- Web: IndexedDB blob or File System Access API
|
|
||||||
6. **File size estimate**: 48kHz * 16-bit * mono = 96 KB/s = ~5.6 MB/min = ~345 MB/hour
|
|
||||||
7. **UI indicator**: Red dot + timer showing recording is active and file size growing
|
|
||||||
8. **On hangup**: Close the WAV file, show "Recording saved" with file path/size
|
|
||||||
|
|
||||||
### Phase 2: Upload to Mixer
|
|
||||||
|
|
||||||
1. **Upload endpoint**: Self-hosted HTTP service (Rust or Go) that accepts WAV uploads with metadata
|
|
||||||
2. **Chunked/resumable upload**: Large files need resumable uploads (tus protocol or simple chunked POST)
|
|
||||||
3. **Upload metadata**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"session_id": "uuid",
|
|
||||||
"participant_fingerprint": "xxxx:xxxx:...",
|
|
||||||
"alias": "Alice",
|
|
||||||
"room": "podcast-ep-42",
|
|
||||||
"duration_secs": 3600,
|
|
||||||
"sync_markers": [...],
|
|
||||||
"sample_rate": 48000,
|
|
||||||
"channels": 1,
|
|
||||||
"bit_depth": 16
|
|
||||||
}
|
|
||||||
```
|
|
||||||
4. **Upload UI**: Progress bar after hangup, option to upload now or later
|
|
||||||
5. **Retry on failure**: Queue uploads for retry if network is unavailable
|
|
||||||
|
|
||||||
### Phase 3: Mixer Service
|
|
||||||
|
|
||||||
1. **Alignment**: Use sync markers (wall clock + sequence numbers) to align recordings from all participants to a common timeline
|
|
||||||
2. **Silence trimming**: Detect and optionally trim leading/trailing silence
|
|
||||||
3. **Normalization**: Per-track loudness normalization (LUFS-based)
|
|
||||||
4. **Noise reduction**: Optional per-track noise gate or RNNoise pass
|
|
||||||
5. **Output formats**:
|
|
||||||
- Multi-track: ZIP of individual WAVs (aligned, normalized)
|
|
||||||
- Mixed: Single stereo or mono WAV/MP3/FLAC with all participants
|
|
||||||
- Podcast-ready: Loudness-normalized to -16 LUFS (podcast standard)
|
|
||||||
6. **Web UI**: Simple dashboard to see sessions, download outputs, preview waveforms
|
|
||||||
7. **Self-hosted**: Docker image, single binary, SQLite for metadata
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### Recording tap point
|
|
||||||
|
|
||||||
The recording must tap *after* AGC (so levels are normalized) but *before* the codec encoder (to avoid compression artifacts). In the current architecture:
|
|
||||||
|
|
||||||
```
|
|
||||||
Mic → Ring Buffer → AGC → [TAP HERE for recording] → Opus/Codec2 → Network
|
|
||||||
```
|
|
||||||
|
|
||||||
**Desktop** (`engine.rs`): After `capture_agc.process_frame()`, before `encoder.encode()`
|
|
||||||
**Android** (`engine.rs`): Same location — after AGC, before encode
|
|
||||||
**CLI** (`call.rs`): After `self.agc.process_frame()` in `CallEncoder::encode_frame()`
|
|
||||||
|
|
||||||
### WAV writer
|
|
||||||
|
|
||||||
Use a simple streaming WAV writer that:
|
|
||||||
- Writes the WAV header with placeholder data length
|
|
||||||
- Appends PCM samples as they come
|
|
||||||
- On close, seeks back to update the data length in the header
|
|
||||||
|
|
||||||
### Sync mechanism
|
|
||||||
|
|
||||||
Wall-clock UTC alone is insufficient (clocks drift). The sync strategy:
|
|
||||||
1. Each participant records their local monotonic time + wall clock at call start
|
|
||||||
2. Periodically (every 10s), each participant writes: `{local_mono_ms, seq_number, utc_iso}`
|
|
||||||
3. The mixer uses sequence numbers (which are shared via the wire protocol) as ground truth for alignment, with wall clock as a fallback
|
|
||||||
|
|
||||||
### Privacy
|
|
||||||
|
|
||||||
- Local recordings never leave the device without explicit user action
|
|
||||||
- Upload is manual, not automatic
|
|
||||||
- The mixer service processes files and can delete originals after mixing
|
|
||||||
- No recording data flows through the relay — only the user's own mic
|
|
||||||
|
|
||||||
## Non-Goals (v1)
|
|
||||||
|
|
||||||
- Live transcription (future)
|
|
||||||
- Video recording (audio only)
|
|
||||||
- Automatic upload without user consent
|
|
||||||
- Recording other participants' audio (only your own mic)
|
|
||||||
- Real-time mixing (post-session only)
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
| Phase | Scope | Effort |
|
|
||||||
|-------|-------|--------|
|
|
||||||
| 1a | Local WAV recording on Desktop | 1-2 days |
|
|
||||||
| 1b | Local WAV recording on Android | 1-2 days |
|
|
||||||
| 1c | Sync markers + metadata sidecar | 1 day |
|
|
||||||
| 2a | Upload service (HTTP + storage) | 2-3 days |
|
|
||||||
| 2b | Upload UI in clients | 1-2 days |
|
|
||||||
| 3a | Mixer: alignment + normalization | 2-3 days |
|
|
||||||
| 3b | Mixer: web dashboard | 2-3 days |
|
|
||||||
| 3c | Docker packaging | 1 day |
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# PRD: Studio Quality Tiers (Opus 32k/48k/64k)
|
|
||||||
|
|
||||||
## Status: Implemented
|
|
||||||
|
|
||||||
Studio quality tiers have been added to the wire protocol and all clients.
|
|
||||||
|
|
||||||
## What Was Added
|
|
||||||
|
|
||||||
### Wire Protocol (codec_id.rs)
|
|
||||||
|
|
||||||
Three new `CodecId` variants using the 4-bit header space (values 6-8):
|
|
||||||
|
|
||||||
| CodecId | Wire Value | Bitrate | Frame | Use Case |
|
|
||||||
|---------|-----------|---------|-------|----------|
|
|
||||||
| Opus32k | 6 | 32 kbps | 20ms | Studio low — noticeable improvement over 24k for voice |
|
|
||||||
| Opus48k | 7 | 48 kbps | 20ms | Studio — excellent voice, captures nuance |
|
|
||||||
| Opus64k | 8 | 64 kbps | 20ms | Studio high — near-transparent quality |
|
|
||||||
|
|
||||||
### Quality Profiles
|
|
||||||
|
|
||||||
| Profile | Codec | FEC | Bandwidth (with FEC) |
|
|
||||||
|---------|-------|-----|---------------------|
|
|
||||||
| STUDIO_32K | Opus 32k | 10% | ~35 kbps |
|
|
||||||
| STUDIO_48K | Opus 48k | 10% | ~53 kbps |
|
|
||||||
| STUDIO_64K | Opus 64k | 10% | ~70 kbps |
|
|
||||||
|
|
||||||
FEC is set to 10% (vs 20% for GOOD) — studio assumes a good network.
|
|
||||||
|
|
||||||
### Client Support
|
|
||||||
|
|
||||||
| Client | Selection | Status |
|
|
||||||
|--------|-----------|--------|
|
|
||||||
| Desktop (Tauri) | Quality slider in Settings (8 levels) | Done |
|
|
||||||
| CLI | `--profile studio-64k` / `studio-48k` / `studio-32k` | Done |
|
|
||||||
| Android | Needs codec picker update in SettingsScreen.kt | TODO |
|
|
||||||
| Web | Needs UI | TODO |
|
|
||||||
|
|
||||||
### Cross-Codec Interop
|
|
||||||
|
|
||||||
All decoder auto-switch paths (call.rs, desktop engine.rs) handle the new codec IDs. A studio-64k client can talk to a codec2-1200 client — the receiver auto-switches.
|
|
||||||
|
|
||||||
## When to Use Studio Tiers
|
|
||||||
|
|
||||||
- **Podcast recording sessions**: Use studio-64k for best quality (combined with local WAV recording for pristine output)
|
|
||||||
- **Music collaboration**: Opus at 48-64k captures instrument harmonics much better than 24k
|
|
||||||
- **Good network conditions**: Only useful when bandwidth isn't constrained; the extra bits are wasted on lossy networks
|
|
||||||
|
|
||||||
## When NOT to Use
|
|
||||||
|
|
||||||
- **Mobile data**: Stick with Auto/GOOD — studio tiers use 2-3x the bandwidth
|
|
||||||
- **High packet loss**: Studio profiles use minimal FEC (10%); degraded networks need DEGRADED or CATASTROPHIC profiles with 50-100% FEC
|
|
||||||
- **Large group calls**: Each participant's stream multiplies bandwidth; 64k * 10 participants = 640 kbps incoming
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
Old clients (before this change) will receive packets with CodecId 6/7/8 which they don't recognize. The `from_wire()` returns `None` for unknown values, causing the packet to be dropped. Old clients can still *send* to new clients fine (they use CodecId 0-5). This is acceptable for a pre-release protocol.
|
|
||||||
@@ -457,3 +457,52 @@ Auto mode uses three tiers (Good, Degraded, Catastrophic). It does not use the S
|
|||||||
When you select a specific profile (not Auto), adaptive switching is disabled. The encoder stays at the selected profile regardless of network conditions. This is useful when you know your network quality and want consistent encoding, or when you want to force a specific bitrate.
|
When you select a specific profile (not Auto), adaptive switching is disabled. The encoder stays at the selected profile regardless of network conditions. This is useful when you know your network quality and want consistent encoding, or when you want to force a specific bitrate.
|
||||||
|
|
||||||
Note: The decoder always accepts all codecs. A manual quality selection only affects what you send, not what you receive.
|
Note: The decoder always accepts all codecs. A manual quality selection only affects what you send, not what you receive.
|
||||||
|
|
||||||
|
## Direct 1:1 Calling (Desktop + Android)
|
||||||
|
|
||||||
|
In addition to room-mode group calls, you can place direct calls to a specific peer by fingerprint. Direct calls bypass room state entirely — the relay is used purely as a signaling gateway and for media relay. There is no need for the callee to join a room beforehand; they just need to be registered with the same signal hub.
|
||||||
|
|
||||||
|
### UI elements in the direct-call panel
|
||||||
|
|
||||||
|
- **Place call field** — paste a fingerprint (the long hex string you see under your own identity) and click Call. The callee sees a ringing UI.
|
||||||
|
- **Recent contacts row** — a horizontal strip of chips showing your most recently called/receiving peers. Click a chip to re-dial. Aliases are shown if the peer has one, otherwise a short fingerprint prefix.
|
||||||
|
- **Call history list** — every direct call you've placed, received, or missed, with direction indicator (↗ Outgoing, ↙ Incoming, ✗ Missed), the peer's alias (if known) or fingerprint prefix, and a timestamp. Click an entry to re-dial.
|
||||||
|
- **Deregister button** — drops your signal-hub registration without quitting the app. Useful when switching identities (e.g. testing with two accounts on one machine) or when you want to explicitly appear offline to peers.
|
||||||
|
- **Clear history button** — wipes the call history store. Does not affect current calls.
|
||||||
|
|
||||||
|
### Live updates
|
||||||
|
|
||||||
|
The call history updates in real time across all views via Tauri events (`history-changed`). Placing, answering, or missing a call immediately refreshes the history list and the recent contacts row — no manual refresh needed.
|
||||||
|
|
||||||
|
### Default room
|
||||||
|
|
||||||
|
On first launch, the room name in the room-mode panel defaults to `general` (changed from the prior `android` default so the desktop and Android clients don't silently talk past each other). You can still change it to any room name, and the last-used room is remembered across launches.
|
||||||
|
|
||||||
|
### Random alias
|
||||||
|
|
||||||
|
New installations derive a human-friendly alias from your identity seed — something like `silent-forest-41` or `bold-river-07`. It's deterministic, so reinstalling without changing your seed gives you the same alias. The alias is shown alongside your fingerprint in the header and is what peers see in their call history when they receive your call.
|
||||||
|
|
||||||
|
You can override the alias in Settings → Identity if you want a specific name.
|
||||||
|
|
||||||
|
## Windows AEC Variants
|
||||||
|
|
||||||
|
The Windows desktop build ships in two variants for echo cancellation, depending on which backend you want to exercise. Both are `wzp-desktop.exe` binaries — only the internal audio backend differs.
|
||||||
|
|
||||||
|
| Build | File | Capture backend | AEC | When to use |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **noAEC baseline** | `wzp-desktop-noAEC.exe` | CPAL (WASAPI shared mode) | None | Headphone-only use, or for A/B comparison against the AEC build |
|
||||||
|
| **Communications AEC** | `wzp-desktop.exe` | Direct WASAPI with `AudioCategory_Communications` | **Yes** — Windows routes the capture stream through the driver's communications APO chain (AEC + noise suppression + automatic gain control) | Any speaker-mode call, laptop built-in speakers, anywhere echo is audible |
|
||||||
|
|
||||||
|
**Quality caveat**: the communications AEC operates at the OS level and its algorithm depends on the audio driver's installed APO chain. On modern consumer laptops with Intel Smart Sound, Dolby, recent Realtek, or Windows 11 Voice Clarity, the quality is excellent (effectively matching what Teams/Zoom deliver). On generic class-compliant USB microphones or older drivers, the communications APO may not be present at all — in that case the build behaves identically to the noAEC baseline.
|
||||||
|
|
||||||
|
If you hear echo on the AEC build, try these in order before escalating:
|
||||||
|
|
||||||
|
1. **Check which capture device is selected as "Default Device - Communications"** in Windows Sound Settings → Recording tab. Right-click any device to set it. The AEC build opens the device marked as `eCommunications`, not `eConsole`, so changing the default-communications device changes what we capture from.
|
||||||
|
2. **Verify the driver exposes a communications APO**. Sound Settings → Recording → your mic → Properties → Advanced → look for an "Enhancements" or "Signal Enhancements" tab. If it's absent, the driver has no APOs and the AEC build effectively has no AEC.
|
||||||
|
3. **Try the classic Voice Capture DSP build** when it ships (tracked as task #26). That uses Microsoft's bundled software AEC (`CLSID_CWMAudioAEC`) which works on every Windows machine regardless of driver.
|
||||||
|
|
||||||
|
### Installing the Windows builds
|
||||||
|
|
||||||
|
1. Windows 10: install the [WebView2 Runtime Evergreen Bootstrapper](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) first. Windows 11 has it pre-installed.
|
||||||
|
2. Copy `wzp-desktop.exe` (or `wzp-desktop-noAEC.exe`) to any directory and double-click. No installer needed.
|
||||||
|
3. First launch creates the config + identity store at `%APPDATA%\com.wzp.phone\`.
|
||||||
|
|||||||
431
docs/incident-tauri-android-init-tcb.md
Normal file
431
docs/incident-tauri-android-init-tcb.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# Incident report — Tauri Android `__init_tcb+4` SIGSEGV
|
||||||
|
|
||||||
|
**Status:** Blocked. Reproducible crash with a known trigger at the cc::Build /
|
||||||
|
rustc-link-lib layer that we cannot yet explain. Writing this report to hand
|
||||||
|
off for external help.
|
||||||
|
|
||||||
|
**Project:** WarzonePhone (Rust + Tauri 2.x Mobile) Android rewrite
|
||||||
|
**Branch:** `feat/desktop-audio-rewrite`
|
||||||
|
**Target phone:** Pixel 6 (`oriole`), Android 16 (`BP3A.250905.014`), arm64-v8a
|
||||||
|
**Date range of investigation:** 2026-04-09 (one working session, ~27 builds)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-paragraph summary
|
||||||
|
|
||||||
|
We're porting the existing CPAL-backed desktop Tauri app (`desktop/src-tauri`)
|
||||||
|
to Tauri Mobile Android so the same Rust + Tauri + WebView codebase runs on
|
||||||
|
both platforms. The Android `.apk` launches, renders the home screen, and
|
||||||
|
registers on a relay for signal-only builds (no audio backend). The moment
|
||||||
|
we add **any** `cc::Build::new().cpp(true).cpp_link_stdlib("c++_shared")`
|
||||||
|
call to `build.rs` — even with a 6-line cpp file that just returns 42 and is
|
||||||
|
never called from Rust — the built `.so` crashes at launch inside
|
||||||
|
`__init_tcb(bionic_tcb*, pthread_internal_t*)+4` via `pthread_create` via
|
||||||
|
`std::thread::spawn` via `tao::ndk_glue::create` via
|
||||||
|
`Java_com_wzp_desktop_WryActivity_create`, before our Rust entry point has
|
||||||
|
a chance to run. The exact same NDK, exact same Rust toolchain, exact same
|
||||||
|
Docker image is used by the legacy `wzp-android` crate (via `cargo-ndk`)
|
||||||
|
which compiles Oboe and runs fine on the same phone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
**Docker build image:** `wzp-android-builder` (Dockerfile at
|
||||||
|
`scripts/Dockerfile.android-builder`)
|
||||||
|
|
||||||
|
- Base: `debian:bookworm`
|
||||||
|
- JDK 17
|
||||||
|
- Android SDK:
|
||||||
|
- cmdline-tools latest
|
||||||
|
- `platforms;android-34`, `platforms;android-36`
|
||||||
|
- `build-tools;34.0.0`, `build-tools;35.0.0`
|
||||||
|
- `ndk;26.1.10909125` (last stable before scudo/MTE crash on NDK r27+)
|
||||||
|
- `platform-tools`
|
||||||
|
- Node.js 20 LTS
|
||||||
|
- Rust stable `1.94.1 (e408947bf 2026-03-25)`
|
||||||
|
- Rust android targets: `aarch64-linux-android`, `armv7-linux-androideabi`,
|
||||||
|
`i686-linux-android`, `x86_64-linux-android`
|
||||||
|
- `cargo-ndk` + `cargo tauri-cli 2.10.1` (latest 2.x)
|
||||||
|
|
||||||
|
**Host:** Docker on `SepehrHomeserverdk` (remote build server).
|
||||||
|
|
||||||
|
**Phone:** Pixel 6, Android 16, kernel 6.1.134-android14-11, on the same LAN
|
||||||
|
as the build machine and a local `wzp-relay` binary.
|
||||||
|
|
||||||
|
**Tauri crate:** `desktop/src-tauri/` in the workspace at the root of the
|
||||||
|
repo. Depends on `tauri = "2"`, `tauri-plugin-shell = "2"`, `tokio`, `rustls`,
|
||||||
|
`wzp-proto`, `wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`, and (on
|
||||||
|
non-Android only) `wzp-client` with `features = ["audio", "vpio"]`. The
|
||||||
|
crate's `[lib]` section is:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[lib]
|
||||||
|
name = "wzp_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The crate produces `libwzp_desktop_lib.so` which is `System.loadLibrary`'d by
|
||||||
|
Tauri's generated `WryActivity.onCreate` via JNI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The crash
|
||||||
|
|
||||||
|
Every failing build produces the same stack at launch, same pc offsets:
|
||||||
|
|
||||||
|
```
|
||||||
|
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00000072XXXXXX00f (write)
|
||||||
|
|
||||||
|
#00 pc 000000000130cc74 libwzp_desktop_lib.so (__init_tcb(bionic_tcb*, pthread_internal_t*)+4)
|
||||||
|
#01 pc 0000000001331cf0 libwzp_desktop_lib.so (pthread_create+360)
|
||||||
|
#02 pc 00000000012bee04 libwzp_desktop_lib.so (std::sys::thread::unix::Thread::new::h87be8e9feeaaaf84+184)
|
||||||
|
#03 pc 0000000000e37f5c libwzp_desktop_lib.so (std::thread::lifecycle::spawn_unchecked::h941f828f9a95150d+1504)
|
||||||
|
#04 pc 0000000000e461e8 libwzp_desktop_lib.so (std::thread::builder::Builder::spawn_unchecked::hec5f087680cb0248+112)
|
||||||
|
#05 pc 0000000000e441c8 libwzp_desktop_lib.so (std::thread::functions::spawn::ha3d3fbf2d9fe53e3+108)
|
||||||
|
#06 pc ... libwzp_desktop_lib.so (tao::platform_impl::platform::ndk_glue::create::h254c68662718841a+1792)
|
||||||
|
#07 pc ... libwzp_desktop_lib.so (Java_com_wzp_desktop_WryActivity_create+76)
|
||||||
|
```
|
||||||
|
|
||||||
|
The offsets are **byte-identical across every failing build**, even when the
|
||||||
|
cpp content changes drastically (cf. `cpp_smoke.cpp` at 6 lines, 20 lines,
|
||||||
|
200+ Oboe source files). We believe this is because cargo caches the Rust
|
||||||
|
compilation unit and only the build-script artifacts differ, and the final
|
||||||
|
link produces the same layout.
|
||||||
|
|
||||||
|
`__init_tcb` is defined locally inside our `.so` with C++ mangling:
|
||||||
|
|
||||||
|
```
|
||||||
|
_Z10__init_tcbP10bionic_tcbP18pthread_internal_t
|
||||||
|
```
|
||||||
|
|
||||||
|
It originates from bionic's `pthread_create.cpp`, which got pulled in
|
||||||
|
statically from the NDK's `sysroot/usr/lib/aarch64-linux-android/libc.a`.
|
||||||
|
Both failing and known-good (legacy `wzp_android.so`) builds contain this
|
||||||
|
same static symbol — the presence of the symbol is not the problem.
|
||||||
|
|
||||||
|
Fault address `0x72XXXXXX00f` with code `SEGV_ACCERR` (access permission
|
||||||
|
error, write). Aligned to `+4` inside `__init_tcb`, which is typically a
|
||||||
|
store into the passed-in `bionic_tcb*`. The pointer is either NULL-ish or
|
||||||
|
pointing into read-only memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bisection (the important part)
|
||||||
|
|
||||||
|
We started from a known-good commit (`5309938`) where the Tauri Android app
|
||||||
|
launches, registers on a relay, and behaves identically to the desktop app
|
||||||
|
modulo audio. Then we added features **one variable at a time**:
|
||||||
|
|
||||||
|
| Step | Commit | Change vs previous | Result |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Baseline | `5309938` | — | ✅ launches, renders home, registers on relay |
|
||||||
|
| **A** | `f96d7ce` | Add `cc = "1"` build-dep + compile trivial `cpp/hello.c` via `cc::Build` (C, not C++). Static lib never linked in. | ✅ |
|
||||||
|
| **B** | `ae4f366` | Add `wzp-client` Android dep with `default-features = false` (no CPAL, no VPIO). No new imports. | ✅ |
|
||||||
|
| **C** | `19fd3dd` | Un-cfg-gate `mod engine;` in `lib.rs` so `engine.rs` compiles on Android. `CallEngine::start()` has an Android stub returning an error. | ✅ |
|
||||||
|
| **D** | `a852cad` | Compile `cpp/getauxval_fix.c` (legacy wzp-android shim). Still pure C. | ✅ |
|
||||||
|
| **E** | `4250f1b` | **Compile full Oboe C++ bridge** (200+ source files from `google/oboe@1.8.1`). `cc::Build::new().cpp(true).std("c++17").cpp_link_stdlib(Some("c++_shared"))` + `-llog` + `-lOpenSLES` link directives. Nothing called from Rust yet — the `extern "C"` bridge functions are exported but never referenced from the Rust side. | ❌ **crash** |
|
||||||
|
| E.4 | `aa240c6` | **Only change:** replace the entire Oboe compile with ONE tiny `cpp_smoke.cpp` file: `extern "C" int wzp_cpp_smoke(void) { std::lock_guard<std::mutex> lk(m); std::thread t([](){...}); t.join(); return g.load(); }`. Still `cpp(true) + cpp_link_stdlib("c++_shared")`. Drop `-llog`/`-lOpenSLES`. | ❌ **same crash, same offsets** |
|
||||||
|
| E.2 | `0224ce6` | Shrink `cpp_smoke.cpp` further: just `std::atomic<int>` + `fetch_add`, no mutex, no thread, no includes beyond `<atomic>`. | ❌ **same crash, same offsets** |
|
||||||
|
| E.1 | `0d74366` | **Absolute minimum:** `cpp_smoke.cpp` = `extern "C" int wzp_cpp_hello(void){return 42;}`. NO `#include`. NO STL. Just a function. Still compiled with `cpp(true) + cpp_link_stdlib("c++_shared")`. | ❌ **same crash, same offsets** |
|
||||||
|
|
||||||
|
### Additional confirming observations
|
||||||
|
|
||||||
|
1. **The cpp code is dead-stripped.** `llvm-nm -a libwzp_desktop_lib.so` shows
|
||||||
|
zero matches for `wzp_cpp_hello`, `wzp_cpp_smoke`, or any Oboe symbol in
|
||||||
|
builds E through E.1. The static archive (`libwzp_cpp_smoke.a` /
|
||||||
|
`liboboe_bridge.a`) exists on disk under
|
||||||
|
`target/aarch64-linux-android/debug/build/wzp-desktop-*/out/`, but because
|
||||||
|
nothing in Rust ever references the exported C function, the final linker
|
||||||
|
drops it.
|
||||||
|
|
||||||
|
2. **`build.rs` link directives are the real delta.** `cc::Build::new()
|
||||||
|
.cpp(true).cpp_link_stdlib(Some("c++_shared"))` emits a
|
||||||
|
`cargo:rustc-link-lib=c++_shared` directive that adds a `NEEDED` entry for
|
||||||
|
`libc++_shared.so` to the final `.so`'s dynamic table. `readelf -d` on
|
||||||
|
the crashing `.so` shows:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEEDED Shared library: [libc++_shared.so]
|
||||||
|
NEEDED Shared library: [liblog.so] (only in full Oboe build)
|
||||||
|
NEEDED Shared library: [libOpenSLES.so] (only in full Oboe build)
|
||||||
|
```
|
||||||
|
|
||||||
|
The working baseline `.so` has no `NEEDED` entries beyond libc/liblog.
|
||||||
|
|
||||||
|
3. **Linker version doesn't matter.** We tried forcing
|
||||||
|
`aarch64-linux-android26-clang` as the linker (API 26 has proper dynamic
|
||||||
|
bindings to libc.so's runtime `pthread_create`/`__init_tcb`) via three
|
||||||
|
different mechanisms:
|
||||||
|
- `CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER` env var in `docker run`
|
||||||
|
- `.cargo/config.toml` workspace-level linker override
|
||||||
|
- **Binary replacement inside the image**: `mv
|
||||||
|
aarch64-linux-android24-clang .orig` and replace with a shell script
|
||||||
|
that `exec`s `aarch64-linux-android26-clang`. Verified by calling
|
||||||
|
`--version` which prints `Target: aarch64-unknown-linux-android26`.
|
||||||
|
|
||||||
|
All three made no difference. The `__init_tcb` symbol is pulled statically
|
||||||
|
from the **same** `libc.a` regardless of which clang wrapper is used — the
|
||||||
|
NDK ships ONE `libc.a` at
|
||||||
|
`sysroot/usr/lib/aarch64-linux-android/libc.a` shared across all API
|
||||||
|
levels. Only the per-API `libc.so` symlinks change (and we're linked
|
||||||
|
statically, not dynamically, against libc).
|
||||||
|
|
||||||
|
4. **Legacy `wzp-android` crate works on the same phone, same image.** Run
|
||||||
|
in the exact same Docker container, the legacy Kotlin app's JNI library
|
||||||
|
(`crates/wzp-android` built via `cargo ndk`) compiles a subset of the
|
||||||
|
same Oboe code, produces a `.so` that has the same static
|
||||||
|
`_Z10__init_tcbP...` + `pthread_create` + `pthread_create.cpp` symbols,
|
||||||
|
and launches cleanly on the Pixel 6. Key differences between the two
|
||||||
|
build paths:
|
||||||
|
|
||||||
|
| | `wzp-android` (works) | `wzp-desktop` Tauri (crashes) |
|
||||||
|
|---|---|---|
|
||||||
|
| Build driver | `cargo ndk -t arm64-v8a build --release -p wzp-android` | `cargo tauri android build --debug --target aarch64 --apk` |
|
||||||
|
| Profile | release | debug (release crashes identically) |
|
||||||
|
| Linker | `aarch64-linux-android26-clang` (via `.cargo/config.toml` which cargo-ndk honors) | `aarch64-linux-android24-clang` (tauri-cli hardcodes and ignores config; the shim redirect makes no difference) |
|
||||||
|
| crate-type | `["cdylib", "rlib"]` | `["staticlib", "cdylib", "rlib"]` |
|
||||||
|
| JNI entrypoint | direct Kotlin `System.loadLibrary` + our own `native fun` declarations; first `pthread_create` runs later from the tokio runtime inside a command | `WryActivity.onCreate` via Tauri's generated Java glue; first `pthread_create` runs **inside the JNI call** via `tao::ndk_glue::create` |
|
||||||
|
| Other heavy deps | tokio, wzp-{proto,codec,fec,crypto,transport} | tokio, tauri, tauri-runtime-wry, tao, wry, webview2-com, soup3, webkit2gtk (all platform-specific ones cfg-gated out of android), and also all of the above |
|
||||||
|
| Binary size | `libwzp_android.so` ≈ 14 MB (release) | `libwzp_desktop_lib.so` ≈ 160 MB (debug), 16 MB (release) |
|
||||||
|
|
||||||
|
5. **The crash happens in the JNI-callback thread during `onCreate`.** Frame
|
||||||
|
#06 `tao::platform_impl::platform::ndk_glue::create+1792` is tao's Android
|
||||||
|
event-loop bootstrap, which Tauri calls from inside
|
||||||
|
`Java_com_wzp_desktop_WryActivity_create` in response to the Java-side
|
||||||
|
activity lifecycle. This means the thread spawn is happening while the
|
||||||
|
Java VM still holds the native onCreate call, before `onCreate` has
|
||||||
|
returned to the Android runtime. Legacy `wzp-android` never spawns a
|
||||||
|
thread from an onCreate JNI call — it spawns threads only from
|
||||||
|
`nativeSignalConnect`/similar commands invoked later from Kotlin button
|
||||||
|
clicks, after the activity is fully initialised.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current suspect
|
||||||
|
|
||||||
|
One of the two items below, probably (2):
|
||||||
|
|
||||||
|
1. **The `.cpp(true)` mode in cc-rs changes something invisible in the link
|
||||||
|
pipeline** (for example, emitting a different `-x` flag to clang, or
|
||||||
|
changing linker driver selection). We have not yet verified this by
|
||||||
|
diffing the actual rustc linker invocation between a working and a
|
||||||
|
crashing build with `--verbose` + `-Clink-arg=-Wl,-t`.
|
||||||
|
|
||||||
|
2. **Adding `libc++_shared.so` as a NEEDED entry causes Android's dynamic
|
||||||
|
linker to load libc++_shared.so before our `.so`'s init runs, and
|
||||||
|
something in libc++_shared's `.init_array` interacts badly with
|
||||||
|
tao::ndk_glue's `pthread_create` call from inside the JNI onCreate
|
||||||
|
window**. The legacy crate doesn't hit this because (a) it has no
|
||||||
|
NEEDED libc++_shared when built without Oboe, and (b) even when it does
|
||||||
|
build Oboe, its thread spawns happen outside the onCreate JNI call so
|
||||||
|
whatever libc state is wrong at that moment is already stabilised.
|
||||||
|
|
||||||
|
We have not yet confirmed (2) with the obvious A/B test: keep `cpp_smoke.cpp`
|
||||||
|
but drop `.cpp_link_stdlib(Some("c++_shared"))` (and drop any manual
|
||||||
|
`cargo:rustc-link-lib=c++_shared`) so the NEEDED entry disappears but the
|
||||||
|
rest of the pipeline stays identical. That's the next experiment we were
|
||||||
|
going to run, but the user reasonably asked for this report first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What we've ruled out
|
||||||
|
|
||||||
|
- **NDK API level** — forcing API-26 linker via three independent mechanisms
|
||||||
|
made zero difference.
|
||||||
|
- **Build profile** — release (`0x6b8000` offset, 21 MB unsigned APK) and
|
||||||
|
debug (same 193 MB APK, same crash offsets) both crash identically.
|
||||||
|
- **Oboe specifically** — replacing the Oboe compile with 6 lines of C++
|
||||||
|
that does nothing still reproduces the crash.
|
||||||
|
- **cpp code being executed at runtime** — dead-stripped, not in the final
|
||||||
|
`.so` at all per `nm -a`.
|
||||||
|
- **minSdk in build.gradle** — bumped from 24 to 26, no effect.
|
||||||
|
- **libdl.a stub issue** — ruled out via logcat (`libdl.a is a stub --- use
|
||||||
|
libdl.so instead` was only surfacing from our own `dlsym` shim that we
|
||||||
|
subsequently deleted).
|
||||||
|
- **`pthread_create` interposition via `-Wl,--wrap=pthread_create`** — tried
|
||||||
|
and reverted; the wrap target still resolved to the broken static stub.
|
||||||
|
- **Keystore / signing** — debug signing with persistent `~/.android/
|
||||||
|
debug.keystore` works fine; no signature mismatch issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The files involved
|
||||||
|
|
||||||
|
### `desktop/src-tauri/build.rs` (current state, E.1)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Embedded git hash
|
||||||
|
let git_hash = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".into());
|
||||||
|
println!("cargo:rustc-env=WZP_GIT_HASH={git_hash}");
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=../../.git/refs/heads");
|
||||||
|
|
||||||
|
let target = std::env::var("TARGET").unwrap_or_default();
|
||||||
|
if target.contains("android") {
|
||||||
|
// Step A: plain C sanity file
|
||||||
|
println!("cargo:rerun-if-changed=cpp/hello.c");
|
||||||
|
cc::Build::new().file("cpp/hello.c").compile("wzp_hello");
|
||||||
|
|
||||||
|
// Step D: legacy getauxval shim
|
||||||
|
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
|
||||||
|
cc::Build::new().file("cpp/getauxval_fix.c").compile("getauxval_fix");
|
||||||
|
|
||||||
|
// Step E.1: minimal C++ smoke — THIS STEP BRINGS BACK THE CRASH
|
||||||
|
println!("cargo:rerun-if-changed=cpp/cpp_smoke.cpp");
|
||||||
|
cc::Build::new()
|
||||||
|
.cpp(true)
|
||||||
|
.std("c++17")
|
||||||
|
.cpp_link_stdlib(Some("c++_shared"))
|
||||||
|
.file("cpp/cpp_smoke.cpp")
|
||||||
|
.compile("wzp_cpp_smoke");
|
||||||
|
|
||||||
|
// Copy libc++_shared.so into gen/android jniLibs so the runtime
|
||||||
|
// linker can find it when the NEEDED entry fires.
|
||||||
|
if let Ok(ndk) = std::env::var("ANDROID_NDK_HOME").or_else(|_| std::env::var("NDK_HOME")) {
|
||||||
|
let triple = "aarch64-linux-android";
|
||||||
|
let abi = "arm64-v8a";
|
||||||
|
let lib_dir = format!(
|
||||||
|
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{triple}"
|
||||||
|
);
|
||||||
|
println!("cargo:rustc-link-search=native={lib_dir}");
|
||||||
|
let shared_so = format!("{lib_dir}/libc++_shared.so");
|
||||||
|
if std::path::Path::new(&shared_so).exists() {
|
||||||
|
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||||
|
let jni_dir = format!("{manifest}/gen/android/app/src/main/jniLibs/{abi}");
|
||||||
|
if std::fs::create_dir_all(&jni_dir).is_ok() {
|
||||||
|
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `desktop/src-tauri/cpp/cpp_smoke.cpp` (E.1)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
extern "C" int wzp_cpp_hello(void) {
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `desktop/src-tauri/Cargo.toml` (relevant excerpts)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "wzp-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "wzp_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "wzp-desktop"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
cc = "1"
|
||||||
|
|
||||||
|
[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"] }
|
||||||
|
|
||||||
|
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" }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "vpio"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
wzp-client = { path = "../../crates/wzp-client", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
A fresh clone on a Linux x86_64 host with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git
|
||||||
|
cd wz-phone
|
||||||
|
git checkout feat/desktop-audio-rewrite
|
||||||
|
git reset --hard 0d74366 # <-- step E.1, smallest crashing commit
|
||||||
|
|
||||||
|
# Need: Android NDK r26.1.10909125, JDK 17, Node 20, Rust stable, cargo tauri 2.x
|
||||||
|
scripts/prep-linux-mint.sh # installs all the above into /opt/android-sdk etc.
|
||||||
|
|
||||||
|
cd desktop
|
||||||
|
npm install
|
||||||
|
cd src-tauri
|
||||||
|
cargo tauri android build --debug --target aarch64 --apk
|
||||||
|
adb install -r gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk
|
||||||
|
adb logcat -c && adb shell am start -n com.wzp.desktop/.MainActivity
|
||||||
|
adb logcat | grep -E "F DEBUG|__init_tcb|pthread_create"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected result: SIGSEGV at `__init_tcb+4` within ~500 ms of launch.
|
||||||
|
|
||||||
|
Reverting `cpp/cpp_smoke.cpp` + the `cc::Build` call for it in `build.rs`
|
||||||
|
(one git command: `git revert 0d74366 aa240c6 0224ce6 a852cad`) restores a
|
||||||
|
working build. Keeping the C sanity compile (`hello.c`, `getauxval_fix.c`)
|
||||||
|
is fine — only the `.cpp(true) + .cpp_link_stdlib("c++_shared")` combination
|
||||||
|
triggers the regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What we'd like help with
|
||||||
|
|
||||||
|
1. **Is our suspect #2 actually the mechanism?** Is there a known issue
|
||||||
|
where a Tauri/tao android cdylib crashes on load when it has a
|
||||||
|
`libc++_shared.so` NEEDED entry and tries to spawn a thread from inside
|
||||||
|
an onCreate JNI call?
|
||||||
|
|
||||||
|
2. **What's the correct way to link Oboe (or any C++ Android audio
|
||||||
|
library) into a `cargo tauri android build` cdylib** without hitting
|
||||||
|
this? Is there a known-good combination of cc-rs flags / linker
|
||||||
|
arguments / cargo config?
|
||||||
|
|
||||||
|
3. **Is there a way to force `cargo tauri` to use the same linker setup
|
||||||
|
as `cargo ndk`**, which reliably produces working Oboe-linked .so
|
||||||
|
files from the exact same workspace? We've tried env var override,
|
||||||
|
`.cargo/config.toml`, and image-level binary replacement — cargo
|
||||||
|
tauri ignores all three and keeps using
|
||||||
|
`aarch64-linux-android24-clang`.
|
||||||
|
|
||||||
|
4. **Is there a way to defer `tao::ndk_glue::create`'s thread spawn to
|
||||||
|
after `onCreate` returns** so that whatever bionic state `__init_tcb`
|
||||||
|
depends on is ready?
|
||||||
|
|
||||||
|
5. **Lastly** — is there a fundamentally different approach we should
|
||||||
|
take (e.g., use the `oboe` Rust crate from crates.io instead of a
|
||||||
|
hand-rolled C++ bridge, use Android's AAudio directly via the `ndk`
|
||||||
|
crate's aaudio bindings, or even abandon the C++ audio path and
|
||||||
|
implement mic/speaker via JNI into Java `AudioRecord`/`AudioTrack`)?
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# WZ Phone — Android build environment (Debian 12 / Bookworm)
|
# WZ Phone — Android build environment (Debian 12 / Bookworm)
|
||||||
#
|
#
|
||||||
# Matches the bare-metal build-android.sh environment:
|
# Supports both:
|
||||||
|
# 1. Legacy Kotlin+JNI Android app (via cargo-ndk + gradle)
|
||||||
|
# 2. Tauri 2.x Mobile Android app (via tauri-cli + Node/npm)
|
||||||
|
#
|
||||||
|
# Toolchain:
|
||||||
# - Debian 12 (cmake 3.25, no Android cross-compilation bugs)
|
# - Debian 12 (cmake 3.25, no Android cross-compilation bugs)
|
||||||
# - JDK 17 (Gradle 8.5 + AGP 8.2.0 compatible)
|
# - JDK 17 (Gradle 8.5 + AGP 8.2.0 compatible)
|
||||||
# - NDK 26.1 (last stable before scudo/MTE crash on NDK 27+)
|
# - NDK 26.1 (last stable before scudo/MTE crash on NDK 27+)
|
||||||
# - Rust stable with aarch64-linux-android target + cargo-ndk
|
# - Node.js 20 LTS (for Tauri frontend build)
|
||||||
|
# - Rust stable with all 4 Android targets + cargo-ndk + tauri-cli 2.x
|
||||||
#
|
#
|
||||||
# Build: docker build -t wzp-android-builder -f Dockerfile.android-builder .
|
# Build: docker build -t wzp-android-builder -f Dockerfile.android-builder .
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -13,6 +18,11 @@ FROM debian:bookworm
|
|||||||
|
|
||||||
ARG NDK_VERSION=26.1.10909125
|
ARG NDK_VERSION=26.1.10909125
|
||||||
ARG ANDROID_API=34
|
ARG ANDROID_API=34
|
||||||
|
# Tauri 2.x mobile targets compileSdk 36 + build-tools 35 by default. Install
|
||||||
|
# both 34 (legacy Kotlin app) and 35/36 (Tauri mobile) so the same image works
|
||||||
|
# for both pipelines.
|
||||||
|
ARG ANDROID_API_TAURI=36
|
||||||
|
ARG BUILD_TOOLS_TAURI=35.0.0
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
ANDROID_HOME=/opt/android-sdk \
|
||||||
@@ -35,8 +45,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
openjdk-17-jdk-headless \
|
openjdk-17-jdk-headless \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
libasound2-dev \
|
libasound2-dev \
|
||||||
|
file \
|
||||||
|
xz-utils \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── Node.js 20 LTS (required by Tauri for frontend build) ────────────────────
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& node --version \
|
||||||
|
&& npm --version
|
||||||
|
|
||||||
# ── Android SDK + NDK 26.1 ──────────────────────────────────────────────────
|
# ── Android SDK + NDK 26.1 ──────────────────────────────────────────────────
|
||||||
RUN mkdir -p $ANDROID_HOME/cmdline-tools \
|
RUN mkdir -p $ANDROID_HOME/cmdline-tools \
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
@@ -49,10 +68,36 @@ RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/nu
|
|||||||
&& $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install \
|
&& $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install \
|
||||||
"platforms;android-${ANDROID_API}" \
|
"platforms;android-${ANDROID_API}" \
|
||||||
"build-tools;${ANDROID_API}.0.0" \
|
"build-tools;${ANDROID_API}.0.0" \
|
||||||
|
"platforms;android-${ANDROID_API_TAURI}" \
|
||||||
|
"build-tools;${BUILD_TOOLS_TAURI}" \
|
||||||
"ndk;${NDK_VERSION}" \
|
"ndk;${NDK_VERSION}" \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
2>&1 | grep -v '^\[' > /dev/null
|
2>&1 | grep -v '^\[' > /dev/null
|
||||||
|
|
||||||
|
# Work around the API-24 libc.a stub in the NDK. Any C++ static lib we
|
||||||
|
# link into libwzp_desktop_lib.so (e.g. the Oboe audio bridge) pulls in
|
||||||
|
# bionic's static pthread_create from API-24 libc.a via libc++_shared,
|
||||||
|
# and that pthread_create crashes at __init_tcb+4 when called from a
|
||||||
|
# .so loaded via dlopen (the static stub expects libc init state that
|
||||||
|
# only exists for main executables). API-26 has the proper runtime
|
||||||
|
# bindings. Tauri-cli hard-codes aarch64-linux-android24-clang as the
|
||||||
|
# linker and ignores .cargo/config.toml overrides, so the only sure
|
||||||
|
# fix is to replace the NDK's ${abi}24-clang binary itself with a
|
||||||
|
# shim that exec()s the ${abi}26-clang equivalent. Applies to all four
|
||||||
|
# ABIs × {clang, clang++}. The legacy wzp-android crate works without
|
||||||
|
# this because cargo-ndk honours a crate-level linker override; the
|
||||||
|
# shim is the minimal targeted fix for the cargo-tauri build path.
|
||||||
|
# Added as Option 3 for the incremental Step E regression (commit 4250f1b).
|
||||||
|
RUN set -eux; \
|
||||||
|
BIN=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin; \
|
||||||
|
for abi in aarch64-linux-android armv7a-linux-androideabi i686-linux-android x86_64-linux-android; do \
|
||||||
|
for suffix in clang clang++; do \
|
||||||
|
mv "$BIN/${abi}24-${suffix}" "$BIN/${abi}24-${suffix}.orig"; \
|
||||||
|
printf '#!/bin/sh\nexec "%s/%s26-%s" "$@"\n' "$BIN" "$abi" "$suffix" > "$BIN/${abi}24-${suffix}"; \
|
||||||
|
chmod +x "$BIN/${abi}24-${suffix}"; \
|
||||||
|
done; \
|
||||||
|
done
|
||||||
|
|
||||||
# Make SDK world-readable so builder user can access it
|
# Make SDK world-readable so builder user can access it
|
||||||
RUN chmod -R a+rX $ANDROID_HOME
|
RUN chmod -R a+rX $ANDROID_HOME
|
||||||
|
|
||||||
@@ -64,12 +109,22 @@ USER builder
|
|||||||
WORKDIR /home/builder
|
WORKDIR /home/builder
|
||||||
|
|
||||||
# ── Rust toolchain ───────────────────────────────────────────────────────────
|
# ── Rust toolchain ───────────────────────────────────────────────────────────
|
||||||
|
# Install all 4 Android targets (Tauri Mobile builds for all ABIs by default;
|
||||||
|
# cargo-ndk legacy path only needs arm64-v8a — both workflows supported).
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
| sh -s -- -y --default-toolchain stable \
|
| sh -s -- -y --default-toolchain stable \
|
||||||
&& . $HOME/.cargo/env \
|
&& . $HOME/.cargo/env \
|
||||||
&& rustup target add aarch64-linux-android \
|
&& rustup target add \
|
||||||
&& cargo install cargo-ndk
|
aarch64-linux-android \
|
||||||
|
armv7-linux-androideabi \
|
||||||
|
i686-linux-android \
|
||||||
|
x86_64-linux-android \
|
||||||
|
&& cargo install cargo-ndk \
|
||||||
|
&& cargo install tauri-cli --version "^2.0" --locked
|
||||||
|
|
||||||
ENV PATH="/home/builder/.cargo/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$JAVA_HOME/bin:$PATH"
|
ENV PATH="/home/builder/.cargo/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
# NDK_HOME is the env var tauri-cli checks (in addition to ANDROID_NDK_HOME)
|
||||||
|
ENV NDK_HOME=$ANDROID_NDK_HOME
|
||||||
|
|
||||||
WORKDIR /build/source
|
WORKDIR /build/source
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ ls -lh android/app/src/main/jniLibs/arm64-v8a/
|
|||||||
|
|
||||||
echo ">>> APK build..."
|
echo ">>> APK build..."
|
||||||
cd android && chmod +x gradlew
|
cd android && chmod +x gradlew
|
||||||
./gradlew clean assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -3
|
./gradlew clean assembleDebug --no-daemon --warning-mode=none 2>&1 | tail -50
|
||||||
echo "APK_BUILT"
|
echo "APK_BUILT"
|
||||||
'
|
'
|
||||||
|
|
||||||
|
|||||||
253
scripts/build-tauri-android.sh
Executable file
253
scripts/build-tauri-android.sh
Executable file
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WZ Phone — Tauri 2.x Mobile Android APK build
|
||||||
|
#
|
||||||
|
# Builds the desktop/ Tauri app as an Android APK via cargo-tauri inside the
|
||||||
|
# wzp-android-builder Docker image on SepehrHomeserverdk. Uploads the APK to
|
||||||
|
# rustypaste, fires ntfy.sh/wzp notifications at start + finish, and SCPs the
|
||||||
|
# APK back locally.
|
||||||
|
#
|
||||||
|
# Same pattern as build-and-notify.sh but for the Tauri mobile pipeline:
|
||||||
|
# - Source: desktop/src-tauri/ (not android/)
|
||||||
|
# - Build: cargo tauri android build (not gradlew assembleDebug)
|
||||||
|
# - Output: desktop/src-tauri/gen/android/.../*.apk
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/build-tauri-android.sh # full pipeline (debug)
|
||||||
|
# ./scripts/build-tauri-android.sh --release # release APK
|
||||||
|
# ./scripts/build-tauri-android.sh --no-pull # skip git fetch
|
||||||
|
# ./scripts/build-tauri-android.sh --rust # force-clean rust target
|
||||||
|
# ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init`
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
REMOTE_HOST="SepehrHomeserverdk"
|
||||||
|
BASE_DIR="/mnt/storage/manBuilder"
|
||||||
|
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||||
|
LOCAL_OUTPUT="target/tauri-android-apk"
|
||||||
|
BRANCH="${WZP_BRANCH:-feat/desktop-audio-rewrite}"
|
||||||
|
SSH_OPTS="-o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o LogLevel=ERROR"
|
||||||
|
|
||||||
|
REBUILD_RUST=0
|
||||||
|
DO_PULL=1
|
||||||
|
DO_INIT=0
|
||||||
|
BUILD_RELEASE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--rust) REBUILD_RUST=1 ;;
|
||||||
|
--pull) DO_PULL=1 ;;
|
||||||
|
--no-pull) DO_PULL=0 ;;
|
||||||
|
--init) DO_INIT=1 ;;
|
||||||
|
--release) BUILD_RELEASE=1 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '3,30p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
|
||||||
|
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
|
||||||
|
|
||||||
|
notify_local() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||||
|
|
||||||
|
mkdir -p "$LOCAL_OUTPUT"
|
||||||
|
|
||||||
|
log "Uploading remote build script..."
|
||||||
|
ssh_cmd "cat > /tmp/wzp-tauri-build.sh" <<'REMOTE_SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_DIR="/mnt/storage/manBuilder"
|
||||||
|
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||||
|
BRANCH="${1:-feat/desktop-audio-rewrite}"
|
||||||
|
DO_PULL="${2:-1}"
|
||||||
|
REBUILD_RUST="${3:-0}"
|
||||||
|
DO_INIT="${4:-0}"
|
||||||
|
BUILD_RELEASE="${5:-0}"
|
||||||
|
|
||||||
|
LOG_FILE=/tmp/wzp-tauri-build.log
|
||||||
|
GIT_HASH="unknown" # populated after fetch
|
||||||
|
ENV_FILE="$BASE_DIR/.env"
|
||||||
|
|
||||||
|
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||||
|
|
||||||
|
# Upload a file to rustypaste; print URL on stdout (or empty on failure).
|
||||||
|
upload_to_rustypaste() {
|
||||||
|
local file="$1"
|
||||||
|
[ ! -f "$ENV_FILE" ] && { echo ""; return; }
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
if [ -n "${rusty_address:-}" ] && [ -n "${rusty_auth_token:-}" ]; then
|
||||||
|
curl -s -F "file=@$file" -H "Authorization: $rusty_auth_token" "$rusty_address" || echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# On failure: upload the build log to rustypaste, then notify with hash + url.
|
||||||
|
on_error() {
|
||||||
|
local line="$1"
|
||||||
|
local log_url
|
||||||
|
log_url=$(upload_to_rustypaste "$LOG_FILE" || echo "")
|
||||||
|
if [ -n "$log_url" ]; then
|
||||||
|
notify "WZP Tauri Android build FAILED [$GIT_HASH] (line $line)
|
||||||
|
log: $log_url"
|
||||||
|
else
|
||||||
|
notify "WZP Tauri Android build FAILED [$GIT_HASH] (line $line) — log upload failed, see $LOG_FILE on remote"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap 'on_error $LINENO' ERR
|
||||||
|
|
||||||
|
exec > >(tee "$LOG_FILE") 2>&1
|
||||||
|
|
||||||
|
if [ "$DO_PULL" = "1" ]; then
|
||||||
|
echo ">>> git fetch + reset $BRANCH"
|
||||||
|
cd "$BASE_DIR/data/source"
|
||||||
|
git reset --hard HEAD 2>/dev/null || true
|
||||||
|
# NOTE: deliberately do NOT run `git clean -fd` here. It would wipe the
|
||||||
|
# tauri-generated `desktop/src-tauri/gen/android/` scaffold (gradlew,
|
||||||
|
# settings.gradle, etc.) which is expensive to recreate and breaks
|
||||||
|
# subsequent builds with "gradlew not found".
|
||||||
|
git gc --prune=now 2>/dev/null || true
|
||||||
|
git fetch origin "$BRANCH" 2>&1 | tail -3
|
||||||
|
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
git submodule update --init || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
GIT_HASH=$(cd "$BASE_DIR/data/source" && git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
|
GIT_MSG=$(cd "$BASE_DIR/data/source" && git log -1 --pretty=%s 2>/dev/null | head -c 60 || echo "?")
|
||||||
|
notify "WZP Tauri Android build STARTED [$GIT_HASH] — $GIT_MSG"
|
||||||
|
|
||||||
|
# Fix perms so uid 1000 can write
|
||||||
|
find "$BASE_DIR/data/source" "$BASE_DIR/data/cache" \
|
||||||
|
! -user 1000 -o ! -group 1000 2>/dev/null | \
|
||||||
|
xargs -r chown 1000:1000 2>/dev/null || true
|
||||||
|
|
||||||
|
# Optionally clean rust target for android triples
|
||||||
|
if [ "$REBUILD_RUST" = "1" ]; then
|
||||||
|
echo ">>> Cleaning Rust android target dirs..."
|
||||||
|
rm -rf "$BASE_DIR/data/cache/target/aarch64-linux-android" \
|
||||||
|
"$BASE_DIR/data/cache/target/armv7-linux-androideabi" \
|
||||||
|
"$BASE_DIR/data/cache/target/i686-linux-android" \
|
||||||
|
"$BASE_DIR/data/cache/target/x86_64-linux-android"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Profile flag
|
||||||
|
PROFILE_FLAG="--debug"
|
||||||
|
[ "$BUILD_RELEASE" = "1" ] && PROFILE_FLAG=""
|
||||||
|
|
||||||
|
# Persist ~/.android (where the auto-generated debug.keystore lives) so every
|
||||||
|
# build is signed with the SAME key. Without this, every fresh container gets
|
||||||
|
# a new debug keystore and `adb install -r` fails with INSTALL_FAILED_UPDATE_
|
||||||
|
# INCOMPATIBLE because the signature changed.
|
||||||
|
mkdir -p "$BASE_DIR/data/cache/android-home"
|
||||||
|
chown 1000:1000 "$BASE_DIR/data/cache/android-home" 2>/dev/null || true
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--user 1000:1000 \
|
||||||
|
-e DO_INIT="$DO_INIT" \
|
||||||
|
-e PROFILE_FLAG="$PROFILE_FLAG" \
|
||||||
|
-v "$BASE_DIR/data/source:/build/source" \
|
||||||
|
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
|
||||||
|
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
|
||||||
|
-v "$BASE_DIR/data/cache/target:/build/source/target" \
|
||||||
|
-v "$BASE_DIR/data/cache/gradle:/home/builder/.gradle" \
|
||||||
|
-v "$BASE_DIR/data/cache/android-home:/home/builder/.android" \
|
||||||
|
wzp-android-builder \
|
||||||
|
bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
cd /build/source/desktop
|
||||||
|
|
||||||
|
echo ">>> npm install"
|
||||||
|
npm install --silent 2>&1 | tail -5 || npm install 2>&1 | tail -20
|
||||||
|
|
||||||
|
cd src-tauri
|
||||||
|
|
||||||
|
# Run init if forced, OR if the gradle wrapper is missing. Just checking
|
||||||
|
# for `gen/android` is not enough — Tauri creates a few subdirectories
|
||||||
|
# during build (app/, buildSrc/, .gradle/) that survive a partial wipe and
|
||||||
|
# would make a naive `[ ! -d gen/android ]` check return false even though
|
||||||
|
# the build wrapper itself is gone.
|
||||||
|
if [ "${DO_INIT}" = "1" ] || [ ! -x gen/android/gradlew ]; then
|
||||||
|
echo ">>> cargo tauri android init"
|
||||||
|
cargo tauri android init 2>&1 | tail -20
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── wzp-native standalone cdylib (built with cargo-ndk, not cargo-tauri) ──
|
||||||
|
# Produces libwzp_native.so which wzp-desktop dlopens at runtime via
|
||||||
|
# libloading. Split exists because cargo-tauri`s linker wiring pulls
|
||||||
|
# bionic private symbols into any cdylib with cc::Build C++, causing
|
||||||
|
# __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the
|
||||||
|
# legacy wzp-android crate which works.
|
||||||
|
echo ">>> cargo ndk build -p wzp-native --release"
|
||||||
|
JNI_ABI_DIR=gen/android/app/src/main/jniLibs/arm64-v8a
|
||||||
|
mkdir -p "$JNI_ABI_DIR"
|
||||||
|
(
|
||||||
|
cd /build/source
|
||||||
|
cargo ndk -t arm64-v8a -o desktop/src-tauri/gen/android/app/src/main/jniLibs \
|
||||||
|
build --release -p wzp-native 2>&1 | tail -10
|
||||||
|
)
|
||||||
|
if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
|
||||||
|
ls -lh "$JNI_ABI_DIR/libwzp_native.so"
|
||||||
|
else
|
||||||
|
echo ">>> WARNING: libwzp_native.so not produced"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk"
|
||||||
|
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> Build artifacts:"
|
||||||
|
find gen/android -name "*.apk" -exec ls -lh {} \; 2>/dev/null
|
||||||
|
'
|
||||||
|
|
||||||
|
# Locate the produced APK
|
||||||
|
APK=$(find "$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1)
|
||||||
|
if [ -z "$APK" ] || [ ! -f "$APK" ]; then
|
||||||
|
LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
|
||||||
|
if [ -n "$LOG_URL" ]; then
|
||||||
|
notify "WZP Tauri Android build [$GIT_HASH]: no APK produced
|
||||||
|
log: $LOG_URL"
|
||||||
|
else
|
||||||
|
notify "WZP Tauri Android build [$GIT_HASH]: no APK produced — log upload failed"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
APK_SIZE=$(du -h "$APK" | cut -f1)
|
||||||
|
|
||||||
|
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "")
|
||||||
|
if [ -n "$RUSTY_URL" ]; then
|
||||||
|
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE)
|
||||||
|
$RUSTY_URL"
|
||||||
|
else
|
||||||
|
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) — rustypaste upload skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Print path so the local script can grab it
|
||||||
|
echo "APK_REMOTE_PATH=$APK"
|
||||||
|
REMOTE_SCRIPT
|
||||||
|
|
||||||
|
ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh"
|
||||||
|
|
||||||
|
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, release=$BUILD_RELEASE)"
|
||||||
|
log "Triggering remote build (branch=$BRANCH)..."
|
||||||
|
|
||||||
|
# Run; capture full output, last line is APK_REMOTE_PATH=...
|
||||||
|
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE'" || true)
|
||||||
|
echo "$REMOTE_OUTPUT" | tail -60
|
||||||
|
|
||||||
|
APK_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
|
||||||
|
if [ -n "$APK_REMOTE" ]; then
|
||||||
|
log "Downloading APK to $LOCAL_OUTPUT/wzp-tauri.apk..."
|
||||||
|
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/wzp-tauri.apk"
|
||||||
|
echo " $LOCAL_OUTPUT/wzp-tauri.apk ($(du -h "$LOCAL_OUTPUT/wzp-tauri.apk" | cut -f1))"
|
||||||
|
else
|
||||||
|
log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
72
scripts/mint-tmux.sh
Executable file
72
scripts/mint-tmux.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# mint-tmux.sh — run a command inside a persistent tmux session on the
|
||||||
|
# Linux Mint build box so the user can attach and watch/interact at any time.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# mint-tmux.sh run <window-name> <command...> # start a new tmux window
|
||||||
|
# mint-tmux.sh send <window-name> <text...> # send keys to a window
|
||||||
|
# mint-tmux.sh kill <window-name> # close a window
|
||||||
|
# mint-tmux.sh list # list windows
|
||||||
|
# mint-tmux.sh tail <window-name> # dump last 200 lines
|
||||||
|
#
|
||||||
|
# Session name is always "wzp". Attach manually with:
|
||||||
|
# ssh -t root@172.16.81.192 tmux attach -t wzp
|
||||||
|
#
|
||||||
|
# If the wzp session doesn't exist yet, it's created automatically.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="root@172.16.81.192"
|
||||||
|
SESSION="wzp"
|
||||||
|
SSH_OPTS="-o ConnectTimeout=10 -o LogLevel=ERROR"
|
||||||
|
|
||||||
|
ensure_session() {
|
||||||
|
ssh $SSH_OPTS "$HOST" "
|
||||||
|
tmux has-session -t $SESSION 2>/dev/null || tmux new-session -d -s $SESSION -n home 'bash -l'
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-list}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
run)
|
||||||
|
WIN="${1:?window name required}"; shift
|
||||||
|
ensure_session
|
||||||
|
# Use a heredoc so multi-arg commands don't need escaping
|
||||||
|
CMD="$*"
|
||||||
|
ssh $SSH_OPTS "$HOST" bash -s <<REMOTE
|
||||||
|
if tmux list-windows -t $SESSION -F '#W' 2>/dev/null | grep -qx '$WIN'; then
|
||||||
|
tmux kill-window -t $SESSION:$WIN 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
tmux new-window -t $SESSION -n '$WIN' "bash -l -c '$CMD; echo; echo --- window $WIN exited with code \\\$?; exec bash -l'"
|
||||||
|
REMOTE
|
||||||
|
echo "Started '$WIN' in tmux session $SESSION on $HOST"
|
||||||
|
echo "Attach: ssh -t $HOST tmux attach -t $SESSION"
|
||||||
|
;;
|
||||||
|
send)
|
||||||
|
WIN="${1:?window name required}"; shift
|
||||||
|
TEXT="$*"
|
||||||
|
ssh $SSH_OPTS "$HOST" "tmux send-keys -t $SESSION:$WIN '$TEXT' C-m"
|
||||||
|
;;
|
||||||
|
kill)
|
||||||
|
WIN="${1:?window name required}"
|
||||||
|
ssh $SSH_OPTS "$HOST" "tmux kill-window -t $SESSION:$WIN 2>/dev/null || true"
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
ensure_session
|
||||||
|
ssh $SSH_OPTS "$HOST" "tmux list-windows -t $SESSION"
|
||||||
|
;;
|
||||||
|
tail)
|
||||||
|
WIN="${1:?window name required}"
|
||||||
|
ssh $SSH_OPTS "$HOST" "tmux capture-pane -p -t $SESSION:$WIN -S -200 || echo 'no such window'"
|
||||||
|
;;
|
||||||
|
attach)
|
||||||
|
exec ssh -t $SSH_OPTS "$HOST" tmux attach -t $SESSION
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
sed -n '3,20p' "$0"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
167
scripts/prep-linux-mint.sh
Executable file
167
scripts/prep-linux-mint.sh
Executable file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Prepare a Linux Mint / Debian / Ubuntu x86_64 host as a full WarzonePhone
|
||||||
|
# Android build environment. Installs everything the docker wzp-android-builder
|
||||||
|
# image has, but directly on the host — so we can iterate locally without
|
||||||
|
# docker layer caching, see real linker output, run gdbserver, etc.
|
||||||
|
#
|
||||||
|
# Target host: root@172.16.81.192 (Linux Mint on the LAN)
|
||||||
|
#
|
||||||
|
# Usage (from the macOS workstation):
|
||||||
|
# scp scripts/prep-linux-mint.sh root@172.16.81.192:/tmp/
|
||||||
|
# ssh root@172.16.81.192 'nohup bash /tmp/prep-linux-mint.sh > /var/log/wzp-prep.log 2>&1 &'
|
||||||
|
#
|
||||||
|
# The script is idempotent: safe to re-run if a step fails. Each stage tests
|
||||||
|
# for its target before doing work. Progress + completion is pinged to
|
||||||
|
# ntfy.sh/wzp so we can track it from the phone.
|
||||||
|
#
|
||||||
|
# On success the host has:
|
||||||
|
# - JDK 17
|
||||||
|
# - Android SDK (cmdline-tools + platforms 34/36, build-tools 34/35, NDK 26.1)
|
||||||
|
# - Node.js 20 LTS + npm
|
||||||
|
# - Rust stable + aarch64/armv7/i686/x86_64 android targets
|
||||||
|
# - cargo-ndk + cargo tauri-cli 2.x
|
||||||
|
# - /opt/wzp/warzonePhone (cloned workspace checkout on feat/desktop-audio-rewrite)
|
||||||
|
#
|
||||||
|
# Everything lives under /opt/android-sdk and /opt/wzp so nothing leaks into $HOME.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NTFY_TOPIC="https://ntfy.sh/wzp"
|
||||||
|
NDK_VERSION="26.1.10909125"
|
||||||
|
ANDROID_API=34
|
||||||
|
ANDROID_API_TAURI=36
|
||||||
|
BUILD_TOOLS_TAURI="35.0.0"
|
||||||
|
ANDROID_HOME=/opt/android-sdk
|
||||||
|
WZP_DIR=/opt/wzp
|
||||||
|
GIT_REPO="ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git"
|
||||||
|
GIT_BRANCH="feat/desktop-audio-rewrite"
|
||||||
|
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
export ANDROID_HOME ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||||
|
export NDK_HOME="$ANDROID_NDK_HOME"
|
||||||
|
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:/root/.cargo/bin:$PATH"
|
||||||
|
|
||||||
|
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
|
||||||
|
log() { echo -e "\n\033[1;36m[prep-linux-mint]\033[0m $*"; }
|
||||||
|
die() { notify "wzp prep-linux-mint FAILED: $1"; echo "FATAL: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
trap 'die "line $LINENO"' ERR
|
||||||
|
|
||||||
|
notify "wzp prep-linux-mint STARTED on $(hostname) ($(whoami))"
|
||||||
|
|
||||||
|
# ─── 1. Base packages ────────────────────────────────────────────────────────
|
||||||
|
log "Installing base packages..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
cmake \
|
||||||
|
curl \
|
||||||
|
file \
|
||||||
|
git \
|
||||||
|
libasound2-dev \
|
||||||
|
libc6-dev \
|
||||||
|
libssl-dev \
|
||||||
|
openjdk-17-jdk-headless \
|
||||||
|
pkg-config \
|
||||||
|
unzip \
|
||||||
|
wget \
|
||||||
|
xz-utils \
|
||||||
|
zip
|
||||||
|
|
||||||
|
# ─── 2. Android SDK + NDK ────────────────────────────────────────────────────
|
||||||
|
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||||
|
log "Installing Android cmdline-tools..."
|
||||||
|
mkdir -p "$ANDROID_HOME/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 "$ANDROID_HOME/cmdline-tools"
|
||||||
|
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
|
rm cmdtools.zip
|
||||||
|
else
|
||||||
|
log "cmdline-tools already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$ANDROID_HOME/ndk/$NDK_VERSION" ] || \
|
||||||
|
[ ! -d "$ANDROID_HOME/platforms/android-$ANDROID_API" ] || \
|
||||||
|
[ ! -d "$ANDROID_HOME/platforms/android-$ANDROID_API_TAURI" ]; then
|
||||||
|
log "Installing Android platforms + NDK $NDK_VERSION..."
|
||||||
|
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null 2>&1 || true
|
||||||
|
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install \
|
||||||
|
"platforms;android-$ANDROID_API" \
|
||||||
|
"build-tools;$ANDROID_API.0.0" \
|
||||||
|
"platforms;android-$ANDROID_API_TAURI" \
|
||||||
|
"build-tools;$BUILD_TOOLS_TAURI" \
|
||||||
|
"ndk;$NDK_VERSION" \
|
||||||
|
"platform-tools" 2>&1 | grep -v '^\[' || true
|
||||||
|
else
|
||||||
|
log "Android SDK components already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 3. Node.js 20 LTS ───────────────────────────────────────────────────────
|
||||||
|
if ! command -v node >/dev/null 2>&1 || ! node --version | grep -q "^v20"; then
|
||||||
|
log "Installing Node.js 20 LTS..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y --no-install-recommends nodejs
|
||||||
|
else
|
||||||
|
log "Node.js already at $(node --version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 4. Rust + Android targets ───────────────────────────────────────────────
|
||||||
|
if ! command -v rustup >/dev/null 2>&1; then
|
||||||
|
log "Installing rustup..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
fi
|
||||||
|
. /root/.cargo/env
|
||||||
|
|
||||||
|
log "Ensuring Rust android targets + cargo-ndk + cargo-tauri..."
|
||||||
|
rustup target add \
|
||||||
|
aarch64-linux-android \
|
||||||
|
armv7-linux-androideabi \
|
||||||
|
i686-linux-android \
|
||||||
|
x86_64-linux-android
|
||||||
|
command -v cargo-ndk >/dev/null 2>&1 || cargo install cargo-ndk
|
||||||
|
command -v cargo-tauri >/dev/null 2>&1 || cargo install tauri-cli --version "^2.0" --locked
|
||||||
|
|
||||||
|
# ─── 5. Clone the workspace ──────────────────────────────────────────────────
|
||||||
|
mkdir -p "$WZP_DIR"
|
||||||
|
cd "$WZP_DIR"
|
||||||
|
if [ -d warzonePhone/.git ]; then
|
||||||
|
log "Pulling latest on $GIT_BRANCH..."
|
||||||
|
cd warzonePhone
|
||||||
|
git fetch origin || true
|
||||||
|
git checkout "$GIT_BRANCH" 2>/dev/null || git checkout -b "$GIT_BRANCH" "origin/$GIT_BRANCH"
|
||||||
|
git reset --hard "origin/$GIT_BRANCH" || true
|
||||||
|
else
|
||||||
|
log "Cloning warzonePhone from $GIT_REPO..."
|
||||||
|
# The public repo URL needs ssh keys; if unavailable, skip and let the user sort it later
|
||||||
|
if git clone --branch "$GIT_BRANCH" "$GIT_REPO" warzonePhone 2>/dev/null; then
|
||||||
|
log " cloned ok"
|
||||||
|
else
|
||||||
|
log " clone failed (no SSH keys for $GIT_REPO — skipping, user will rsync)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── 6. Persistent env for the user ──────────────────────────────────────────
|
||||||
|
cat > /etc/profile.d/wzp-android.sh <<ENVEOF
|
||||||
|
export ANDROID_HOME=$ANDROID_HOME
|
||||||
|
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||||
|
export NDK_HOME=\$ANDROID_NDK_HOME
|
||||||
|
export PATH=\$ANDROID_HOME/cmdline-tools/latest/bin:\$ANDROID_HOME/platform-tools:/root/.cargo/bin:\$PATH
|
||||||
|
ENVEOF
|
||||||
|
chmod 644 /etc/profile.d/wzp-android.sh
|
||||||
|
|
||||||
|
# ─── 7. Sanity summary ───────────────────────────────────────────────────────
|
||||||
|
log "Sanity checks:"
|
||||||
|
echo " java: $(java -version 2>&1 | head -1)"
|
||||||
|
echo " node: $(node --version)"
|
||||||
|
echo " npm: $(npm --version)"
|
||||||
|
echo " rustc: $(rustc --version)"
|
||||||
|
echo " cargo-ndk: $(cargo ndk --version 2>&1 | head -1)"
|
||||||
|
echo " cargo-tauri:$(cargo tauri --version 2>&1 | head -1)"
|
||||||
|
echo " NDK dir: $ANDROID_NDK_HOME"
|
||||||
|
echo " WZP dir: $WZP_DIR/warzonePhone"
|
||||||
|
|
||||||
|
notify "wzp prep-linux-mint DONE on $(hostname) — ready at /opt/wzp/warzonePhone"
|
||||||
|
log "All done."
|
||||||
Reference in New Issue
Block a user