Compare commits
2 Commits
578ff8cff4
...
build/last
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e0d8d212 | ||
|
|
4e0356ef37 |
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
name: caveman
|
|
||||||
description: >
|
|
||||||
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
|
|
||||||
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
|
|
||||||
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
|
|
||||||
when token efficiency is requested.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Caveman Mode
|
|
||||||
|
|
||||||
## Core Rule
|
|
||||||
|
|
||||||
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
|
|
||||||
|
|
||||||
## Grammar
|
|
||||||
|
|
||||||
- Drop articles (a, an, the)
|
|
||||||
- Drop filler (just, really, basically, actually, simply)
|
|
||||||
- Drop pleasantries (sure, certainly, of course, happy to)
|
|
||||||
- Short synonyms (big not extensive, fix not "implement a solution for")
|
|
||||||
- No hedging (skip "it might be worth considering")
|
|
||||||
- Fragments fine. No need full sentence
|
|
||||||
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
|
|
||||||
- Code blocks unchanged. Caveman speak around code, not in code
|
|
||||||
- Error messages quoted exact. Caveman only for explanation
|
|
||||||
|
|
||||||
## Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
[thing] [action] [reason]. [next step].
|
|
||||||
```
|
|
||||||
|
|
||||||
Not:
|
|
||||||
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
|
|
||||||
|
|
||||||
Yes:
|
|
||||||
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
**User:** Why is my React component re-rendering?
|
|
||||||
|
|
||||||
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
|
|
||||||
|
|
||||||
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**User:** How do I set up a PostgreSQL connection pool?
|
|
||||||
|
|
||||||
**Caveman:**
|
|
||||||
```
|
|
||||||
Use `pg` pool:
|
|
||||||
```
|
|
||||||
```js
|
|
||||||
const pool = new Pool({
|
|
||||||
max: 20,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 2000,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
```
|
|
||||||
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Code: write normal. Caveman English only
|
|
||||||
- Git commits: normal
|
|
||||||
- PR descriptions: normal
|
|
||||||
- User say "stop caveman" or "normal mode": revert immediately
|
|
||||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -4,28 +4,3 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
dev-debug.log
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
# OS specific
|
|
||||||
|
|
||||||
# Taskmaster (local workflow tool)
|
|
||||||
.taskmaster/
|
|
||||||
.env.example
|
|
||||||
|
|||||||
3800
Cargo.lock
generated
3800
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
39
Cargo.toml
@@ -10,8 +10,6 @@ members = [
|
|||||||
"crates/wzp-client",
|
"crates/wzp-client",
|
||||||
"crates/wzp-web",
|
"crates/wzp-web",
|
||||||
"crates/wzp-android",
|
"crates/wzp-android",
|
||||||
"crates/wzp-native",
|
|
||||||
"desktop/src-tauri",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -37,19 +35,12 @@ quinn = "0.11"
|
|||||||
raptorq = "2"
|
raptorq = "2"
|
||||||
|
|
||||||
# Codec
|
# Codec
|
||||||
# opusic-c: high-level safe bindings over libopus 1.5.2 (encoder side).
|
audiopus = "0.3.0-rc.0"
|
||||||
# opusic-sys: raw FFI for the decoder side — we build our own DecoderHandle
|
|
||||||
# because opusic-c::Decoder.inner is pub(crate) and cannot be reached for the
|
|
||||||
# Phase 3 DRED reconstruction path. See docs/PRD-dred-integration.md.
|
|
||||||
# Pinned exactly (no caret) for reproducible libopus 1.5.2 across the fleet.
|
|
||||||
opusic-c = { version = "=1.5.5", default-features = false, features = ["bundled", "dred"] }
|
|
||||||
opusic-sys = { version = "=0.6.0", default-features = false, features = ["bundled"] }
|
|
||||||
bytemuck = "1"
|
|
||||||
codec2 = "0.3"
|
codec2 = "0.3"
|
||||||
|
|
||||||
# Crypto
|
# Crypto
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
@@ -62,29 +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.opusic-sys]
|
|
||||||
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
|
|
||||||
|
|
||||||
# Phase 0 (opus-DRED): removed the [patch.crates-io] audiopus_sys = { path =
|
|
||||||
# "vendor/audiopus_sys" } block. That patch existed to fix a Windows clang-cl
|
|
||||||
# SIMD compile bug in libopus 1.3.1. With the swap to opusic-sys (libopus
|
|
||||||
# 1.5.2), the upstream SIMD gating was fixed and the vendor patch is
|
|
||||||
# obsolete. The vendor/audiopus_sys directory itself should be deleted as
|
|
||||||
# part of the same cleanup — see the commit that follows this Phase 0.
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class AudioPipeline(private val context: Context) {
|
|||||||
/** Whether to attach hardware AEC. Must be set before start(). */
|
/** Whether to attach hardware AEC. Must be set before start(). */
|
||||||
var aecEnabled: Boolean = true
|
var aecEnabled: Boolean = true
|
||||||
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
/** Enable debug recording of PCM + RMS histogram to cache dir. */
|
||||||
var debugRecording: Boolean = false
|
var debugRecording: Boolean = true
|
||||||
private var captureThread: Thread? = null
|
private var captureThread: Thread? = null
|
||||||
private var playoutThread: Thread? = null
|
private var playoutThread: Thread? = null
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class SettingsRepository(context: Context) {
|
|||||||
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
|
||||||
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
private const val KEY_IDENTITY_SEED = "identity_seed_hex"
|
||||||
private const val KEY_AEC_ENABLED = "aec_enabled"
|
private const val KEY_AEC_ENABLED = "aec_enabled"
|
||||||
private const val KEY_DEBUG_RECORDING = "debug_recording"
|
|
||||||
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
private const val KEY_RECENT_ROOMS = "recent_rooms"
|
||||||
private const val TOFU_PREFIX = "tofu_"
|
private const val TOFU_PREFIX = "tofu_"
|
||||||
}
|
}
|
||||||
@@ -121,16 +120,6 @@ class SettingsRepository(context: Context) {
|
|||||||
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
fun saveAecEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_AEC_ENABLED, enabled).apply() }
|
||||||
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
fun loadAecEnabled(): Boolean = prefs.getBoolean(KEY_AEC_ENABLED, true)
|
||||||
|
|
||||||
// --- Debug recording ---
|
|
||||||
|
|
||||||
fun saveDebugRecording(enabled: Boolean) { prefs.edit().putBoolean(KEY_DEBUG_RECORDING, enabled).apply() }
|
|
||||||
fun loadDebugRecording(): Boolean = prefs.getBoolean(KEY_DEBUG_RECORDING, false)
|
|
||||||
|
|
||||||
// --- Codec choice ---
|
|
||||||
// 0 = Opus (GOOD), 1 = Opus Low (DEGRADED), 2 = Codec2 (CATASTROPHIC)
|
|
||||||
fun saveCodecChoice(choice: Int) { prefs.edit().putInt("codec_choice", choice).apply() }
|
|
||||||
fun loadCodecChoice(): Int = prefs.getInt("codec_choice", 0)
|
|
||||||
|
|
||||||
// --- Identity seed ---
|
// --- Identity seed ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,14 +179,4 @@ class SettingsRepository(context: Context) {
|
|||||||
fun loadServerFingerprint(address: String): String? {
|
fun loadServerFingerprint(address: String): String? {
|
||||||
return prefs.getString("$TOFU_PREFIX$address", null)
|
return prefs.getString("$TOFU_PREFIX$address", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ping RTT cache ---
|
|
||||||
|
|
||||||
fun savePingRtt(address: String, rttMs: Int) {
|
|
||||||
prefs.edit().putInt("ping_rtt_$address", rttMs).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadPingRtt(address: String): Int {
|
|
||||||
return prefs.getInt("ping_rtt_$address", -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,14 +46,6 @@ class DebugReporter(private val context: Context) {
|
|||||||
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
val zipFile = File(context.cacheDir, "wzp_debug_${timestamp}.zip")
|
||||||
|
|
||||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
|
||||||
// Phase 4: extract DRED / classical PLC counters from the
|
|
||||||
// stats JSON so they're visible in the meta preamble at a
|
|
||||||
// glance, not buried in the trailing JSON dump.
|
|
||||||
val dredReconstructions = extractLongField(finalStatsJson, "dred_reconstructions")
|
|
||||||
val classicalPlc = extractLongField(finalStatsJson, "classical_plc_invocations")
|
|
||||||
val framesDecoded = extractLongField(finalStatsJson, "frames_decoded")
|
|
||||||
val fecRecovered = extractLongField(finalStatsJson, "fec_recovered")
|
|
||||||
|
|
||||||
// 1. Call metadata
|
// 1. Call metadata
|
||||||
val meta = buildString {
|
val meta = buildString {
|
||||||
appendLine("=== WZ Phone Debug Report ===")
|
appendLine("=== WZ Phone Debug Report ===")
|
||||||
@@ -66,18 +58,6 @@ class DebugReporter(private val context: Context) {
|
|||||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||||
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
appendLine("Android: ${android.os.Build.VERSION.RELEASE} (API ${android.os.Build.VERSION.SDK_INT})")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("=== Loss Recovery ===")
|
|
||||||
appendLine("Frames decoded: $framesDecoded")
|
|
||||||
appendLine("DRED reconstructions: $dredReconstructions (Opus neural recovery)")
|
|
||||||
appendLine("Classical PLC: $classicalPlc (fallback)")
|
|
||||||
appendLine("RaptorQ FEC recovered: $fecRecovered (Codec2 only)")
|
|
||||||
if (framesDecoded > 0) {
|
|
||||||
val dredPct = 100.0 * dredReconstructions / framesDecoded
|
|
||||||
val plcPct = 100.0 * classicalPlc / framesDecoded
|
|
||||||
appendLine("DRED rate: ${"%.2f".format(dredPct)}%")
|
|
||||||
appendLine("Classical PLC rate: ${"%.2f".format(plcPct)}%")
|
|
||||||
}
|
|
||||||
appendLine()
|
|
||||||
appendLine("=== Final Stats ===")
|
appendLine("=== Final Stats ===")
|
||||||
appendLine(finalStatsJson)
|
appendLine(finalStatsJson)
|
||||||
}
|
}
|
||||||
@@ -215,28 +195,4 @@ class DebugReporter(private val context: Context) {
|
|||||||
FileInputStream(file).use { it.copyTo(zos) }
|
FileInputStream(file).use { it.copyTo(zos) }
|
||||||
zos.closeEntry()
|
zos.closeEntry()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tiny JSON field extractor — pulls an integer value for a top-level
|
|
||||||
* field like `"dred_reconstructions":42`. We don't want to pull in a
|
|
||||||
* full JSON parser just for the debug preamble, and the CallStats
|
|
||||||
* output is a flat record with well-known field names.
|
|
||||||
*
|
|
||||||
* Returns 0 if the field is missing or unparseable.
|
|
||||||
*/
|
|
||||||
private fun extractLongField(json: String, field: String): Long {
|
|
||||||
val key = "\"$field\":"
|
|
||||||
val idx = json.indexOf(key)
|
|
||||||
if (idx < 0) return 0
|
|
||||||
var i = idx + key.length
|
|
||||||
// Skip whitespace
|
|
||||||
while (i < json.length && json[i].isWhitespace()) i++
|
|
||||||
val start = i
|
|
||||||
while (i < json.length && (json[i].isDigit() || json[i] == '-')) i++
|
|
||||||
return try {
|
|
||||||
json.substring(start, i).toLong()
|
|
||||||
} catch (_: NumberFormatException) {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,24 +33,10 @@ data class CallStats(
|
|||||||
val fecRecovered: Long = 0,
|
val fecRecovered: Long = 0,
|
||||||
/** Current mic audio level (RMS, 0-32767). */
|
/** Current mic audio level (RMS, 0-32767). */
|
||||||
val audioLevel: Int = 0,
|
val audioLevel: Int = 0,
|
||||||
/** Our current outgoing codec (e.g. "Opus24k"). */
|
|
||||||
val currentCodec: String = "",
|
|
||||||
/** Last seen incoming codec from peers. */
|
|
||||||
val peerCodec: String = "",
|
|
||||||
/** Whether auto quality mode is active. */
|
|
||||||
val autoMode: Boolean = false,
|
|
||||||
/** Number of participants in the room. */
|
/** Number of participants in the room. */
|
||||||
val roomParticipantCount: Int = 0,
|
val roomParticipantCount: Int = 0,
|
||||||
/** Participants in the room (fingerprint + optional alias). */
|
/** Participants in the room (fingerprint + optional alias). */
|
||||||
val roomParticipants: List<RoomMember> = emptyList(),
|
val roomParticipants: List<RoomMember> = emptyList(),
|
||||||
/** SAS verification code (4-digit, null if not in a call). */
|
|
||||||
val sasCode: Int? = null,
|
|
||||||
/** Incoming call ID (or "relay|room" for CallSetup). */
|
|
||||||
val incomingCallId: String? = null,
|
|
||||||
/** Incoming caller's fingerprint. */
|
|
||||||
val incomingCallerFp: String? = null,
|
|
||||||
/** Incoming caller's alias. */
|
|
||||||
val incomingCallerAlias: String? = null,
|
|
||||||
) {
|
) {
|
||||||
/** Human-readable quality label. */
|
/** Human-readable quality label. */
|
||||||
val qualityLabel: String
|
val qualityLabel: String
|
||||||
@@ -68,8 +54,7 @@ data class CallStats(
|
|||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
RoomMember(
|
RoomMember(
|
||||||
fingerprint = o.optString("fingerprint", ""),
|
fingerprint = o.optString("fingerprint", ""),
|
||||||
alias = if (o.isNull("alias")) null else o.optString("alias", null),
|
alias = if (o.isNull("alias")) null else o.optString("alias", null)
|
||||||
relayLabel = if (o.isNull("relay_label")) null else o.optString("relay_label", null)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,15 +76,8 @@ data class CallStats(
|
|||||||
underruns = obj.optLong("underruns", 0),
|
underruns = obj.optLong("underruns", 0),
|
||||||
fecRecovered = obj.optLong("fec_recovered", 0),
|
fecRecovered = obj.optLong("fec_recovered", 0),
|
||||||
audioLevel = obj.optInt("audio_level", 0),
|
audioLevel = obj.optInt("audio_level", 0),
|
||||||
currentCodec = obj.optString("current_codec", ""),
|
|
||||||
peerCodec = obj.optString("peer_codec", ""),
|
|
||||||
autoMode = obj.optBoolean("auto_mode", false),
|
|
||||||
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
roomParticipantCount = obj.optInt("room_participant_count", 0),
|
||||||
roomParticipants = parseParticipants(obj.optJSONArray("room_participants")),
|
roomParticipants = parseParticipants(obj.optJSONArray("room_participants"))
|
||||||
sasCode = if (obj.has("sas_code")) obj.optInt("sas_code") else null,
|
|
||||||
incomingCallId = if (obj.isNull("incoming_call_id")) null else obj.optString("incoming_call_id", null),
|
|
||||||
incomingCallerFp = if (obj.isNull("incoming_caller_fp")) null else obj.optString("incoming_caller_fp", null),
|
|
||||||
incomingCallerAlias = if (obj.isNull("incoming_caller_alias")) null else obj.optString("incoming_caller_alias", null),
|
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
CallStats()
|
CallStats()
|
||||||
@@ -110,8 +88,7 @@ data class CallStats(
|
|||||||
|
|
||||||
data class RoomMember(
|
data class RoomMember(
|
||||||
val fingerprint: String,
|
val fingerprint: String,
|
||||||
val alias: String? = null,
|
val alias: String? = null
|
||||||
val relayLabel: String? = null
|
|
||||||
) {
|
) {
|
||||||
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
/** Short display name: alias if set, otherwise first 8 chars of fingerprint. */
|
||||||
val displayName: String
|
val displayName: String
|
||||||
|
|||||||
@@ -38,12 +38,9 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
* @param alias display name sent to relay for room participant list
|
* @param alias display name sent to relay for room participant list
|
||||||
* @return 0 on success, negative error code on failure
|
* @return 0 on success, negative error code on failure
|
||||||
*/
|
*/
|
||||||
/**
|
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = ""): Int {
|
||||||
* @param profile 0 = Opus GOOD, 1 = Opus DEGRADED, 2 = Codec2 CATASTROPHIC
|
|
||||||
*/
|
|
||||||
fun startCall(relayAddr: String, room: String, seedHex: String = "", token: String = "", alias: String = "", profile: Int = 0): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
check(nativeHandle != 0L) { "Engine not initialized" }
|
||||||
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias, profile)
|
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token, alias)
|
||||||
if (result == 0) {
|
if (result == 0) {
|
||||||
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
callback.onCallStateChanged(CallStateConstants.CONNECTING)
|
||||||
} else {
|
} else {
|
||||||
@@ -53,7 +50,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the active call. Safe to call when no call is active. */
|
/** Stop the active call. Safe to call when no call is active. */
|
||||||
@Synchronized
|
|
||||||
fun stopCall() {
|
fun stopCall() {
|
||||||
if (nativeHandle != 0L) {
|
if (nativeHandle != 0L) {
|
||||||
nativeStopCall(nativeHandle)
|
nativeStopCall(nativeHandle)
|
||||||
@@ -77,7 +73,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
*
|
*
|
||||||
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
|
||||||
fun getStats(): String {
|
fun getStats(): String {
|
||||||
if (nativeHandle == 0L) return "{}"
|
if (nativeHandle == 0L) return "{}"
|
||||||
return try {
|
return try {
|
||||||
@@ -97,7 +92,6 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
/** Destroy the native engine and free all resources. The instance must not be reused. */
|
||||||
@Synchronized
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
if (nativeHandle != 0L) {
|
if (nativeHandle != 0L) {
|
||||||
nativeDestroy(nativeHandle)
|
nativeDestroy(nativeHandle)
|
||||||
@@ -147,7 +141,7 @@ class WzpEngine(private val callback: WzpCallback) {
|
|||||||
|
|
||||||
private external fun nativeInit(): Long
|
private external fun nativeInit(): Long
|
||||||
private external fun nativeStartCall(
|
private external fun nativeStartCall(
|
||||||
handle: Long, relay: String, room: String, seed: String, token: String, alias: String, profile: Int
|
handle: Long, relay: String, room: String, seed: String, token: String, alias: String
|
||||||
): Int
|
): Int
|
||||||
private external fun nativeStopCall(handle: Long)
|
private external fun nativeStopCall(handle: Long)
|
||||||
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
private external fun nativeSetMute(handle: Long, muted: Boolean)
|
||||||
@@ -159,59 +153,20 @@ 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)
|
||||||
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 nativePlaceCall(handle: Long, targetFp: String): Int
|
|
||||||
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ping a relay server. Requires engine to be initialized.
|
|
||||||
* Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null.
|
|
||||||
*/
|
|
||||||
fun pingRelay(address: String): String? {
|
|
||||||
if (nativeHandle == 0L) return null
|
|
||||||
return nativePingRelay(nativeHandle, address)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start persistent signaling connection for direct 1:1 calls.
|
|
||||||
* The engine registers on the relay and listens for incoming calls.
|
|
||||||
* Call state updates are available via [getStats].
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 on error
|
|
||||||
*/
|
|
||||||
fun startSignaling(relay: String, seed: String = "", token: String = "", alias: String = ""): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
||||||
return nativeStartSignaling(nativeHandle, relay, seed, token, alias)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Place a direct call to a peer by fingerprint.
|
|
||||||
* Requires [startSignaling] to have been called first.
|
|
||||||
*
|
|
||||||
* @return 0 on success, -1 on error
|
|
||||||
*/
|
|
||||||
fun placeCall(targetFingerprint: String): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
||||||
return nativePlaceCall(nativeHandle, targetFingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Answer an incoming direct call.
|
|
||||||
*
|
|
||||||
* @param callId The call ID from the incoming call (available in stats.incoming_call_id)
|
|
||||||
* @param mode 0=Reject, 1=AcceptTrusted (P2P in Phase 2), 2=AcceptGeneric (relay-mediated)
|
|
||||||
* @return 0 on success, -1 on error
|
|
||||||
*/
|
|
||||||
fun answerCall(callId: String, mode: Int = 2): Int {
|
|
||||||
check(nativeHandle != 0L) { "Engine not initialized" }
|
|
||||||
return nativeAnswerCall(nativeHandle, callId, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("wzp_android")
|
System.loadLibrary("wzp_android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping a relay server. Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}`
|
||||||
|
* or null if unreachable. Does not require an engine instance.
|
||||||
|
*/
|
||||||
|
fun pingRelay(address: String): String? = nativePingRelay(address)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private external fun nativePingRelay(relay: String): String?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.wzp.net
|
|
||||||
|
|
||||||
// Relay pinging is now done via WzpEngine.pingRelay() (instance method).
|
|
||||||
// This file kept for the data class only.
|
|
||||||
|
|
||||||
object RelayPinger {
|
|
||||||
data class PingResult(
|
|
||||||
val rttMs: Int,
|
|
||||||
val reachable: Boolean,
|
|
||||||
val serverFingerprint: String = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -31,8 +31,7 @@ data class ServerEntry(val address: String, val label: String)
|
|||||||
|
|
||||||
data class PingResult(
|
data class PingResult(
|
||||||
val rttMs: Int,
|
val rttMs: Int,
|
||||||
val serverFingerprint: String = "",
|
val serverFingerprint: String,
|
||||||
val reachable: Boolean = rttMs > 0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
enum class LockStatus { UNKNOWN, OFFLINE, NEW, VERIFIED, CHANGED }
|
||||||
@@ -106,18 +105,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _aecEnabled = MutableStateFlow(true)
|
private val _aecEnabled = MutableStateFlow(true)
|
||||||
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
val aecEnabled: StateFlow<Boolean> = _aecEnabled.asStateFlow()
|
||||||
|
|
||||||
private val _debugRecording = MutableStateFlow(false)
|
|
||||||
val debugRecording: StateFlow<Boolean> = _debugRecording.asStateFlow()
|
|
||||||
|
|
||||||
// Quality profile index (matches JNI bridge profile_from_int)
|
|
||||||
private val _codecChoice = MutableStateFlow(0)
|
|
||||||
val codecChoice: StateFlow<Int> = _codecChoice.asStateFlow()
|
|
||||||
|
|
||||||
/** Key-change warning dialog state. */
|
|
||||||
data class KeyWarningInfo(val address: String, val oldFp: String, val newFp: String)
|
|
||||||
private val _keyWarning = MutableStateFlow<KeyWarningInfo?>(null)
|
|
||||||
val keyWarning: StateFlow<KeyWarningInfo?> = _keyWarning.asStateFlow()
|
|
||||||
|
|
||||||
/** True when a call just ended and debug report can be sent. */
|
/** True when a call just ended and debug report can be sent. */
|
||||||
private val _debugReportAvailable = MutableStateFlow(false)
|
private val _debugReportAvailable = MutableStateFlow(false)
|
||||||
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
val debugReportAvailable: StateFlow<Boolean> = _debugReportAvailable.asStateFlow()
|
||||||
@@ -132,91 +119,13 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
|
|
||||||
private var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
|
|
||||||
// ── Direct calling state ──
|
|
||||||
/** 0=room mode, 1=direct call mode */
|
|
||||||
private val _callMode = MutableStateFlow(0)
|
|
||||||
val callMode: StateFlow<Int> = _callMode.asStateFlow()
|
|
||||||
|
|
||||||
/** Target fingerprint for direct call */
|
|
||||||
private val _targetFingerprint = MutableStateFlow("")
|
|
||||||
val targetFingerprint: StateFlow<String> = _targetFingerprint.asStateFlow()
|
|
||||||
|
|
||||||
/** Signal connection state: 0=idle, 5=registered, 6=ringing, 7=incoming */
|
|
||||||
private val _signalState = MutableStateFlow(0)
|
|
||||||
val signalState: StateFlow<Int> = _signalState.asStateFlow()
|
|
||||||
|
|
||||||
/** Incoming call info */
|
|
||||||
private val _incomingCallId = MutableStateFlow<String?>(null)
|
|
||||||
val incomingCallId: StateFlow<String?> = _incomingCallId.asStateFlow()
|
|
||||||
|
|
||||||
private val _incomingCallerFp = MutableStateFlow<String?>(null)
|
|
||||||
val incomingCallerFp: StateFlow<String?> = _incomingCallerFp.asStateFlow()
|
|
||||||
|
|
||||||
private val _incomingCallerAlias = MutableStateFlow<String?>(null)
|
|
||||||
val incomingCallerAlias: StateFlow<String?> = _incomingCallerAlias.asStateFlow()
|
|
||||||
|
|
||||||
fun setCallMode(mode: Int) { _callMode.value = mode }
|
|
||||||
fun setTargetFingerprint(fp: String) { _targetFingerprint.value = fp }
|
|
||||||
|
|
||||||
/** Register on relay for direct calls */
|
|
||||||
fun registerForCalls() {
|
|
||||||
if (engine == null) {
|
|
||||||
engine = WzpEngine(this).also { it.init() }
|
|
||||||
}
|
|
||||||
val serverIdx = _selectedServer.value
|
|
||||||
val serverList = _servers.value
|
|
||||||
if (serverIdx >= serverList.size) return
|
|
||||||
|
|
||||||
val relay = serverList[serverIdx].address
|
|
||||||
val seed = _seedHex.value
|
|
||||||
val alias = _alias.value
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val resolvedRelay = resolveToIp(relay) ?: relay
|
|
||||||
val result = engine?.startSignaling(resolvedRelay, seed, "", alias)
|
|
||||||
if (result == 0) {
|
|
||||||
_signalState.value = 5 // Registered
|
|
||||||
startStatsPolling()
|
|
||||||
} else {
|
|
||||||
_errorMessage.value = "Failed to register on relay"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Place a direct call to the target fingerprint */
|
|
||||||
fun placeDirectCall() {
|
|
||||||
val target = _targetFingerprint.value.trim()
|
|
||||||
if (target.isEmpty()) {
|
|
||||||
_errorMessage.value = "Enter a fingerprint to call"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engine?.placeCall(target)
|
|
||||||
_signalState.value = 6 // Ringing
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer an incoming direct call */
|
|
||||||
fun answerIncomingCall(mode: Int = 2) {
|
|
||||||
val callId = _incomingCallId.value ?: return
|
|
||||||
engine?.answerCall(callId, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reject an incoming direct call */
|
|
||||||
fun rejectIncomingCall() {
|
|
||||||
val callId = _incomingCallId.value ?: return
|
|
||||||
engine?.answerCall(callId, 0) // 0 = Reject
|
|
||||||
_signalState.value = 5 // Back to registered
|
|
||||||
_incomingCallId.value = null
|
|
||||||
_incomingCallerFp.value = null
|
|
||||||
_incomingCallerAlias.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WzpCall"
|
private const val TAG = "WzpCall"
|
||||||
val DEFAULT_SERVERS = listOf(
|
val DEFAULT_SERVERS = listOf(
|
||||||
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
||||||
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
||||||
)
|
)
|
||||||
const val DEFAULT_ROOM = "general"
|
const val DEFAULT_ROOM = "android"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setContext(context: Context) {
|
fun setContext(context: Context) {
|
||||||
@@ -250,8 +159,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
_captureGainDb.value = s.loadCaptureGain()
|
_captureGainDb.value = s.loadCaptureGain()
|
||||||
_seedHex.value = s.getOrCreateSeedHex()
|
_seedHex.value = s.getOrCreateSeedHex()
|
||||||
_aecEnabled.value = s.loadAecEnabled()
|
_aecEnabled.value = s.loadAecEnabled()
|
||||||
_debugRecording.value = s.loadDebugRecording()
|
|
||||||
_codecChoice.value = s.loadCodecChoice()
|
|
||||||
_recentRooms.value = s.loadRecentRooms()
|
_recentRooms.value = s.loadRecentRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,43 +203,35 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveSelectedServer(_selectedServer.value)
|
settings?.saveSelectedServer(_selectedServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Ping all servers in background, update results. */
|
||||||
* Ping all servers via native QUIC. Requires engine to be initialized.
|
|
||||||
* Creates engine if needed, pings, keeps engine alive for subsequent Connect.
|
|
||||||
*/
|
|
||||||
fun pingAllServers() {
|
fun pingAllServers() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Ensure engine exists
|
|
||||||
if (engine == null || engine?.isInitialized != true) {
|
|
||||||
try {
|
|
||||||
engine = WzpEngine(this@CallViewModel).also { it.init() }
|
|
||||||
engineInitialized = true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "engine init for ping failed: $e")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val eng = engine ?: return@launch
|
|
||||||
|
|
||||||
val results = mutableMapOf<String, PingResult>()
|
val results = mutableMapOf<String, PingResult>()
|
||||||
val known = mutableMapOf<String, String>()
|
val known = mutableMapOf<String, String>()
|
||||||
_servers.value.forEach { server ->
|
_servers.value.forEach { server ->
|
||||||
val json = withContext(Dispatchers.IO) {
|
val pr = withContext(Dispatchers.IO) {
|
||||||
eng.pingRelay(server.address)
|
|
||||||
}
|
|
||||||
if (json != null) {
|
|
||||||
try {
|
try {
|
||||||
|
val json = WzpEngine.pingRelay(server.address) ?: return@withContext null
|
||||||
val obj = JSONObject(json)
|
val obj = JSONObject(json)
|
||||||
val rtt = obj.getInt("rtt_ms")
|
PingResult(
|
||||||
val fp = obj.optString("server_fingerprint", "")
|
rttMs = obj.getInt("rtt_ms"),
|
||||||
results[server.address] = PingResult(rttMs = rtt, serverFingerprint = fp)
|
serverFingerprint = obj.optString("server_fingerprint", ""),
|
||||||
// TOFU
|
)
|
||||||
if (fp.isNotEmpty()) {
|
} catch (e: Exception) {
|
||||||
val saved = settings?.loadServerFingerprint(server.address)
|
Log.w(TAG, "ping ${server.address} failed: ${e.message}")
|
||||||
if (saved == null) settings?.saveServerFingerprint(server.address, fp)
|
null
|
||||||
known[server.address] = saved ?: fp
|
}
|
||||||
|
}
|
||||||
|
if (pr != null) {
|
||||||
|
results[server.address] = pr
|
||||||
|
// TOFU: save fingerprint on first contact
|
||||||
|
if (pr.serverFingerprint.isNotEmpty()) {
|
||||||
|
val saved = settings?.loadServerFingerprint(server.address)
|
||||||
|
if (saved == null) {
|
||||||
|
settings?.saveServerFingerprint(server.address, pr.serverFingerprint)
|
||||||
|
}
|
||||||
|
known[server.address] = saved ?: pr.serverFingerprint
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_pingResults.value = results
|
_pingResults.value = results
|
||||||
@@ -340,23 +239,12 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load saved TOFU fingerprints. */
|
|
||||||
fun loadSavedFingerprints() {
|
|
||||||
val known = mutableMapOf<String, String>()
|
|
||||||
_servers.value.forEach { server ->
|
|
||||||
settings?.loadServerFingerprint(server.address)?.let {
|
|
||||||
known[server.address] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_knownFingerprints.value = known
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get lock status for a server. */
|
/** Get lock status for a server. */
|
||||||
fun lockStatus(address: String): LockStatus {
|
fun lockStatus(address: String): LockStatus {
|
||||||
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
val pr = _pingResults.value[address] ?: return LockStatus.UNKNOWN
|
||||||
if (!pr.reachable) return LockStatus.OFFLINE
|
val known = _knownFingerprints.value[address]
|
||||||
val known = _knownFingerprints.value[address] ?: return LockStatus.NEW
|
|
||||||
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
if (pr.serverFingerprint.isEmpty()) return LockStatus.NEW
|
||||||
|
if (known == null) return LockStatus.NEW
|
||||||
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
return if (pr.serverFingerprint == known) LockStatus.VERIFIED else LockStatus.CHANGED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,16 +280,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
settings?.saveAecEnabled(enabled)
|
settings?.saveAecEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDebugRecording(enabled: Boolean) {
|
|
||||||
_debugRecording.value = enabled
|
|
||||||
settings?.saveDebugRecording(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCodecChoice(choice: Int) {
|
|
||||||
_codecChoice.value = choice
|
|
||||||
settings?.saveCodecChoice(choice)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
* Resolve DNS hostname to IP address on the Kotlin/Android side,
|
||||||
* since Rust's DNS resolution may not work on Android.
|
* since Rust's DNS resolution may not work on Android.
|
||||||
@@ -468,74 +346,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
Log.i(TAG, "teardown: done")
|
Log.i(TAG, "teardown: done")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accept the new server key and proceed with the call. */
|
|
||||||
fun acceptNewFingerprint() {
|
|
||||||
val info = _keyWarning.value ?: return
|
|
||||||
_knownFingerprints.value = _knownFingerprints.value.toMutableMap().also {
|
|
||||||
it[info.address] = info.newFp
|
|
||||||
}
|
|
||||||
settings?.saveServerFingerprint(info.address, info.newFp)
|
|
||||||
_keyWarning.value = null
|
|
||||||
startCallInternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissKeyWarning() {
|
|
||||||
_keyWarning.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startCall() {
|
fun startCall() {
|
||||||
val serverEntry = _servers.value[_selectedServer.value]
|
|
||||||
// Check for key change before connecting
|
|
||||||
val ls = lockStatus(serverEntry.address)
|
|
||||||
if (ls == LockStatus.CHANGED) {
|
|
||||||
val known = _knownFingerprints.value[serverEntry.address] ?: ""
|
|
||||||
val current = _pingResults.value[serverEntry.address]?.serverFingerprint ?: ""
|
|
||||||
_keyWarning.value = KeyWarningInfo(serverEntry.address, known, current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startCallInternal()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start a call to a specific relay + room (used by direct call setup). */
|
|
||||||
private fun startCallInternal(relay: String, room: String) {
|
|
||||||
Log.i(TAG, "startCallDirect: relay=$relay room=$room")
|
|
||||||
try {
|
|
||||||
// Don't teardown — keep the signal connection alive
|
|
||||||
engine = WzpEngine(this)
|
|
||||||
engine!!.init()
|
|
||||||
engineInitialized = true
|
|
||||||
_callState.value = 1
|
|
||||||
_errorMessage.value = null
|
|
||||||
try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
|
|
||||||
Log.w(TAG, "service start err: $e")
|
|
||||||
}
|
|
||||||
startStatsPolling()
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val seed = _seedHex.value
|
|
||||||
val name = _alias.value
|
|
||||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
|
||||||
CallService.onStopFromNotification = { stopCall() }
|
|
||||||
if (result != 0) {
|
|
||||||
_callState.value = 0
|
|
||||||
_errorMessage.value = "Failed to connect to call room (code $result)"
|
|
||||||
appContext?.let { CallService.stop(it) }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "startCallDirect error", e)
|
|
||||||
_callState.value = 0
|
|
||||||
_errorMessage.value = "Engine error: ${e.message}"
|
|
||||||
appContext?.let { CallService.stop(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "startCallDirect error", e)
|
|
||||||
_callState.value = 0
|
|
||||||
_errorMessage.value = "Engine error: ${e.message}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startCallInternal() {
|
|
||||||
val serverEntry = _servers.value[_selectedServer.value]
|
val serverEntry = _servers.value[_selectedServer.value]
|
||||||
val room = _roomName.value
|
val room = _roomName.value
|
||||||
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
|
||||||
@@ -566,7 +377,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
val seed = _seedHex.value
|
val seed = _seedHex.value
|
||||||
val name = _alias.value
|
val name = _alias.value
|
||||||
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
Log.i(TAG, "startCall: resolved=$relay, alias=$name, calling engine.startCall")
|
||||||
val result = engine?.startCall(relay, room, seedHex = seed, alias = name, profile = _codecChoice.value) ?: -1
|
val result = engine?.startCall(relay, room, seedHex = seed, alias = name) ?: -1
|
||||||
Log.i(TAG, "startCall: engine returned $result")
|
Log.i(TAG, "startCall: engine returned $result")
|
||||||
// Only wire up notification callback after engine is running
|
// Only wire up notification callback after engine is running
|
||||||
CallService.onStopFromNotification = { stopCall() }
|
CallService.onStopFromNotification = { stopCall() }
|
||||||
@@ -657,7 +468,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
it.playoutGainDb = _playoutGainDb.value
|
it.playoutGainDb = _playoutGainDb.value
|
||||||
it.captureGainDb = _captureGainDb.value
|
it.captureGainDb = _captureGainDb.value
|
||||||
it.aecEnabled = _aecEnabled.value
|
it.aecEnabled = _aecEnabled.value
|
||||||
it.debugRecording = _debugRecording.value
|
|
||||||
it.start(e)
|
it.start(e)
|
||||||
}
|
}
|
||||||
audioRouteManager?.register()
|
audioRouteManager?.register()
|
||||||
@@ -688,27 +498,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,60 +89,9 @@ fun InCallScreen(
|
|||||||
val pingResults by viewModel.pingResults.collectAsState()
|
val pingResults by viewModel.pingResults.collectAsState()
|
||||||
|
|
||||||
var showManageRelays by remember { mutableStateOf(false) }
|
var showManageRelays by remember { mutableStateOf(false) }
|
||||||
val keyWarning by viewModel.keyWarning.collectAsState()
|
|
||||||
|
|
||||||
// Key-change warning dialog
|
// Auto-ping on first display
|
||||||
keyWarning?.let { info ->
|
LaunchedEffect(Unit) { viewModel.pingAllServers() }
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { viewModel.dismissKeyWarning() },
|
|
||||||
title = {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text("\u26A0\uFE0F", fontSize = 40.sp)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text("Server Key Changed", fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"The relay's identity has changed since you last connected. " +
|
|
||||||
"This usually happens when the server was restarted.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text("Previously known", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(info.oldFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text("New key", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(info.newFp, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.acceptNewFingerprint() },
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFACC15))
|
|
||||||
) {
|
|
||||||
Text("Accept New Key", color = Color.Black, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { viewModel.dismissKeyWarning() }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ping once on launch, then every 5 minutes
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.loadSavedFingerprints()
|
|
||||||
viewModel.pingAllServers()
|
|
||||||
while (true) {
|
|
||||||
kotlinx.coroutines.delay(300_000) // 5 minutes
|
|
||||||
viewModel.pingAllServers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -217,40 +166,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Mode toggle: Room vs Direct Call
|
// Room
|
||||||
val callMode by viewModel.callMode.collectAsState()
|
|
||||||
val signalState by viewModel.signalState.collectAsState()
|
|
||||||
val targetFp by viewModel.targetFingerprint.collectAsState()
|
|
||||||
val incomingCallId by viewModel.incomingCallId.collectAsState()
|
|
||||||
val incomingCallerFp by viewModel.incomingCallerFp.collectAsState()
|
|
||||||
val incomingCallerAlias by viewModel.incomingCallerAlias.collectAsState()
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.setCallMode(0) },
|
|
||||||
modifier = Modifier.weight(1f).height(36.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = if (callMode == 0) Accent else Color(0xFF333333)
|
|
||||||
)
|
|
||||||
) { Text("Room", color = Color.White, fontSize = 13.sp) }
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.setCallMode(1) },
|
|
||||||
modifier = Modifier.weight(1f).height(36.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = if (callMode == 1) Accent else Color(0xFF333333)
|
|
||||||
)
|
|
||||||
) { Text("Direct Call", color = Color.White, fontSize = 13.sp) }
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
if (callMode == 0) {
|
|
||||||
// ── Room mode ──
|
|
||||||
SectionLabel("ROOM")
|
SectionLabel("ROOM")
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = roomName,
|
value = roomName,
|
||||||
@@ -261,6 +177,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Alias
|
||||||
SectionLabel("ALIAS")
|
SectionLabel("ALIAS")
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = alias,
|
value = alias,
|
||||||
@@ -271,6 +188,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// AEC + Settings
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@@ -295,6 +213,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Connect button
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.startCall() },
|
onClick = { viewModel.startCall() },
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
@@ -307,122 +226,6 @@ fun InCallScreen(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// ── Direct call mode ──
|
|
||||||
if (signalState < 5) {
|
|
||||||
// Not registered yet
|
|
||||||
SectionLabel("ALIAS")
|
|
||||||
OutlinedTextField(
|
|
||||||
value = alias,
|
|
||||||
onValueChange = { viewModel.setAlias(it) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.registerForCalls() },
|
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2196F3))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Register on Relay",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (signalState == 5) {
|
|
||||||
// Registered — show dial pad
|
|
||||||
Text(
|
|
||||||
"\u2705 Registered — waiting for calls",
|
|
||||||
color = Green,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Incoming call notification
|
|
||||||
if (incomingCallId != null && incomingCallerFp != null) {
|
|
||||||
Surface(
|
|
||||||
color = Color(0xFF1B5E20),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
"Incoming Call",
|
|
||||||
color = Color.White,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"From: ${incomingCallerAlias ?: incomingCallerFp?.take(16) ?: "unknown"}",
|
|
||||||
color = Color.White.copy(alpha = 0.8f),
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.answerIncomingCall(2) },
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Green),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) { Text("Accept", color = Color.White) }
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.rejectIncomingCall() },
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Red),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) { Text("Reject", color = Color.White) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionLabel("CALL BY FINGERPRINT")
|
|
||||||
OutlinedTextField(
|
|
||||||
value = targetFp,
|
|
||||||
onValueChange = { viewModel.setTargetFingerprint(it) },
|
|
||||||
singleLine = true,
|
|
||||||
placeholder = { Text("Paste fingerprint (xxxx:xxxx:...)") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.placeDirectCall() },
|
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Accent),
|
|
||||||
enabled = targetFp.isNotBlank()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Call",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (signalState == 6) {
|
|
||||||
// Ringing
|
|
||||||
Text(
|
|
||||||
"\uD83D\uDD14 Ringing...",
|
|
||||||
color = Yellow,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
} else if (signalState == 7) {
|
|
||||||
// Incoming call (state 7 also handled above in registered view)
|
|
||||||
Text(
|
|
||||||
"\uD83D\uDCDE Incoming call...",
|
|
||||||
color = Green,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
errorMessage?.let { err ->
|
errorMessage?.let { err ->
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -557,29 +360,7 @@ fun InCallScreen(
|
|||||||
if (stats.roomParticipantCount > 0) {
|
if (stats.roomParticipantCount > 0) {
|
||||||
val unique = stats.roomParticipants
|
val unique = stats.roomParticipants
|
||||||
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
|
.distinctBy { it.fingerprint.ifEmpty { it.displayName } }
|
||||||
// Group by relay
|
unique.forEach { member ->
|
||||||
val grouped = unique.groupBy { it.relayLabel ?: "This Relay" }
|
|
||||||
grouped.forEach { (relay, members) ->
|
|
||||||
// Relay header
|
|
||||||
val isLocal = relay == "This Relay"
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(6.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (isLocal) Green else Color(0xFF60A5FA))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = relay.uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 0.5.sp),
|
|
||||||
color = TextDim
|
|
||||||
)
|
|
||||||
}
|
|
||||||
members.forEach { member ->
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
@@ -608,7 +389,6 @@ fun InCallScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "Waiting for participants...",
|
text = "Waiting for participants...",
|
||||||
@@ -632,51 +412,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Codec + Stats
|
// Stats
|
||||||
if (stats.currentCodec.isNotEmpty()) {
|
|
||||||
val codecLabel = formatCodecName(stats.currentCodec)
|
|
||||||
val peerLabel = if (stats.peerCodec.isNotEmpty()) formatCodecName(stats.peerCodec) else null
|
|
||||||
val autoTag = if (stats.autoMode) " [Auto]" else ""
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Our codec badge
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
color = codecColor(stats.currentCodec)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "TX $codecLabel$autoTag",
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 10.sp
|
|
||||||
),
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (peerLabel != null) {
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
color = codecColor(stats.peerCodec)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "RX $peerLabel",
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 10.sp
|
|
||||||
),
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = "TX: ${stats.framesEncoded} | RX: ${stats.framesDecoded}",
|
text = "TX: ${stats.framesEncoded} | RX: ${stats.framesDecoded}",
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
@@ -698,7 +434,6 @@ fun InCallScreen(
|
|||||||
onSelect = { idx -> viewModel.selectServer(idx) },
|
onSelect = { idx -> viewModel.selectServer(idx) },
|
||||||
onDelete = { idx -> viewModel.removeServer(idx) },
|
onDelete = { idx -> viewModel.removeServer(idx) },
|
||||||
onAdd = { addr, label -> viewModel.addServer(addr, label) },
|
onAdd = { addr, label -> viewModel.addServer(addr, label) },
|
||||||
onRefresh = { viewModel.pingAllServers() },
|
|
||||||
onDismiss = { showManageRelays = false }
|
onDismiss = { showManageRelays = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -727,7 +462,6 @@ private fun ManageRelaysDialog(
|
|||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int) -> Unit,
|
||||||
onDelete: (Int) -> Unit,
|
onDelete: (Int) -> Unit,
|
||||||
onAdd: (String, String) -> Unit,
|
onAdd: (String, String) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
var addName by remember { mutableStateOf("") }
|
var addName by remember { mutableStateOf("") }
|
||||||
@@ -743,17 +477,6 @@ private fun ManageRelaysDialog(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Manage Relays", color = Color.White, fontWeight = FontWeight.Bold)
|
Text("Manage Relays", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
Surface(
|
|
||||||
onClick = onRefresh,
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
color = DarkSurface2,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Text("\u21BB", color = TextDim, fontSize = 16.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
@@ -765,7 +488,6 @@ private fun ManageRelaysDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
@@ -817,17 +539,13 @@ private fun ManageRelaysDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Surface(
|
Text(
|
||||||
onClick = { onDelete(idx) },
|
"\u00D7",
|
||||||
shape = RoundedCornerShape(4.dp),
|
color = TextDim,
|
||||||
color = Color.Transparent,
|
fontSize = 18.sp,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.clickable { onDelete(idx) }
|
||||||
) {
|
)
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Text("\u00D7", color = TextDim, fontSize = 18.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1038,25 +756,3 @@ private fun DebugReportCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map Rust CodecId debug name to a human-readable label. */
|
|
||||||
private fun formatCodecName(codecId: String): String = when (codecId) {
|
|
||||||
"Opus64k" -> "Opus 64k"
|
|
||||||
"Opus48k" -> "Opus 48k"
|
|
||||||
"Opus32k" -> "Opus 32k"
|
|
||||||
"Opus24k" -> "Opus 24k"
|
|
||||||
"Opus16k" -> "Opus 16k"
|
|
||||||
"Opus6k" -> "Opus 6k"
|
|
||||||
"Codec2_3200" -> "C2 3.2k"
|
|
||||||
"Codec2_1200" -> "C2 1.2k"
|
|
||||||
else -> codecId
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Color-code codec badges by quality tier. */
|
|
||||||
private fun codecColor(codecId: String): Color = when (codecId) {
|
|
||||||
"Opus64k", "Opus48k", "Opus32k" -> Color(0xFF0D6EFD) // blue — studio
|
|
||||||
"Opus24k", "Opus16k" -> Color(0xFF198754) // green — good
|
|
||||||
"Opus6k" -> Color(0xFFCC8800) // amber — degraded
|
|
||||||
"Codec2_3200", "Codec2_1200" -> Color(0xFFDC3545) // red — catastrophic
|
|
||||||
else -> Color(0xFF6C757D) // gray
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.wzp.ui.settings
|
package com.wzp.ui.settings
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -23,7 +22,6 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
@@ -243,51 +241,6 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Quality selection — slider from best (studio 64k) to worst (codec2 1.2k) + auto
|
|
||||||
val qualityLabels = listOf(
|
|
||||||
"Studio 64k", "Studio 48k", "Studio 32k", "Auto",
|
|
||||||
"Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"
|
|
||||||
)
|
|
||||||
// Map slider position to JNI profile int:
|
|
||||||
// 0=Studio64k(6), 1=Studio48k(5), 2=Studio32k(4), 3=Auto(7),
|
|
||||||
// 4=Opus24k(0), 5=Opus6k(1), 6=Codec2_3.2k(3), 7=Codec2_1.2k(2)
|
|
||||||
val sliderToProfile = intArrayOf(6, 5, 4, 7, 0, 1, 3, 2)
|
|
||||||
val profileToSlider = mapOf(6 to 0, 5 to 1, 4 to 2, 7 to 3, 0 to 4, 1 to 5, 3 to 6, 2 to 7)
|
|
||||||
val qualityColors = listOf(
|
|
||||||
Color(0xFF22C55E), Color(0xFF4ADE80), Color(0xFF86EFAC), Color(0xFFA3E635),
|
|
||||||
Color(0xFFA3E635), Color(0xFFFACC15), Color(0xFFE97320), Color(0xFF991B1B)
|
|
||||||
)
|
|
||||||
val currentCodec by viewModel.codecChoice.collectAsState()
|
|
||||||
val sliderPos = profileToSlider[currentCodec] ?: 3
|
|
||||||
Text("Quality", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Text(
|
|
||||||
text = "Decode always accepts all codecs",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = qualityLabels[sliderPos],
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = qualityColors[sliderPos]
|
|
||||||
)
|
|
||||||
Slider(
|
|
||||||
value = sliderPos.toFloat(),
|
|
||||||
onValueChange = { viewModel.setCodecChoice(sliderToProfile[it.toInt()]) },
|
|
||||||
valueRange = 0f..7f,
|
|
||||||
steps = 6,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text("Best", style = MaterialTheme.typography.labelSmall, color = Color(0xFF22C55E))
|
|
||||||
Text("Lowest", style = MaterialTheme.typography.labelSmall, color = Color(0xFF991B1B))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Divider()
|
Divider()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ wzp-crypto = { workspace = true }
|
|||||||
wzp-transport = { workspace = true }
|
wzp-transport = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -28,7 +27,9 @@ libc = "0.2"
|
|||||||
jni = { version = "0.21", default-features = false }
|
jni = { version = "0.21", default-features = false }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||||
tracing-android = "0.2"
|
android_logger = "0.14"
|
||||||
|
log = "0.4"
|
||||||
|
tracing-log = "0.2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
cc = "1"
|
cc = "1"
|
||||||
|
|||||||
@@ -12,13 +12,4 @@ pub enum EngineCommand {
|
|||||||
ForceProfile(QualityProfile),
|
ForceProfile(QualityProfile),
|
||||||
/// Stop the call and shut down the engine.
|
/// Stop the call and shut down the engine.
|
||||||
Stop,
|
Stop,
|
||||||
/// Place a direct call to a fingerprint (requires signal connection).
|
|
||||||
PlaceCall { target_fingerprint: String },
|
|
||||||
/// Answer an incoming direct call.
|
|
||||||
AnswerCall {
|
|
||||||
call_id: String,
|
|
||||||
accept_mode: wzp_proto::CallAcceptMode,
|
|
||||||
},
|
|
||||||
/// Reject an incoming direct call.
|
|
||||||
RejectCall { call_id: String },
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,60 +9,32 @@
|
|||||||
//! and AudioTrack. PCM samples are transferred through lock-free ring buffers.
|
//! and AudioTrack. PCM samples are transferred through lock-free ring buffers.
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use wzp_codec::AdaptiveDecoder;
|
|
||||||
use wzp_codec::agc::AutoGainControl;
|
use wzp_codec::agc::AutoGainControl;
|
||||||
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState};
|
use wzp_codec::opus_dec::OpusDecoder;
|
||||||
|
use wzp_codec::opus_enc::OpusEncoder;
|
||||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::{
|
use wzp_proto::{
|
||||||
AdaptiveQualityController, AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder,
|
||||||
MediaHeader, MediaPacket, MediaTransport, QualityController, QualityProfile, SignalMessage,
|
MediaHeader, MediaPacket, MediaTransport, QualityProfile, SignalMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
use crate::audio_ring::AudioRing;
|
||||||
use crate::commands::EngineCommand;
|
use crate::commands::EngineCommand;
|
||||||
use crate::stats::{CallState, CallStats};
|
use crate::stats::{CallState, CallStats};
|
||||||
|
|
||||||
/// Max frame size at 48kHz mono (40ms = 1920 samples, for Codec2/Opus6k).
|
/// Opus frame size at 48kHz mono, 20ms = 960 samples.
|
||||||
const MAX_FRAME_SAMPLES: usize = 1920;
|
const FRAME_SAMPLES: usize = 960;
|
||||||
|
|
||||||
/// Sentinel value: no profile change pending.
|
|
||||||
const PROFILE_NO_CHANGE: u8 = 0xFF;
|
|
||||||
|
|
||||||
/// All quality profiles in index order, for AtomicU8-based signaling.
|
|
||||||
const PROFILES: [QualityProfile; 6] = [
|
|
||||||
QualityProfile::STUDIO_64K, // 0
|
|
||||||
QualityProfile::STUDIO_48K, // 1
|
|
||||||
QualityProfile::STUDIO_32K, // 2
|
|
||||||
QualityProfile::GOOD, // 3
|
|
||||||
QualityProfile::DEGRADED, // 4
|
|
||||||
QualityProfile::CATASTROPHIC, // 5
|
|
||||||
];
|
|
||||||
|
|
||||||
fn profile_to_index(p: &QualityProfile) -> u8 {
|
|
||||||
PROFILES.iter().position(|pp| pp.codec == p.codec).map(|i| i as u8).unwrap_or(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
|
|
||||||
PROFILES.get(idx as usize).copied()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute frame samples at 48kHz for a given profile.
|
|
||||||
fn frame_samples_for(profile: &QualityProfile) -> usize {
|
|
||||||
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration to start a call.
|
/// Configuration to start a call.
|
||||||
pub struct CallStartConfig {
|
pub struct CallStartConfig {
|
||||||
pub profile: QualityProfile,
|
pub profile: QualityProfile,
|
||||||
/// When true, use the relay's chosen_profile from CallAnswer instead of local profile.
|
|
||||||
pub auto_profile: bool,
|
|
||||||
pub relay_addr: String,
|
pub relay_addr: String,
|
||||||
pub room: String,
|
pub room: String,
|
||||||
pub auth_token: Vec<u8>,
|
pub auth_token: Vec<u8>,
|
||||||
@@ -74,7 +46,6 @@ impl Default for CallStartConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
profile: QualityProfile::GOOD,
|
profile: QualityProfile::GOOD,
|
||||||
auto_profile: false,
|
|
||||||
relay_addr: String::new(),
|
relay_addr: String::new(),
|
||||||
room: String::new(),
|
room: String::new(),
|
||||||
auth_token: Vec::new(),
|
auth_token: Vec::new(),
|
||||||
@@ -152,7 +123,6 @@ impl WzpEngine {
|
|||||||
let room = config.room.clone();
|
let room = config.room.clone();
|
||||||
let identity_seed = config.identity_seed;
|
let identity_seed = config.identity_seed;
|
||||||
let profile = config.profile;
|
let profile = config.profile;
|
||||||
let auto_profile = config.auto_profile;
|
|
||||||
let alias = config.alias.clone();
|
let alias = config.alias.clone();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
@@ -161,7 +131,7 @@ impl WzpEngine {
|
|||||||
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
runtime.block_on(async move {
|
runtime.block_on(async move {
|
||||||
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, auto_profile, alias.as_deref(), state_clone).await
|
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, alias.as_deref(), state_clone).await
|
||||||
{
|
{
|
||||||
error!("call failed: {e}");
|
error!("call failed: {e}");
|
||||||
}
|
}
|
||||||
@@ -199,203 +169,6 @@ impl WzpEngine {
|
|||||||
info!("stop_call: done");
|
info!("stop_call: done");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ping a relay — same pattern as start_call (creates runtime on calling thread).
|
|
||||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or error.
|
|
||||||
pub fn ping_relay(&self, address: &str) -> Result<String, anyhow::Error> {
|
|
||||||
let addr: SocketAddr = address.parse()?;
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
let result = rt.block_on(async {
|
|
||||||
let bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
|
||||||
let endpoint = wzp_transport::create_endpoint(bind, None)?;
|
|
||||||
let client_cfg = wzp_transport::client_config();
|
|
||||||
let start = 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");
|
|
||||||
|
|
||||||
let conn = conn_result.map_err(|_| anyhow::anyhow!("timeout"))??;
|
|
||||||
let rtt_ms = start.elapsed().as_millis() as u64;
|
|
||||||
let server_fp = conn
|
|
||||||
.peer_identity()
|
|
||||||
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
|
|
||||||
.and_then(|certs| certs.first().map(|c| {
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
c.as_ref().hash(&mut h);
|
|
||||||
format!("{:016x}", h.finish())
|
|
||||||
}))
|
|
||||||
.unwrap_or_default();
|
|
||||||
conn.close(0u32.into(), b"ping");
|
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>(format!(r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#, rtt_ms, server_fp))
|
|
||||||
});
|
|
||||||
|
|
||||||
// Shutdown runtime cleanly with timeout
|
|
||||||
rt.shutdown_timeout(std::time::Duration::from_millis(500));
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start persistent signaling connection for direct calls.
|
|
||||||
/// Spawns a background task that maintains the `_signal` connection.
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -454,7 +227,6 @@ async fn run_call(
|
|||||||
room: &str,
|
room: &str,
|
||||||
identity_seed: &[u8; 32],
|
identity_seed: &[u8; 32],
|
||||||
profile: QualityProfile,
|
profile: QualityProfile,
|
||||||
auto_profile: bool,
|
|
||||||
alias: Option<&str>,
|
alias: Option<&str>,
|
||||||
state: Arc<EngineState>,
|
state: Arc<EngineState>,
|
||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
@@ -489,9 +261,6 @@ async fn run_call(
|
|||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles: vec![
|
supported_profiles: vec![
|
||||||
QualityProfile::STUDIO_64K,
|
|
||||||
QualityProfile::STUDIO_48K,
|
|
||||||
QualityProfile::STUDIO_32K,
|
|
||||||
QualityProfile::GOOD,
|
QualityProfile::GOOD,
|
||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
@@ -506,8 +275,8 @@ async fn run_call(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
|
||||||
|
|
||||||
let (relay_ephemeral_pub, chosen_profile) = match answer {
|
let relay_ephemeral_pub = match answer {
|
||||||
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile),
|
SignalMessage::CallAnswer { ephemeral_pub, .. } => ephemeral_pub,
|
||||||
other => {
|
other => {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"expected CallAnswer, got {:?}",
|
"expected CallAnswer, got {:?}",
|
||||||
@@ -516,28 +285,19 @@ async fn run_call(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto mode: use the relay's chosen profile instead of the local preference
|
|
||||||
let profile = if auto_profile {
|
|
||||||
info!(chosen = ?chosen_profile.codec, "auto mode: using relay's chosen profile");
|
|
||||||
chosen_profile
|
|
||||||
} else {
|
|
||||||
profile
|
|
||||||
};
|
|
||||||
|
|
||||||
let _session = kx.derive_session(&relay_ephemeral_pub)?;
|
let _session = kx.derive_session(&relay_ephemeral_pub)?;
|
||||||
info!(codec = ?profile.codec, "handshake complete, call active");
|
info!("handshake complete, call active");
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut stats = state.stats.lock().unwrap();
|
let mut stats = state.stats.lock().unwrap();
|
||||||
stats.state = CallState::Active;
|
stats.state = CallState::Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize codec (Opus or Codec2 based on profile).
|
// Initialize Opus codec
|
||||||
// Phase 3c: decoder is a concrete AdaptiveDecoder (not Box<dyn
|
let mut encoder =
|
||||||
// AudioDecoder>) so the recv task can call reconstruct_from_dred on
|
OpusEncoder::new(profile).map_err(|e| anyhow::anyhow!("opus encoder init: {e}"))?;
|
||||||
// gaps detected via sequence tracking.
|
let mut decoder =
|
||||||
let mut encoder = wzp_codec::create_encoder(profile);
|
OpusDecoder::new(profile).map_err(|e| anyhow::anyhow!("opus decoder init: {e}"))?;
|
||||||
let mut decoder = AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder");
|
|
||||||
|
|
||||||
// Initialize FEC encoder/decoder
|
// Initialize FEC encoder/decoder
|
||||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||||
@@ -547,37 +307,21 @@ async fn run_call(
|
|||||||
let mut capture_agc = AutoGainControl::new();
|
let mut capture_agc = AutoGainControl::new();
|
||||||
let mut playout_agc = AutoGainControl::new();
|
let mut playout_agc = AutoGainControl::new();
|
||||||
|
|
||||||
let mut frame_samples = frame_samples_for(&profile);
|
|
||||||
info!(
|
info!(
|
||||||
codec = ?profile.codec,
|
|
||||||
fec_ratio = profile.fec_ratio,
|
fec_ratio = profile.fec_ratio,
|
||||||
frames_per_block = profile.frames_per_block,
|
frames_per_block = profile.frames_per_block,
|
||||||
frame_ms = profile.frame_duration_ms,
|
"codec + FEC + AGC initialized (48kHz mono, 20ms frames)"
|
||||||
frame_samples,
|
|
||||||
"codec + FEC + AGC initialized"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
{
|
|
||||||
let mut stats = state.stats.lock().unwrap();
|
|
||||||
stats.current_codec = format!("{:?}", profile.codec);
|
|
||||||
stats.auto_mode = auto_profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
let seq = AtomicU16::new(0);
|
let seq = AtomicU16::new(0);
|
||||||
let ts = AtomicU32::new(0);
|
let ts = AtomicU32::new(0);
|
||||||
let transport_recv = transport.clone();
|
let transport_recv = transport.clone();
|
||||||
|
|
||||||
// Adaptive quality: shared AtomicU8 between recv task (writer) and send task (reader).
|
// Pre-allocate buffers
|
||||||
// 0xFF = no change pending, 0-5 = index into PROFILES array.
|
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
|
||||||
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
|
|
||||||
let pending_profile_recv = pending_profile.clone();
|
|
||||||
|
|
||||||
// Pre-allocate buffers (sized for current profile)
|
|
||||||
let mut capture_buf = vec![0i16; frame_samples];
|
|
||||||
let mut encode_buf = vec![0u8; encoder.max_frame_bytes()];
|
let mut encode_buf = vec![0u8; encoder.max_frame_bytes()];
|
||||||
let mut frame_in_block: u8 = 0;
|
let mut frame_in_block: u8 = 0;
|
||||||
let mut block_id: u8 = 0;
|
let mut block_id: u8 = 0;
|
||||||
let mut current_profile = profile;
|
|
||||||
|
|
||||||
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
// Send task: capture ring → Opus encode → FEC → MediaPackets
|
||||||
//
|
//
|
||||||
@@ -603,47 +347,14 @@ async fn run_call(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for adaptive profile switch from recv task
|
|
||||||
if auto_profile {
|
|
||||||
let p = pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
|
|
||||||
if p != PROFILE_NO_CHANGE {
|
|
||||||
if let Some(new_profile) = index_to_profile(p) {
|
|
||||||
info!(
|
|
||||||
from = ?current_profile.codec,
|
|
||||||
to = ?new_profile.codec,
|
|
||||||
"auto: switching encoder profile"
|
|
||||||
);
|
|
||||||
if let Err(e) = encoder.set_profile(new_profile) {
|
|
||||||
warn!("encoder set_profile failed: {e}");
|
|
||||||
} else {
|
|
||||||
fec_enc = wzp_fec::create_encoder(&new_profile);
|
|
||||||
current_profile = new_profile;
|
|
||||||
let new_frame_samples = frame_samples_for(&new_profile);
|
|
||||||
if new_frame_samples != frame_samples {
|
|
||||||
frame_samples = new_frame_samples;
|
|
||||||
capture_buf.resize(frame_samples, 0);
|
|
||||||
}
|
|
||||||
encode_buf.resize(encoder.max_frame_bytes(), 0);
|
|
||||||
// Reset FEC block state for clean switch
|
|
||||||
frame_in_block = 0;
|
|
||||||
block_id = block_id.wrapping_add(1);
|
|
||||||
// Update stats with new codec
|
|
||||||
if let Ok(mut stats) = state.stats.lock() {
|
|
||||||
stats.current_codec = format!("{:?}", new_profile.codec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let avail = state.capture_ring.available();
|
let avail = state.capture_ring.available();
|
||||||
if avail < frame_samples {
|
if avail < FRAME_SAMPLES {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let read = state.capture_ring.read(&mut capture_buf);
|
let read = state.capture_ring.read(&mut capture_buf);
|
||||||
if read < frame_samples {
|
if read < FRAME_SAMPLES {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,34 +381,21 @@ async fn run_call(
|
|||||||
t_opus_us += t0.elapsed().as_micros() as u64;
|
t_opus_us += t0.elapsed().as_micros() as u64;
|
||||||
let encoded = &encode_buf[..encoded_len];
|
let encoded = &encode_buf[..encoded_len];
|
||||||
|
|
||||||
// Phase 2: Opus tiers bypass RaptorQ (DRED handles loss recovery
|
|
||||||
// at the codec layer). Codec2 tiers keep RaptorQ unchanged.
|
|
||||||
let is_opus = current_profile.codec.is_opus();
|
|
||||||
let (hdr_fec_block, hdr_fec_symbol, hdr_fec_ratio) = if is_opus {
|
|
||||||
(0u8, 0u8, 0u8)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
block_id,
|
|
||||||
frame_in_block,
|
|
||||||
MediaHeader::encode_fec_ratio(current_profile.fec_ratio),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build source packet
|
// Build source packet
|
||||||
let s = seq.fetch_add(1, Ordering::Relaxed);
|
let s = seq.fetch_add(1, Ordering::Relaxed);
|
||||||
let t = ts.fetch_add(frame_samples as u32, Ordering::Relaxed);
|
let t = ts.fetch_add(FRAME_SAMPLES as u32, Ordering::Relaxed);
|
||||||
|
|
||||||
let source_pkt = MediaPacket {
|
let source_pkt = MediaPacket {
|
||||||
header: MediaHeader {
|
header: MediaHeader {
|
||||||
version: 0,
|
version: 0,
|
||||||
is_repair: false,
|
is_repair: false,
|
||||||
codec_id: current_profile.codec,
|
codec_id: profile.codec,
|
||||||
has_quality_report: false,
|
has_quality_report: false,
|
||||||
fec_ratio_encoded: hdr_fec_ratio,
|
fec_ratio_encoded: MediaHeader::encode_fec_ratio(profile.fec_ratio),
|
||||||
seq: s,
|
seq: s,
|
||||||
timestamp: t,
|
timestamp: t,
|
||||||
fec_block: hdr_fec_block,
|
fec_block: block_id,
|
||||||
fec_symbol: hdr_fec_symbol,
|
fec_symbol: frame_in_block,
|
||||||
reserved: 0,
|
reserved: 0,
|
||||||
csrc_count: 0,
|
csrc_count: 0,
|
||||||
},
|
},
|
||||||
@@ -727,18 +425,16 @@ async fn run_call(
|
|||||||
t_send_us += t0.elapsed().as_micros() as u64;
|
t_send_us += t0.elapsed().as_micros() as u64;
|
||||||
frames_sent += 1;
|
frames_sent += 1;
|
||||||
|
|
||||||
// Codec2-only: feed RaptorQ and emit repair packets when the
|
// Feed encoded frame to FEC encoder
|
||||||
// block is full. Opus tiers skip this entire block — DRED
|
|
||||||
// (enabled in Phase 1) provides codec-layer loss recovery.
|
|
||||||
let t0 = Instant::now();
|
let t0 = Instant::now();
|
||||||
if !is_opus {
|
|
||||||
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
if let Err(e) = fec_enc.add_source_symbol(encoded) {
|
||||||
warn!("fec add_source error: {e}");
|
warn!("fec add_source error: {e}");
|
||||||
}
|
}
|
||||||
frame_in_block += 1;
|
frame_in_block += 1;
|
||||||
|
|
||||||
if frame_in_block >= current_profile.frames_per_block {
|
// When block is full, generate repair packets
|
||||||
match fec_enc.generate_repair(current_profile.fec_ratio) {
|
if frame_in_block >= profile.frames_per_block {
|
||||||
|
match fec_enc.generate_repair(profile.fec_ratio) {
|
||||||
Ok(repairs) => {
|
Ok(repairs) => {
|
||||||
let repair_count = repairs.len();
|
let repair_count = repairs.len();
|
||||||
for (sym_idx, repair_data) in repairs {
|
for (sym_idx, repair_data) in repairs {
|
||||||
@@ -747,10 +443,10 @@ async fn run_call(
|
|||||||
header: MediaHeader {
|
header: MediaHeader {
|
||||||
version: 0,
|
version: 0,
|
||||||
is_repair: true,
|
is_repair: true,
|
||||||
codec_id: current_profile.codec,
|
codec_id: profile.codec,
|
||||||
has_quality_report: false,
|
has_quality_report: false,
|
||||||
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
fec_ratio_encoded: MediaHeader::encode_fec_ratio(
|
||||||
current_profile.fec_ratio,
|
profile.fec_ratio,
|
||||||
),
|
),
|
||||||
seq: rs,
|
seq: rs,
|
||||||
timestamp: t,
|
timestamp: t,
|
||||||
@@ -773,7 +469,7 @@ async fn run_call(
|
|||||||
info!(
|
info!(
|
||||||
block_id,
|
block_id,
|
||||||
repair_count,
|
repair_count,
|
||||||
fec_ratio = current_profile.fec_ratio,
|
fec_ratio = profile.fec_ratio,
|
||||||
"FEC block complete"
|
"FEC block complete"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -787,7 +483,6 @@ async fn run_call(
|
|||||||
block_id = block_id.wrapping_add(1);
|
block_id = block_id.wrapping_add(1);
|
||||||
frame_in_block = 0;
|
frame_in_block = 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
t_fec_us += t0.elapsed().as_micros() as u64;
|
t_fec_us += t0.elapsed().as_micros() as u64;
|
||||||
t_frames += 1;
|
t_frames += 1;
|
||||||
|
|
||||||
@@ -816,8 +511,8 @@ async fn run_call(
|
|||||||
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
info!(frames_sent, frames_dropped, send_errors, "send task ended");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pre-allocate decode buffer (max size to handle any incoming codec)
|
// Pre-allocate decode buffer
|
||||||
let mut decode_buf = vec![0i16; MAX_FRAME_SAMPLES];
|
let mut decode_buf = vec![0i16; FRAME_SAMPLES];
|
||||||
|
|
||||||
// Recv task: MediaPackets → FEC decode → Opus decode → playout ring
|
// Recv task: MediaPackets → FEC decode → Opus decode → playout ring
|
||||||
let recv_task = async {
|
let recv_task = async {
|
||||||
@@ -827,29 +522,7 @@ async fn run_call(
|
|||||||
let mut last_recv_instant = Instant::now();
|
let mut last_recv_instant = Instant::now();
|
||||||
let mut max_recv_gap_ms: u64 = 0;
|
let mut max_recv_gap_ms: u64 = 0;
|
||||||
let mut last_stats_log = Instant::now();
|
let mut last_stats_log = Instant::now();
|
||||||
let mut quality_ctrl = AdaptiveQualityController::new();
|
info!("recv task started (Opus + RaptorQ FEC)");
|
||||||
let mut last_peer_codec: Option<CodecId> = None;
|
|
||||||
|
|
||||||
// Phase 3c: DRED reconstruction state. Unlike the desktop
|
|
||||||
// CallDecoder (which sits behind a jitter buffer that emits
|
|
||||||
// Missing signals), engine.rs reads packets directly from the
|
|
||||||
// transport and decodes straight into the playout ring. Gap
|
|
||||||
// detection is therefore done via sequence-number tracking:
|
|
||||||
// when a packet arrives with seq > expected_seq, the frames in
|
|
||||||
// between are missing and we attempt to reconstruct them via
|
|
||||||
// DRED before decoding the newly-arrived packet.
|
|
||||||
let mut dred_decoder =
|
|
||||||
DredDecoderHandle::new().expect("opus_dred_decoder_create failed");
|
|
||||||
let mut dred_parse_scratch =
|
|
||||||
DredState::new().expect("opus_dred_alloc failed (scratch)");
|
|
||||||
let mut last_good_dred =
|
|
||||||
DredState::new().expect("opus_dred_alloc failed (good state)");
|
|
||||||
let mut last_good_dred_seq: Option<u16> = None;
|
|
||||||
let mut expected_seq: Option<u16> = None;
|
|
||||||
let mut dred_reconstructions: u64 = 0;
|
|
||||||
let mut classical_plc_invocations: u64 = 0;
|
|
||||||
|
|
||||||
info!("recv task started (Opus + DRED + Codec2/RaptorQ)");
|
|
||||||
loop {
|
loop {
|
||||||
if !state.running.load(Ordering::Relaxed) {
|
if !state.running.load(Ordering::Relaxed) {
|
||||||
break;
|
break;
|
||||||
@@ -871,181 +544,20 @@ async fn run_call(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adaptive quality: ingest quality reports from relay
|
|
||||||
if auto_profile {
|
|
||||||
if let Some(ref qr) = pkt.quality_report {
|
|
||||||
if let Some(new_profile) = quality_ctrl.observe(qr) {
|
|
||||||
let idx = profile_to_index(&new_profile);
|
|
||||||
info!(
|
|
||||||
loss = qr.loss_percent(),
|
|
||||||
rtt = qr.rtt_ms(),
|
|
||||||
tier = ?quality_ctrl.tier(),
|
|
||||||
to = ?new_profile.codec,
|
|
||||||
"auto: quality adapter recommends switch"
|
|
||||||
);
|
|
||||||
pending_profile_recv.store(idx, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_repair = pkt.header.is_repair;
|
let is_repair = pkt.header.is_repair;
|
||||||
let pkt_block = pkt.header.fec_block;
|
let pkt_block = pkt.header.fec_block;
|
||||||
let pkt_symbol = pkt.header.fec_symbol;
|
let pkt_symbol = pkt.header.fec_symbol;
|
||||||
let pkt_is_opus = pkt.header.codec_id.is_opus();
|
|
||||||
|
|
||||||
// Phase 2: Opus packets bypass RaptorQ entirely — DRED
|
// Feed every packet (source + repair) to FEC decoder
|
||||||
// (enabled Phase 1) handles codec-layer loss recovery,
|
|
||||||
// and feeding these symbols into the RaptorQ decoder
|
|
||||||
// would accumulate block_id=0 duplicates that never
|
|
||||||
// decode. Codec2 packets still feed RaptorQ.
|
|
||||||
if !pkt_is_opus {
|
|
||||||
let _ = fec_dec.add_symbol(
|
let _ = fec_dec.add_symbol(
|
||||||
pkt_block,
|
pkt_block,
|
||||||
pkt_symbol,
|
pkt_symbol,
|
||||||
is_repair,
|
is_repair,
|
||||||
&pkt.payload,
|
&pkt.payload,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Source packets: decode directly
|
// Source packets: decode directly
|
||||||
if !is_repair && pkt.header.codec_id != CodecId::ComfortNoise {
|
if !is_repair {
|
||||||
// Switch decoder to match incoming codec if different
|
|
||||||
if pkt.header.codec_id != decoder.codec_id() {
|
|
||||||
let switch_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 = ?decoder.codec_id(), to = ?pkt.header.codec_id, "recv: switching decoder");
|
|
||||||
let _ = decoder.set_profile(switch_profile);
|
|
||||||
// Profile switch invalidates the cached DRED
|
|
||||||
// state because samples_available is measured
|
|
||||||
// in the old profile's sample rate. Reset the
|
|
||||||
// tracking so we don't try to reconstruct with
|
|
||||||
// stale offsets.
|
|
||||||
last_good_dred_seq = None;
|
|
||||||
expected_seq = None;
|
|
||||||
}
|
|
||||||
// Track peer codec for UI display
|
|
||||||
if last_peer_codec != Some(pkt.header.codec_id) {
|
|
||||||
last_peer_codec = Some(pkt.header.codec_id);
|
|
||||||
if let Ok(mut stats) = state.stats.lock() {
|
|
||||||
stats.peer_codec = format!("{:?}", pkt.header.codec_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3c: Opus path — parse DRED state out of
|
|
||||||
// the current packet FIRST so last_good_dred
|
|
||||||
// reflects the freshest available reconstruction
|
|
||||||
// source, then attempt gap recovery against it
|
|
||||||
// BEFORE decoding this packet's audio. Ordering
|
|
||||||
// matters because the playout ring is FIFO — gap
|
|
||||||
// samples must be written before this packet's
|
|
||||||
// samples, which come next.
|
|
||||||
if pkt_is_opus {
|
|
||||||
// Update DRED state from the current packet.
|
|
||||||
match dred_decoder.parse_into(&mut dred_parse_scratch, &pkt.payload) {
|
|
||||||
Ok(available) if available > 0 => {
|
|
||||||
std::mem::swap(
|
|
||||||
&mut dred_parse_scratch,
|
|
||||||
&mut last_good_dred,
|
|
||||||
);
|
|
||||||
last_good_dred_seq = Some(pkt.header.seq);
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
// Packet carried no DRED — keep cached state.
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!("DRED parse error (ignored): {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect and fill gap from last-expected to this packet.
|
|
||||||
const MAX_GAP_FRAMES: u16 = 16;
|
|
||||||
if let Some(expected) = expected_seq {
|
|
||||||
let gap = pkt.header.seq.wrapping_sub(expected);
|
|
||||||
if gap > 0 && gap <= MAX_GAP_FRAMES {
|
|
||||||
let current_profile_frame_samples =
|
|
||||||
(48_000 * profile.frame_duration_ms as i32) / 1000;
|
|
||||||
let available = last_good_dred.samples_available();
|
|
||||||
let pcm_slice_len =
|
|
||||||
current_profile_frame_samples as usize;
|
|
||||||
|
|
||||||
for gap_idx in 0..gap {
|
|
||||||
let missing_seq = expected.wrapping_add(gap_idx);
|
|
||||||
// Offset from the DRED anchor (last_good_dred_seq)
|
|
||||||
// back to the missing seq, in samples. Skip if
|
|
||||||
// the anchor is not ahead of missing (defensive).
|
|
||||||
let offset_samples = match last_good_dred_seq {
|
|
||||||
Some(anchor) => {
|
|
||||||
let delta = anchor.wrapping_sub(missing_seq);
|
|
||||||
if delta == 0 || delta > MAX_GAP_FRAMES {
|
|
||||||
-1 // skip DRED, use PLC
|
|
||||||
} else {
|
|
||||||
delta as i32 * current_profile_frame_samples
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let reconstructed = if offset_samples > 0
|
|
||||||
&& offset_samples <= available
|
|
||||||
{
|
|
||||||
decoder
|
|
||||||
.reconstruct_from_dred(
|
|
||||||
&last_good_dred,
|
|
||||||
offset_samples,
|
|
||||||
&mut decode_buf[..pcm_slice_len],
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
match reconstructed {
|
|
||||||
Some(samples) => {
|
|
||||||
playout_agc.process_frame(
|
|
||||||
&mut decode_buf[..samples],
|
|
||||||
);
|
|
||||||
state
|
|
||||||
.playout_ring
|
|
||||||
.write(&decode_buf[..samples]);
|
|
||||||
dred_reconstructions += 1;
|
|
||||||
frames_decoded += 1;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Fall through to classical PLC.
|
|
||||||
if let Ok(samples) =
|
|
||||||
decoder.decode_lost(&mut decode_buf)
|
|
||||||
{
|
|
||||||
playout_agc
|
|
||||||
.process_frame(&mut decode_buf[..samples]);
|
|
||||||
state
|
|
||||||
.playout_ring
|
|
||||||
.write(&decode_buf[..samples]);
|
|
||||||
classical_plc_invocations += 1;
|
|
||||||
frames_decoded += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance the expected-seq tracker for the next arrival.
|
|
||||||
expected_seq = Some(pkt.header.seq.wrapping_add(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||||
Ok(samples) => {
|
Ok(samples) => {
|
||||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||||
@@ -1057,21 +569,12 @@ async fn run_call(
|
|||||||
if let Ok(samples) = decoder.decode_lost(&mut decode_buf) {
|
if let Ok(samples) = decoder.decode_lost(&mut decode_buf) {
|
||||||
playout_agc.process_frame(&mut decode_buf[..samples]);
|
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||||
state.playout_ring.write(&decode_buf[..samples]);
|
state.playout_ring.write(&decode_buf[..samples]);
|
||||||
// This is a decode-error fallback (not a
|
|
||||||
// detected gap), so count it as PLC.
|
|
||||||
classical_plc_invocations += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codec2-only: try FEC recovery and expire old blocks.
|
// Try FEC recovery
|
||||||
// Opus packets skip both — the Phase 2 Opus path has no
|
|
||||||
// RaptorQ state to query or clean up. The `fec_recovered`
|
|
||||||
// counter is now effectively Codec2-only, which is
|
|
||||||
// correct because DRED reconstructions will be counted
|
|
||||||
// separately once Phase 3 lands (new telemetry field).
|
|
||||||
if !pkt_is_opus {
|
|
||||||
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
if let Ok(Some(recovered_frames)) = fec_dec.try_decode(pkt_block) {
|
||||||
fec_recovered += recovered_frames.len() as u64;
|
fec_recovered += recovered_frames.len() as u64;
|
||||||
if fec_recovered % 50 == 1 {
|
if fec_recovered % 50 == 1 {
|
||||||
@@ -1088,13 +591,10 @@ async fn run_call(
|
|||||||
if pkt_block > 3 {
|
if pkt_block > 3 {
|
||||||
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
fec_dec.expire_before(pkt_block.wrapping_sub(3));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut stats = state.stats.lock().unwrap();
|
let mut stats = state.stats.lock().unwrap();
|
||||||
stats.frames_decoded = frames_decoded;
|
stats.frames_decoded = frames_decoded;
|
||||||
stats.fec_recovered = fec_recovered;
|
stats.fec_recovered = fec_recovered;
|
||||||
stats.dred_reconstructions = dred_reconstructions;
|
|
||||||
stats.classical_plc_invocations = classical_plc_invocations;
|
|
||||||
drop(stats);
|
drop(stats);
|
||||||
|
|
||||||
// Periodic stats every 5 seconds
|
// Periodic stats every 5 seconds
|
||||||
@@ -1102,8 +602,6 @@ async fn run_call(
|
|||||||
info!(
|
info!(
|
||||||
frames_decoded,
|
frames_decoded,
|
||||||
fec_recovered,
|
fec_recovered,
|
||||||
dred_reconstructions,
|
|
||||||
classical_plc_invocations,
|
|
||||||
recv_errors,
|
recv_errors,
|
||||||
max_recv_gap_ms,
|
max_recv_gap_ms,
|
||||||
playout_avail = state.playout_ring.available(),
|
playout_avail = state.playout_ring.available(),
|
||||||
@@ -1174,7 +672,6 @@ async fn run_call(
|
|||||||
.map(|p| crate::stats::RoomMember {
|
.map(|p| crate::stats::RoomMember {
|
||||||
fingerprint: p.fingerprint.clone(),
|
fingerprint: p.fingerprint.clone(),
|
||||||
alias: p.alias.clone(),
|
alias: p.alias.clone(),
|
||||||
relay_label: p.relay_label.clone(),
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut stats = state_signal.stats.lock().unwrap();
|
let mut stats = state_signal.stats.lock().unwrap();
|
||||||
|
|||||||
@@ -21,24 +21,11 @@ unsafe fn handle_ref(handle: jlong) -> &'static mut EngineHandle {
|
|||||||
unsafe { &mut *(handle as *mut EngineHandle) }
|
unsafe { &mut *(handle as *mut EngineHandle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 7 = auto (use relay's chosen profile)
|
|
||||||
const PROFILE_AUTO: jint = 7;
|
|
||||||
|
|
||||||
fn profile_from_int(value: jint) -> QualityProfile {
|
fn profile_from_int(value: jint) -> QualityProfile {
|
||||||
match value {
|
match value {
|
||||||
0 => QualityProfile::GOOD, // Opus 24k
|
1 => QualityProfile::DEGRADED,
|
||||||
1 => QualityProfile::DEGRADED, // Opus 6k
|
2 => QualityProfile::CATASTROPHIC,
|
||||||
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
|
_ => QualityProfile::GOOD,
|
||||||
3 => QualityProfile { // Codec2 3.2k
|
|
||||||
codec: wzp_proto::CodecId::Codec2_3200,
|
|
||||||
fec_ratio: 0.5,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
},
|
|
||||||
4 => QualityProfile::STUDIO_32K, // Opus 32k
|
|
||||||
5 => QualityProfile::STUDIO_48K, // Opus 48k
|
|
||||||
6 => QualityProfile::STUDIO_64K, // Opus 64k
|
|
||||||
_ => QualityProfile::GOOD, // auto falls back to GOOD
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,24 +35,17 @@ static INIT_LOGGING: Once = Once::new();
|
|||||||
/// Safe to call multiple times — only the first call takes effect.
|
/// Safe to call multiple times — only the first call takes effect.
|
||||||
fn init_logging() {
|
fn init_logging() {
|
||||||
INIT_LOGGING.call_once(|| {
|
INIT_LOGGING.call_once(|| {
|
||||||
// Wrap in catch_unwind — sharded_slab allocation inside
|
// Use android_logger directly — tracing_subscriber::registry() allocates
|
||||||
// tracing_subscriber::registry() can crash on some Android
|
// a sharded_slab which causes SIGSEGV on Android 16 MTE devices.
|
||||||
// devices if scudo malloc fails during early initialization.
|
// android_logger is lightweight and doesn't trigger scudo crashes.
|
||||||
let _ = std::panic::catch_unwind(|| {
|
let _ = std::panic::catch_unwind(|| {
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
android_logger::init_once(
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
android_logger::Config::default()
|
||||||
use tracing_subscriber::EnvFilter;
|
.with_max_level(log::LevelFilter::Info)
|
||||||
if let Ok(layer) = tracing_android::layer("wzp_android") {
|
.with_tag("wzp"),
|
||||||
// Filter: INFO for our crates, WARN for everything else.
|
);
|
||||||
// The jni crate emits VERBOSE logs for every method lookup
|
// Bridge tracing → log so our tracing::info! macros work
|
||||||
// (~10 lines per JNI call, 100+ calls/sec) which floods logcat
|
let _ = tracing_log::LogTracer::init();
|
||||||
// and causes the system to kill the app.
|
|
||||||
let filter = EnvFilter::new("warn,wzp_android=info,wzp_proto=info,wzp_transport=info,wzp_codec=info,wzp_fec=info,wzp_crypto=info");
|
|
||||||
let _ = tracing_subscriber::registry()
|
|
||||||
.with(layer)
|
|
||||||
.with(filter)
|
|
||||||
.try_init();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,7 +78,6 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
seed_hex_j: JString,
|
seed_hex_j: JString,
|
||||||
token_j: JString,
|
token_j: JString,
|
||||||
alias_j: JString,
|
alias_j: JString,
|
||||||
profile_j: jint,
|
|
||||||
) -> jint {
|
) -> jint {
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default();
|
||||||
@@ -124,8 +103,7 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config = CallStartConfig {
|
let config = CallStartConfig {
|
||||||
profile: profile_from_int(profile_j),
|
profile: QualityProfile::GOOD,
|
||||||
auto_profile: profile_j == PROFILE_AUTO,
|
|
||||||
relay_addr,
|
relay_addr,
|
||||||
room,
|
room,
|
||||||
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() },
|
||||||
@@ -333,22 +311,71 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ping a relay server — instance method, requires engine handle.
|
/// Ping a relay server — returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
||||||
/// Returns JSON `{"rtt_ms":N,"server_fingerprint":"hex"}` or null on failure.
|
/// Does NOT require an engine handle — creates a temporary QUIC connection.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
||||||
mut env: JNIEnv<'a>,
|
mut env: JNIEnv<'a>,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
handle: jlong,
|
|
||||||
relay_j: JString,
|
relay_j: JString,
|
||||||
) -> jstring {
|
) -> jstring {
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default();
|
||||||
match h.engine.ping_relay(&relay) {
|
let addr: std::net::SocketAddr = match relay.parse() {
|
||||||
Ok(json) => Some(json),
|
Ok(a) => a,
|
||||||
Err(_) => None,
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||||
|
let endpoint = match wzp_transport::create_endpoint(bind, None) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
let client_cfg = wzp_transport::client_config();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(conn)) => {
|
||||||
|
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||||
|
let server_fp = conn
|
||||||
|
.peer_identity()
|
||||||
|
.and_then(|id| {
|
||||||
|
id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok()
|
||||||
|
})
|
||||||
|
.and_then(|certs| {
|
||||||
|
certs.first().map(|c| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
c.as_ref().hash(&mut h);
|
||||||
|
format!("{:016x}", h.finish())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
conn.close(0u32.into(), b"ping");
|
||||||
|
Some(format!(
|
||||||
|
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
|
||||||
|
rtt_ms, server_fp
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let json = match result {
|
let json = match result {
|
||||||
@@ -359,89 +386,3 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
|
|||||||
.map(|s| s.into_raw())
|
.map(|s| s.into_raw())
|
||||||
.unwrap_or(JObject::null().into_raw())
|
.unwrap_or(JObject::null().into_raw())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Direct calling JNI functions ──
|
|
||||||
|
|
||||||
/// Start persistent signaling connection to relay for direct calls.
|
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
relay_addr_j: JString,
|
|
||||||
seed_hex_j: JString,
|
|
||||||
token_j: JString,
|
|
||||||
alias_j: JString,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
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(
|
|
||||||
&relay_addr,
|
|
||||||
&seed_hex,
|
|
||||||
if token.is_empty() { None } else { Some(&token) },
|
|
||||||
if alias.is_empty() { None } else { Some(&alias) },
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("start_signaling panicked"); -1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Place a direct call to a target fingerprint.
|
|
||||||
/// Returns 0 on success, -1 on error.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
target_fp_j: JString,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default();
|
|
||||||
h.engine.place_call(&target)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("place_call panicked"); -1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Answer an incoming direct call.
|
|
||||||
/// mode: 0=Reject, 1=AcceptTrusted, 2=AcceptGeneric
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>(
|
|
||||||
mut env: JNIEnv<'a>,
|
|
||||||
_class: JClass,
|
|
||||||
handle: jlong,
|
|
||||||
call_id_j: JString,
|
|
||||||
mode: jint,
|
|
||||||
) -> jint {
|
|
||||||
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
|
||||||
let h = unsafe { handle_ref(handle) };
|
|
||||||
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default();
|
|
||||||
let accept_mode = match mode {
|
|
||||||
0 => wzp_proto::CallAcceptMode::Reject,
|
|
||||||
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
|
|
||||||
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
|
|
||||||
};
|
|
||||||
h.engine.answer_call(&call_id, accept_mode)
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Ok(())) => 0,
|
|
||||||
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 }
|
|
||||||
Err(_) => { error!("answer_call panicked"); -1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,19 +8,6 @@
|
|||||||
//!
|
//!
|
||||||
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
//! On non-Android targets, the Oboe C++ layer compiles as a stub,
|
||||||
//! allowing `cargo check` and unit tests on the host.
|
//! allowing `cargo check` and unit tests on the host.
|
||||||
//!
|
|
||||||
//! ## Status
|
|
||||||
//!
|
|
||||||
//! **Dead code as of the Tauri mobile rewrite.** The legacy Kotlin+JNI
|
|
||||||
//! Android app that consumed this crate was replaced by a Tauri 2.x
|
|
||||||
//! Mobile app (see `desktop/src-tauri/src/engine.rs` for the live
|
|
||||||
//! Android audio recv path and `crates/wzp-native/` for the Oboe
|
|
||||||
//! bridge). We keep this crate in the workspace for reference and to
|
|
||||||
//! preserve the commit history, but it is not built by any shipping
|
|
||||||
//! target. Allow the accumulated leftover warnings so CI/workspace
|
|
||||||
//! checks stay clean — any real cleanup should happen as part of
|
|
||||||
//! removing the crate entirely, not piecemeal.
|
|
||||||
#![allow(dead_code, unused_imports, unused_variables, unused_mut)]
|
|
||||||
|
|
||||||
pub mod audio_android;
|
pub mod audio_android;
|
||||||
pub mod audio_ring;
|
pub mod audio_ring;
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ pub enum CallState {
|
|||||||
Active,
|
Active,
|
||||||
Reconnecting,
|
Reconnecting,
|
||||||
Closed,
|
Closed,
|
||||||
/// Connected to relay signal channel, registered for direct calls.
|
|
||||||
Registered,
|
|
||||||
/// Outgoing call ringing on callee's side.
|
|
||||||
Ringing,
|
|
||||||
/// Incoming call received, waiting for user to accept/reject.
|
|
||||||
IncomingCall,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl serde::Serialize for CallState {
|
impl serde::Serialize for CallState {
|
||||||
@@ -27,9 +21,6 @@ impl serde::Serialize for CallState {
|
|||||||
CallState::Active => 2,
|
CallState::Active => 2,
|
||||||
CallState::Reconnecting => 3,
|
CallState::Reconnecting => 3,
|
||||||
CallState::Closed => 4,
|
CallState::Closed => 4,
|
||||||
CallState::Registered => 5,
|
|
||||||
CallState::Ringing => 6,
|
|
||||||
CallState::IncomingCall => 7,
|
|
||||||
};
|
};
|
||||||
serializer.serialize_u8(n)
|
serializer.serialize_u8(n)
|
||||||
}
|
}
|
||||||
@@ -58,16 +49,8 @@ pub struct CallStats {
|
|||||||
pub frames_decoded: u64,
|
pub frames_decoded: u64,
|
||||||
/// Number of playout underruns (buffer empty when audio needed).
|
/// Number of playout underruns (buffer empty when audio needed).
|
||||||
pub underruns: u64,
|
pub underruns: u64,
|
||||||
/// Frames recovered by RaptorQ FEC (Codec2 tiers only; Opus bypasses
|
/// Frames recovered by FEC.
|
||||||
/// RaptorQ per Phase 2).
|
|
||||||
pub fec_recovered: u64,
|
pub fec_recovered: u64,
|
||||||
/// Phase 3c: Opus frames reconstructed via DRED side-channel data.
|
|
||||||
/// Only increments on the Opus tiers; always zero for Codec2.
|
|
||||||
pub dred_reconstructions: u64,
|
|
||||||
/// Phase 3c: Opus frames filled via classical Opus PLC because no DRED
|
|
||||||
/// state covered the gap, plus any decode-error fallbacks. Codec2 loss
|
|
||||||
/// also increments this counter via the Codec2 PLC path.
|
|
||||||
pub classical_plc_invocations: u64,
|
|
||||||
/// Playout ring overflow count (reader was lapped by writer).
|
/// Playout ring overflow count (reader was lapped by writer).
|
||||||
pub playout_overflows: u64,
|
pub playout_overflows: u64,
|
||||||
/// Playout ring underrun count (reader found empty buffer).
|
/// Playout ring underrun count (reader found empty buffer).
|
||||||
@@ -76,28 +59,10 @@ pub struct CallStats {
|
|||||||
pub capture_overflows: u64,
|
pub capture_overflows: u64,
|
||||||
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
/// Current mic audio level (RMS of i16 samples, 0-32767).
|
||||||
pub audio_level: u32,
|
pub audio_level: u32,
|
||||||
/// Our current outgoing codec name (e.g. "Opus24k", "Codec2_1200").
|
|
||||||
pub current_codec: String,
|
|
||||||
/// Last seen incoming codec from other participants.
|
|
||||||
pub peer_codec: String,
|
|
||||||
/// Whether auto quality mode is active.
|
|
||||||
pub auto_mode: bool,
|
|
||||||
/// Number of participants in the room (from last RoomUpdate).
|
/// Number of participants in the room (from last RoomUpdate).
|
||||||
pub room_participant_count: u32,
|
pub room_participant_count: u32,
|
||||||
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
/// Participant list (fingerprint + optional alias) serialized as JSON array.
|
||||||
pub room_participants: Vec<RoomMember>,
|
pub room_participants: Vec<RoomMember>,
|
||||||
/// SAS code for verbal verification (None if not in a call).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub sas_code: Option<u32>,
|
|
||||||
/// Incoming call info (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_call_id: Option<String>,
|
|
||||||
/// Fingerprint of the caller (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_caller_fp: Option<String>,
|
|
||||||
/// Alias of the caller (present when state == IncomingCall).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub incoming_caller_alias: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A room member entry, serialized into the stats JSON.
|
/// A room member entry, serialized into the stats JSON.
|
||||||
@@ -105,5 +70,4 @@ pub struct CallStats {
|
|||||||
pub struct RoomMember {
|
pub struct RoomMember {
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
pub alias: Option<String>,
|
pub alias: Option<String>,
|
||||||
pub relay_label: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,71 +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 }
|
||||||
libc = "0.2"
|
|
||||||
|
|
||||||
# coreaudio-rs is Apple-framework-only; gate it to macOS so enabling
|
|
||||||
# the `vpio` feature from a non-macOS target builds cleanly instead of
|
|
||||||
# pulling in a crate that can only link against Apple frameworks.
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
coreaudio-rs = { version = "0.11", optional = true }
|
|
||||||
|
|
||||||
# Windows-only: direct WASAPI bindings for the `windows-aec` feature.
|
|
||||||
# `windows` is Microsoft's official Rust COM bindings crate. We pull in
|
|
||||||
# only the audio + COM subfeatures we need — the crate is organized as
|
|
||||||
# a massive optional-feature tree, so enabling just these keeps compile
|
|
||||||
# times reasonable (~5s for these features vs ~60s for the full crate).
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
|
||||||
windows = { version = "0.58", optional = true, features = [
|
|
||||||
"Win32_Foundation",
|
|
||||||
"Win32_Media_Audio",
|
|
||||||
"Win32_Security",
|
|
||||||
"Win32_System_Com",
|
|
||||||
"Win32_System_Com_StructuredStorage",
|
|
||||||
"Win32_System_Threading",
|
|
||||||
"Win32_System_Variant",
|
|
||||||
] }
|
|
||||||
|
|
||||||
# Linux-only: WebRTC AEC (Audio Processing Module) bindings for the
|
|
||||||
# `linux-aec` feature. This is the 0.3.x line of the `tonarino/
|
|
||||||
# webrtc-audio-processing` crate, which links against Debian's
|
|
||||||
# `libwebrtc-audio-processing-dev` apt package (0.3-1+b1 on Bookworm).
|
|
||||||
#
|
|
||||||
# Note: we attempted the 2.x line with its `bundled` sub-feature first
|
|
||||||
# (which would give us AEC3 instead of AEC2), but both the crates.io
|
|
||||||
# tarball AND the upstream git `main` branch of webrtc-audio-processing-sys
|
|
||||||
# 2.0.3 hit a `meson setup --reconfigure` bug where the build.rs passes
|
|
||||||
# --reconfigure unconditionally even on first-run empty build dirs,
|
|
||||||
# causing the bundled build to fail with "Directory does not contain a
|
|
||||||
# valid build tree". The 0.x line doesn't use bundled mode and sidesteps
|
|
||||||
# this entirely by linking the apt-provided library. AEC2 is older than
|
|
||||||
# AEC3 but still the same algorithm family — this is what PulseAudio's
|
|
||||||
# module-echo-cancel and PipeWire's filter-chain use by default on
|
|
||||||
# current Debian-family distros.
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
webrtc-audio-processing = { version = "0.3", optional = true }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
audio = ["cpal"]
|
audio = ["cpal"]
|
||||||
# vpio enables coreaudio-rs but that dep is itself gated to macOS above,
|
|
||||||
# so enabling this feature on Windows/Linux is a no-op (the audio_vpio
|
|
||||||
# module is also #[cfg(target_os = "macos")] in lib.rs).
|
|
||||||
vpio = ["dep:coreaudio-rs"]
|
|
||||||
# windows-aec enables a direct WASAPI capture backend that opens the
|
|
||||||
# microphone under AudioCategory_Communications, turning on Windows's
|
|
||||||
# OS-level communications audio processing (AEC + noise suppression +
|
|
||||||
# AGC). The `windows` dep is itself target-gated to Windows above, so
|
|
||||||
# enabling this feature on non-Windows targets is a no-op (the
|
|
||||||
# audio_wasapi module is also #[cfg(target_os = "windows")] in lib.rs).
|
|
||||||
windows-aec = ["dep:windows"]
|
|
||||||
# linux-aec enables a CPAL + WebRTC AEC3 capture/playback backend that
|
|
||||||
# runs the WebRTC Audio Processing Module (same algo as Chrome / Zoom /
|
|
||||||
# Teams) in-process, using the playback PCM as the reference signal for
|
|
||||||
# echo cancellation. The webrtc-audio-processing dep is target-gated to
|
|
||||||
# Linux above, so enabling this feature on non-Linux targets is a no-op
|
|
||||||
# (the audio_linux_aec module is also #[cfg(target_os = "linux")] in
|
|
||||||
# lib.rs).
|
|
||||||
linux-aec = ["dep:webrtc-audio-processing"]
|
|
||||||
|
|
||||||
[[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));
|
||||||
|
if lock.len() == FRAME_SAMPLES {
|
||||||
|
let frame = lock.drain(..).collect();
|
||||||
|
let _ = tx.try_send(frame);
|
||||||
}
|
}
|
||||||
let mut tmp = [0i16; FRAME_SAMPLES];
|
|
||||||
for chunk in data.chunks(FRAME_SAMPLES) {
|
|
||||||
let n = chunk.len();
|
|
||||||
for i in 0..n {
|
|
||||||
tmp[i] = f32_to_i16(chunk[i]);
|
|
||||||
}
|
|
||||||
ring.write(&tmp[..n]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
err_cb,
|
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,537 +0,0 @@
|
|||||||
//! Linux AEC backend: CPAL capture + playback wired through the WebRTC Audio
|
|
||||||
//! Processing Module (AEC3 + noise suppression + high-pass filter).
|
|
||||||
//!
|
|
||||||
//! This is the same algorithm used by Chrome WebRTC, Zoom, Teams, Jitsi, and
|
|
||||||
//! any other "serious" Linux VoIP app. It runs in-process — no dependency on
|
|
||||||
//! PulseAudio's module-echo-cancel or PipeWire's filter-chain, so it works
|
|
||||||
//! identically on ALSA / PulseAudio / PipeWire systems.
|
|
||||||
//!
|
|
||||||
//! ## Architecture
|
|
||||||
//!
|
|
||||||
//! A single module-level `Arc<Mutex<Processor>>` is shared between the
|
|
||||||
//! capture and playback paths. On each 20 ms frame (960 samples @ 48 kHz
|
|
||||||
//! mono):
|
|
||||||
//!
|
|
||||||
//! - **Playback path**: `LinuxAecPlayback::start` spawns the usual CPAL
|
|
||||||
//! output thread, but wraps each chunk in a call to
|
|
||||||
//! `Processor::process_render_frame` **before** handing it to CPAL. That
|
|
||||||
//! gives APM an authoritative reference of exactly what's going out to
|
|
||||||
//! the speakers (same approach Zoom/Teams/Jitsi use). The AEC then knows
|
|
||||||
//! what to cancel when it sees echo in the capture stream.
|
|
||||||
//!
|
|
||||||
//! - **Capture path**: `LinuxAecCapture::start` spawns the usual CPAL
|
|
||||||
//! input thread, and runs `Processor::process_capture_frame` on each
|
|
||||||
//! incoming mic chunk **in place** before pushing it into the ring
|
|
||||||
//! buffer. The AEC subtracts the echo using the render reference it
|
|
||||||
//! saw on the playback side.
|
|
||||||
//!
|
|
||||||
//! APM is strict about frame size: it requires exactly 10 ms = 480 samples
|
|
||||||
//! per call at 48 kHz. Our pipeline uses 20 ms = 960 samples, so each 20 ms
|
|
||||||
//! frame is split into two 480-sample halves, APM is called twice, and the
|
|
||||||
//! halves are stitched back together.
|
|
||||||
//!
|
|
||||||
//! APM only accepts f32 samples in `[-1.0, 1.0]`, so we convert i16 → f32
|
|
||||||
//! before the call and f32 → i16 after (with clamping on the return path).
|
|
||||||
//!
|
|
||||||
//! ## Stream delay
|
|
||||||
//!
|
|
||||||
//! AEC needs to know roughly how long it takes between a sample being passed
|
|
||||||
//! to `process_render_frame` and its echo showing up at `process_capture_frame`
|
|
||||||
//! — i.e. the round trip through CPAL playback → speaker → air → microphone
|
|
||||||
//! → CPAL capture. AEC3's internal estimator tracks this within a window
|
|
||||||
//! around whatever hint we give it. We hardcode 60 ms as a reasonable
|
|
||||||
//! starting point for typical Linux audio stacks; the delay estimator does
|
|
||||||
//! the fine-tuning automatically.
|
|
||||||
//!
|
|
||||||
//! ## Thread safety
|
|
||||||
//!
|
|
||||||
//! The 0.3.x line of `webrtc-audio-processing` takes `&mut self` on both
|
|
||||||
//! `process_capture_frame` and `process_render_frame`, so the `Processor`
|
|
||||||
//! needs a `Mutex` around it for cross-thread sharing. The capture and
|
|
||||||
//! playback threads each acquire the lock briefly (sub-millisecond per
|
|
||||||
//! 10 ms frame) so contention is minimal at our frame rates.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|
||||||
use cpal::{SampleFormat, SampleRate, StreamConfig};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use webrtc_audio_processing::{
|
|
||||||
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
|
|
||||||
NoiseSuppression, NoiseSuppressionLevel, Processor, NUM_SAMPLES_PER_FRAME,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// 20 ms at 48 kHz, mono — matches the rest of the pipeline and the codec.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
/// APM requires strict 10 ms frames at 48 kHz = 480 samples per call.
|
|
||||||
/// Imported from the webrtc-audio-processing crate so we can't drift out
|
|
||||||
/// of sync with whatever sample rate / frame length the C++ lib is using.
|
|
||||||
const APM_FRAME_SAMPLES: usize = NUM_SAMPLES_PER_FRAME as usize;
|
|
||||||
const APM_NUM_CHANNELS: usize = 1;
|
|
||||||
/// Round-trip delay hint passed to APM; the estimator refines from here.
|
|
||||||
/// 60 ms is a reasonable default for CPAL on ALSA / PulseAudio / PipeWire.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const STREAM_DELAY_MS: i32 = 60;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared APM instance
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Module-level lazily-initialized APM. Shared between capture and playback
|
|
||||||
/// so they operate on the same echo-cancellation state — the render frames
|
|
||||||
/// pushed by playback are what the capture path subtracts from the mic input.
|
|
||||||
/// Wrapped in a Mutex because the 0.3.x Processor takes `&mut self` on both
|
|
||||||
/// process_capture_frame and process_render_frame.
|
|
||||||
static PROCESSOR: OnceLock<Arc<Mutex<Processor>>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
|
|
||||||
if let Some(p) = PROCESSOR.get() {
|
|
||||||
return Ok(p.clone());
|
|
||||||
}
|
|
||||||
let init_config = InitializationConfig {
|
|
||||||
num_capture_channels: APM_NUM_CHANNELS as i32,
|
|
||||||
num_render_channels: APM_NUM_CHANNELS as i32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut processor = Processor::new(&init_config)
|
|
||||||
.map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
|
|
||||||
|
|
||||||
let config = Config {
|
|
||||||
echo_cancellation: Some(EchoCancellation {
|
|
||||||
suppression_level: EchoCancellationSuppressionLevel::High,
|
|
||||||
stream_delay_ms: Some(STREAM_DELAY_MS),
|
|
||||||
enable_delay_agnostic: true,
|
|
||||||
enable_extended_filter: true,
|
|
||||||
}),
|
|
||||||
noise_suppression: Some(NoiseSuppression {
|
|
||||||
suppression_level: NoiseSuppressionLevel::High,
|
|
||||||
}),
|
|
||||||
enable_high_pass_filter: true,
|
|
||||||
// AGC left off for now — it can fight the Opus encoder's own gain
|
|
||||||
// staging and the adaptive-quality controller. Add later if users
|
|
||||||
// report low mic levels.
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
processor.set_config(config);
|
|
||||||
|
|
||||||
let arc = Arc::new(Mutex::new(processor));
|
|
||||||
let _ = PROCESSOR.set(arc.clone());
|
|
||||||
info!(
|
|
||||||
stream_delay_ms = STREAM_DELAY_MS,
|
|
||||||
"webrtc APM initialized (AEC High + NS High + HPF, AGC off)"
|
|
||||||
);
|
|
||||||
Ok(arc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers: i16 ↔ f32 and APM frame processing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn i16_to_f32(s: i16) -> f32 {
|
|
||||||
s as f32 / 32768.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn f32_to_i16(s: f32) -> i16 {
|
|
||||||
(s.clamp(-1.0, 1.0) * 32767.0) as i16
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed a 20 ms (960-sample) playback frame to APM as the render reference.
|
|
||||||
/// Splits into two 10 ms halves because APM is strict about frame size.
|
|
||||||
/// Takes the Mutex-wrapped Processor and locks briefly around each call.
|
|
||||||
fn push_render_frame_20ms(apm: &Mutex<Processor>, pcm: &[i16]) {
|
|
||||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
|
||||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
|
||||||
for half in pcm.chunks_exact(APM_FRAME_SAMPLES) {
|
|
||||||
for (i, &s) in half.iter().enumerate() {
|
|
||||||
buf[i] = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
match apm.lock() {
|
|
||||||
Ok(mut p) => {
|
|
||||||
if let Err(e) = p.process_render_frame(&mut buf) {
|
|
||||||
warn!("webrtc APM process_render_frame failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("webrtc APM mutex poisoned in render path");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a 20 ms (960-sample) capture frame through APM's echo cancellation
|
|
||||||
/// in place. Splits into two 10 ms halves, runs APM on each, stitches
|
|
||||||
/// results back into the caller's buffer. Briefly holds the Mutex once
|
|
||||||
/// per 10 ms half.
|
|
||||||
fn process_capture_frame_20ms(apm: &Mutex<Processor>, pcm: &mut [i16]) {
|
|
||||||
debug_assert_eq!(pcm.len(), FRAME_SAMPLES);
|
|
||||||
let mut buf = [0f32; APM_FRAME_SAMPLES];
|
|
||||||
for half in pcm.chunks_exact_mut(APM_FRAME_SAMPLES) {
|
|
||||||
for (i, &s) in half.iter().enumerate() {
|
|
||||||
buf[i] = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
match apm.lock() {
|
|
||||||
Ok(mut p) => {
|
|
||||||
if let Err(e) = p.process_capture_frame(&mut buf) {
|
|
||||||
warn!("webrtc APM process_capture_frame failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("webrtc APM mutex poisoned in capture path");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (i, d) in half.iter_mut().enumerate() {
|
|
||||||
*d = f32_to_i16(buf[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LinuxAecCapture — CPAL mic + WebRTC AEC capture-side processing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Microphone capture with WebRTC AEC3 applied in place before the codec
|
|
||||||
/// sees the samples. Mirrors the public API of `audio_io::AudioCapture` so
|
|
||||||
/// downstream code doesn't change.
|
|
||||||
pub struct LinuxAecCapture {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxAecCapture {
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
// Eagerly init the APM so the playback side can find it already
|
|
||||||
// configured, and so init errors surface on the caller thread
|
|
||||||
// instead of silently failing inside the capture thread.
|
|
||||||
let apm = get_or_init_processor()?;
|
|
||||||
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
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 apm_capture = apm.clone();
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-capture-linuxaec".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = (|| -> Result<(), anyhow::Error> {
|
|
||||||
let host = cpal::default_host();
|
|
||||||
let device = host
|
|
||||||
.default_input_device()
|
|
||||||
.ok_or_else(|| anyhow!("no default input audio device found"))?;
|
|
||||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using input device");
|
|
||||||
|
|
||||||
let config = StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: SampleRate(48_000),
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
let use_f32 = !supports_i16_input(&device)?;
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
|
||||||
warn!("LinuxAEC input stream error: {e}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Leftover buffer for when CPAL gives us partial frames.
|
|
||||||
// We need exactly 960-sample chunks to feed APM.
|
|
||||||
let leftover = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
let apm = apm_capture.clone();
|
|
||||||
device.build_input_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
|
||||||
if !running.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut lv = leftover.lock().unwrap();
|
|
||||||
lv.reserve(data.len());
|
|
||||||
for &s in data {
|
|
||||||
lv.push(f32_to_i16(s));
|
|
||||||
}
|
|
||||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let running = running_clone.clone();
|
|
||||||
let apm = apm_capture.clone();
|
|
||||||
device.build_input_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &[i16], _: &cpal::InputCallbackInfo| {
|
|
||||||
if !running.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut lv = leftover.lock().unwrap();
|
|
||||||
lv.extend_from_slice(data);
|
|
||||||
drain_frames_through_apm(&mut lv, &apm, &ring);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.play().context("failed to start LinuxAEC input stream")?;
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("LinuxAEC capture started (AEC3 active)");
|
|
||||||
|
|
||||||
while running_clone.load(Ordering::Relaxed) {
|
|
||||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
drop(stream);
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("LinuxAEC capture thread exited before signaling"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LinuxAecCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull whole 960-sample frames out of the leftover buffer, run them through
|
|
||||||
/// APM's capture-side processing, and push to the ring. Leaves any partial
|
|
||||||
/// sub-960 remainder in `leftover` for the next callback.
|
|
||||||
fn drain_frames_through_apm(leftover: &mut Vec<i16>, apm: &Mutex<Processor>, ring: &AudioRing) {
|
|
||||||
let mut frame = [0i16; FRAME_SAMPLES];
|
|
||||||
while leftover.len() >= FRAME_SAMPLES {
|
|
||||||
frame.copy_from_slice(&leftover[..FRAME_SAMPLES]);
|
|
||||||
process_capture_frame_20ms(apm, &mut frame);
|
|
||||||
ring.write(&frame);
|
|
||||||
leftover.drain(..FRAME_SAMPLES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// LinuxAecPlayback — CPAL speaker output + WebRTC AEC render-side tee
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Speaker playback with a render-side tee: each frame written to CPAL is
|
|
||||||
/// ALSO fed to APM via `process_render_frame` as the echo-cancellation
|
|
||||||
/// reference signal. This is the "tee the playback ring" approach (Zoom,
|
|
||||||
/// Teams, Jitsi) — deterministic, does not depend on PulseAudio loopback or
|
|
||||||
/// PipeWire monitor sources.
|
|
||||||
pub struct LinuxAecPlayback {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinuxAecPlayback {
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
let apm = get_or_init_processor()?;
|
|
||||||
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
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 apm_render = apm.clone();
|
|
||||||
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-playback-linuxaec".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = (|| -> Result<(), anyhow::Error> {
|
|
||||||
let host = cpal::default_host();
|
|
||||||
let device = host
|
|
||||||
.default_output_device()
|
|
||||||
.ok_or_else(|| anyhow!("no default output audio device found"))?;
|
|
||||||
info!(device = %device.name().unwrap_or_default(), "LinuxAEC: using output device");
|
|
||||||
|
|
||||||
let config = StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: SampleRate(48_000),
|
|
||||||
buffer_size: cpal::BufferSize::Default,
|
|
||||||
};
|
|
||||||
|
|
||||||
let use_f32 = !supports_i16_output(&device)?;
|
|
||||||
|
|
||||||
let err_cb = |e: cpal::StreamError| {
|
|
||||||
warn!("LinuxAEC output stream error: {e}");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Same 960-sample batching approach as the capture side:
|
|
||||||
// CPAL may ask for N samples in a callback where N doesn't
|
|
||||||
// divide 960. We accumulate partial frames in a Vec and
|
|
||||||
// feed APM as soon as we have a whole 20 ms frame.
|
|
||||||
let carry = std::sync::Mutex::new(Vec::<i16>::with_capacity(FRAME_SAMPLES * 4));
|
|
||||||
|
|
||||||
let stream = if use_f32 {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let apm = apm_render.clone();
|
|
||||||
device.build_output_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
|
||||||
fill_output_and_tee_f32(data, &ring, &apm, &carry);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
let ring = ring_cb.clone();
|
|
||||||
let apm = apm_render.clone();
|
|
||||||
device.build_output_stream(
|
|
||||||
&config,
|
|
||||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
|
||||||
fill_output_and_tee_i16(data, &ring, &apm, &carry);
|
|
||||||
},
|
|
||||||
err_cb,
|
|
||||||
None,
|
|
||||||
)?
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.play().context("failed to start LinuxAEC output stream")?;
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("LinuxAEC playback started (render tee active)");
|
|
||||||
|
|
||||||
while running_clone.load(Ordering::Relaxed) {
|
|
||||||
std::thread::park_timeout(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
drop(stream);
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("LinuxAEC playback thread exited before signaling"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self { ring, running })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LinuxAecPlayback {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fill_output_and_tee_i16(
|
|
||||||
data: &mut [i16],
|
|
||||||
ring: &AudioRing,
|
|
||||||
apm: &Mutex<Processor>,
|
|
||||||
carry: &std::sync::Mutex<Vec<i16>>,
|
|
||||||
) {
|
|
||||||
let read = ring.read(data);
|
|
||||||
for s in &mut data[read..] {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
tee_render_samples(data, apm, carry);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fill_output_and_tee_f32(
|
|
||||||
data: &mut [f32],
|
|
||||||
ring: &AudioRing,
|
|
||||||
apm: &Mutex<Processor>,
|
|
||||||
carry: &std::sync::Mutex<Vec<i16>>,
|
|
||||||
) {
|
|
||||||
let mut tmp = vec![0i16; data.len()];
|
|
||||||
let read = ring.read(&mut tmp);
|
|
||||||
for s in &mut tmp[read..] {
|
|
||||||
*s = 0;
|
|
||||||
}
|
|
||||||
for (d, &s) in data.iter_mut().zip(tmp.iter()) {
|
|
||||||
*d = i16_to_f32(s);
|
|
||||||
}
|
|
||||||
tee_render_samples(&tmp, apm, carry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push CPAL-bound samples into APM's render-side input for echo cancellation.
|
|
||||||
/// Uses a carry buffer to batch into exact 960-sample (20 ms) frames.
|
|
||||||
fn tee_render_samples(samples: &[i16], apm: &Mutex<Processor>, carry: &std::sync::Mutex<Vec<i16>>) {
|
|
||||||
let mut lv = carry.lock().unwrap();
|
|
||||||
lv.extend_from_slice(samples);
|
|
||||||
while lv.len() >= FRAME_SAMPLES {
|
|
||||||
let mut frame = [0i16; FRAME_SAMPLES];
|
|
||||||
frame.copy_from_slice(&lv[..FRAME_SAMPLES]);
|
|
||||||
push_render_frame_20ms(apm, &frame);
|
|
||||||
lv.drain(..FRAME_SAMPLES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CPAL format helpers (duplicated from audio_io.rs to keep the modules
|
|
||||||
// independent — each backend file is a self-contained unit)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn supports_i16_input(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|
||||||
let supported = device
|
|
||||||
.supported_input_configs()
|
|
||||||
.context("failed to query input configs")?;
|
|
||||||
for cfg in supported {
|
|
||||||
if cfg.sample_format() == SampleFormat::I16
|
|
||||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
|
||||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
|
||||||
&& cfg.channels() >= 1
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_i16_output(device: &cpal::Device) -> Result<bool, anyhow::Error> {
|
|
||||||
let supported = device
|
|
||||||
.supported_output_configs()
|
|
||||||
.context("failed to query output configs")?;
|
|
||||||
for cfg in supported {
|
|
||||||
if cfg.sample_format() == SampleFormat::I16
|
|
||||||
&& cfg.min_sample_rate() <= SampleRate(48_000)
|
|
||||||
&& cfg.max_sample_rate() >= SampleRate(48_000)
|
|
||||||
&& cfg.channels() >= 1
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
//! Direct WASAPI microphone capture with Windows's OS-level AEC enabled.
|
|
||||||
//!
|
|
||||||
//! Bypasses CPAL and opens the default capture endpoint directly via
|
|
||||||
//! `IMMDeviceEnumerator` + `IAudioClient2::SetClientProperties`, setting
|
|
||||||
//! `AudioClientProperties.eCategory = AudioCategory_Communications`. That's
|
|
||||||
//! the switch that tells Windows "this is a VoIP call" — the OS then
|
|
||||||
//! enables its communications audio processing chain (AEC, noise
|
|
||||||
//! suppression, automatic gain control) for the stream. AEC operates at
|
|
||||||
//! the OS level using the currently-playing audio as the reference
|
|
||||||
//! signal, so it cancels echo from our CPAL playback (and any other app's
|
|
||||||
//! audio) without us having to plumb a reference signal ourselves.
|
|
||||||
//!
|
|
||||||
//! Platform: Windows only, compiled only when the `windows-aec` feature
|
|
||||||
//! is enabled. Mirrors the public API of `audio_io::AudioCapture` so
|
|
||||||
//! `wzp-client`'s lib.rs can transparently re-export either one as
|
|
||||||
//! `AudioCapture`.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use windows::core::{Interface, GUID};
|
|
||||||
use windows::Win32::Foundation::{CloseHandle, BOOL, WAIT_OBJECT_0};
|
|
||||||
use windows::Win32::Media::Audio::{
|
|
||||||
eCapture, eCommunications, AudioCategory_Communications, AudioClientProperties,
|
|
||||||
IAudioCaptureClient, IAudioClient, IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator,
|
|
||||||
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, WAVEFORMATEX,
|
|
||||||
WAVE_FORMAT_PCM,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Com::{
|
|
||||||
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED,
|
|
||||||
};
|
|
||||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject, INFINITE};
|
|
||||||
|
|
||||||
use crate::audio_ring::AudioRing;
|
|
||||||
|
|
||||||
/// 20 ms at 48 kHz, mono. Matches the rest of the audio pipeline.
|
|
||||||
pub const FRAME_SAMPLES: usize = 960;
|
|
||||||
|
|
||||||
/// Microphone capture via WASAPI with Windows's communications AEC enabled.
|
|
||||||
///
|
|
||||||
/// The WASAPI capture stream runs on a dedicated OS thread. This handle is
|
|
||||||
/// `Send + Sync`. Dropping it stops the stream and joins the thread.
|
|
||||||
pub struct WasapiAudioCapture {
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
thread: Option<std::thread::JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WasapiAudioCapture {
|
|
||||||
/// Open the default communications microphone, enable OS AEC, and start
|
|
||||||
/// streaming PCM into a lock-free ring buffer.
|
|
||||||
///
|
|
||||||
/// Returns only after the capture thread has successfully initialized
|
|
||||||
/// the stream, or propagates the error back to the caller.
|
|
||||||
pub fn start() -> Result<Self, anyhow::Error> {
|
|
||||||
let ring = Arc::new(AudioRing::new());
|
|
||||||
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_cb = running.clone();
|
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
|
||||||
.name("wzp-audio-capture-wasapi".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let result = unsafe { capture_thread_main(ring_cb, running_cb.clone(), &init_tx) };
|
|
||||||
if let Err(e) = result {
|
|
||||||
warn!("wasapi capture thread exited with error: {e}");
|
|
||||||
// If we failed before signaling init, signal now so the
|
|
||||||
// caller unblocks. Double-send is harmless (channel is
|
|
||||||
// bounded to 1 and we only hit the second send path on
|
|
||||||
// late errors).
|
|
||||||
let _ = init_tx.send(Err(e.to_string()));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.context("failed to spawn WASAPI capture thread")?;
|
|
||||||
|
|
||||||
init_rx
|
|
||||||
.recv()
|
|
||||||
.map_err(|_| anyhow!("WASAPI capture thread exited before signaling init"))?
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
ring,
|
|
||||||
running,
|
|
||||||
thread: Some(thread),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a reference to the capture ring buffer for direct polling.
|
|
||||||
pub fn ring(&self) -> &Arc<AudioRing> {
|
|
||||||
&self.ring
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop capturing.
|
|
||||||
pub fn stop(&self) {
|
|
||||||
self.running.store(false, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WasapiAudioCapture {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.stop();
|
|
||||||
if let Some(handle) = self.thread.take() {
|
|
||||||
// Join best-effort. The thread loop polls `running` every 200ms
|
|
||||||
// via a short WaitForSingleObject timeout, so it should exit
|
|
||||||
// within ~200ms of `stop()`.
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WASAPI thread entry point — everything below this line runs on the
|
|
||||||
// dedicated wzp-audio-capture-wasapi thread.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
unsafe fn capture_thread_main(
|
|
||||||
ring: Arc<AudioRing>,
|
|
||||||
running: Arc<AtomicBool>,
|
|
||||||
init_tx: &std::sync::mpsc::SyncSender<Result<(), String>>,
|
|
||||||
) -> Result<(), anyhow::Error> {
|
|
||||||
// COM init for the capture thread. MULTITHREADED because we're not
|
|
||||||
// running a message pump. Must be balanced by CoUninitialize on exit.
|
|
||||||
CoInitializeEx(None, COINIT_MULTITHREADED)
|
|
||||||
.ok()
|
|
||||||
.context("CoInitializeEx failed")?;
|
|
||||||
|
|
||||||
// Use a guard struct so CoUninitialize runs even on early returns.
|
|
||||||
struct ComGuard;
|
|
||||||
impl Drop for ComGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
unsafe { CoUninitialize() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _com_guard = ComGuard;
|
|
||||||
|
|
||||||
let enumerator: IMMDeviceEnumerator =
|
|
||||||
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
|
|
||||||
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
|
|
||||||
|
|
||||||
// eCommunications role (not eConsole) — this picks the device the user
|
|
||||||
// has designated for communications in Sound Settings. It's the one
|
|
||||||
// Windows's AEC is actually tuned for and the one Teams/Zoom use.
|
|
||||||
let device = enumerator
|
|
||||||
.GetDefaultAudioEndpoint(eCapture, eCommunications)
|
|
||||||
.context("GetDefaultAudioEndpoint(eCapture, eCommunications) failed")?;
|
|
||||||
|
|
||||||
if let Ok(name) = device_name(&device) {
|
|
||||||
info!(device = %name, "opening WASAPI communications capture endpoint");
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio_client: IAudioClient = device
|
|
||||||
.Activate(CLSCTX_ALL, None)
|
|
||||||
.context("IMMDevice::Activate(IAudioClient) failed")?;
|
|
||||||
|
|
||||||
// IAudioClient2 exposes SetClientProperties, which is the ONLY way to
|
|
||||||
// set AudioCategory_Communications pre-Initialize. Calling it on the
|
|
||||||
// base IAudioClient would not compile, and setting it after Initialize
|
|
||||||
// is a no-op.
|
|
||||||
let audio_client2: IAudioClient2 = audio_client
|
|
||||||
.cast()
|
|
||||||
.context("QueryInterface IAudioClient2 failed")?;
|
|
||||||
|
|
||||||
let mut props = AudioClientProperties {
|
|
||||||
cbSize: std::mem::size_of::<AudioClientProperties>() as u32,
|
|
||||||
bIsOffload: BOOL(0),
|
|
||||||
eCategory: AudioCategory_Communications,
|
|
||||||
// 0 = AUDCLNT_STREAMOPTIONS_NONE. The `windows` crate doesn't
|
|
||||||
// export the enum constant in all versions, so use 0 directly.
|
|
||||||
Options: Default::default(),
|
|
||||||
};
|
|
||||||
audio_client2
|
|
||||||
.SetClientProperties(&mut props as *mut _)
|
|
||||||
.context("SetClientProperties(AudioCategory_Communications) failed")?;
|
|
||||||
|
|
||||||
// Request 48 kHz mono i16 directly. AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
||||||
// tells Windows to do any needed format conversion inside the audio
|
|
||||||
// engine rather than rejecting our format. SRC_DEFAULT_QUALITY picks
|
|
||||||
// the standard Windows resampler quality (fine for voice).
|
|
||||||
let wave_format = WAVEFORMATEX {
|
|
||||||
wFormatTag: WAVE_FORMAT_PCM as u16,
|
|
||||||
nChannels: 1,
|
|
||||||
nSamplesPerSec: 48_000,
|
|
||||||
nAvgBytesPerSec: 48_000 * 2, // 1 ch * 2 bytes/sample * 48000 Hz
|
|
||||||
nBlockAlign: 2, // 1 ch * 2 bytes/sample
|
|
||||||
wBitsPerSample: 16,
|
|
||||||
cbSize: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1,000,000 hns = 100 ms buffer (hns = 100-nanosecond units). Windows
|
|
||||||
// treats this as the minimum; the engine may give us a larger one.
|
|
||||||
const BUFFER_DURATION_HNS: i64 = 1_000_000;
|
|
||||||
|
|
||||||
audio_client
|
|
||||||
.Initialize(
|
|
||||||
AUDCLNT_SHAREMODE_SHARED,
|
|
||||||
AUDCLNT_STREAMFLAGS_EVENTCALLBACK
|
|
||||||
| AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
|
|
||||||
| AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
|
|
||||||
BUFFER_DURATION_HNS,
|
|
||||||
0,
|
|
||||||
&wave_format,
|
|
||||||
Some(&GUID::zeroed()),
|
|
||||||
)
|
|
||||||
.context("IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16")?;
|
|
||||||
|
|
||||||
// Event-driven capture: Windows signals this handle each time a new
|
|
||||||
// audio packet is available. We wait on it from the loop below.
|
|
||||||
let event = CreateEventW(None, false, false, None)
|
|
||||||
.context("CreateEventW failed")?;
|
|
||||||
audio_client
|
|
||||||
.SetEventHandle(event)
|
|
||||||
.context("SetEventHandle failed")?;
|
|
||||||
|
|
||||||
let capture_client: IAudioCaptureClient = audio_client
|
|
||||||
.GetService()
|
|
||||||
.context("IAudioClient::GetService(IAudioCaptureClient) failed")?;
|
|
||||||
|
|
||||||
audio_client.Start().context("IAudioClient::Start failed")?;
|
|
||||||
|
|
||||||
// Signal to the parent thread that init succeeded before entering the
|
|
||||||
// hot loop. From this point on, errors get logged but don't propagate
|
|
||||||
// back to the caller (they'd just cause the ring buffer to stop
|
|
||||||
// filling, which the main thread detects as underruns).
|
|
||||||
let _ = init_tx.send(Ok(()));
|
|
||||||
info!("WASAPI communications-mode capture started with OS AEC enabled");
|
|
||||||
|
|
||||||
let mut logged_first_packet = false;
|
|
||||||
|
|
||||||
// Main capture loop. Exit when `running` goes false (from Drop or an
|
|
||||||
// explicit stop() call).
|
|
||||||
while running.load(Ordering::Relaxed) {
|
|
||||||
// 200 ms timeout so we check `running` regularly even if the audio
|
|
||||||
// engine stops delivering packets (e.g. device unplugged).
|
|
||||||
let wait = WaitForSingleObject(event, 200);
|
|
||||||
if wait.0 != WAIT_OBJECT_0.0 {
|
|
||||||
// Timeout or failure — just loop and re-check running.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain all available packets. Windows may have queued more than
|
|
||||||
// one since we were last scheduled.
|
|
||||||
loop {
|
|
||||||
let packet_length = match capture_client.GetNextPacketSize() {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("GetNextPacketSize failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if packet_length == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut buffer_ptr: *mut u8 = std::ptr::null_mut();
|
|
||||||
let mut num_frames: u32 = 0;
|
|
||||||
let mut flags: u32 = 0;
|
|
||||||
let mut device_position: u64 = 0;
|
|
||||||
let mut qpc_position: u64 = 0;
|
|
||||||
|
|
||||||
if let Err(e) = capture_client.GetBuffer(
|
|
||||||
&mut buffer_ptr,
|
|
||||||
&mut num_frames,
|
|
||||||
&mut flags,
|
|
||||||
Some(&mut device_position),
|
|
||||||
Some(&mut qpc_position),
|
|
||||||
) {
|
|
||||||
warn!("GetBuffer failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if num_frames > 0 && !buffer_ptr.is_null() {
|
|
||||||
if !logged_first_packet {
|
|
||||||
info!(
|
|
||||||
frames = num_frames,
|
|
||||||
flags, "WASAPI capture: first packet received"
|
|
||||||
);
|
|
||||||
logged_first_packet = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Because we asked for 48 kHz mono i16, each frame is
|
|
||||||
// exactly one i16. Windows's AUTOCONVERTPCM handles the
|
|
||||||
// conversion from whatever the engine mix format is.
|
|
||||||
let samples = std::slice::from_raw_parts(
|
|
||||||
buffer_ptr as *const i16,
|
|
||||||
num_frames as usize,
|
|
||||||
);
|
|
||||||
ring.write(samples);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = capture_client.ReleaseBuffer(num_frames) {
|
|
||||||
warn!("ReleaseBuffer failed: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("WASAPI capture thread stopping");
|
|
||||||
let _ = audio_client.Stop();
|
|
||||||
let _ = CloseHandle(event);
|
|
||||||
// _com_guard drops here, calling CoUninitialize.
|
|
||||||
|
|
||||||
// Silence INFINITE unused-import warning — it's referenced by the
|
|
||||||
// `windows` crate's WaitForSingleObject alternative but we use the
|
|
||||||
// 200 ms timeout variant instead. Explicit suppression for clarity.
|
|
||||||
let _ = INFINITE;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Best-effort device ID string for logging. Grabbing the friendly name via
|
|
||||||
/// PKEY_Device_FriendlyName requires IPropertyStore + PROPVARIANT plumbing
|
|
||||||
/// that's far more ceremony than a log line justifies; the ID is already
|
|
||||||
/// sufficient to confirm we opened the right endpoint.
|
|
||||||
///
|
|
||||||
/// Rust 2024 edition's `unsafe_op_in_unsafe_fn` lint requires explicit
|
|
||||||
/// `unsafe { ... }` blocks inside `unsafe fn` bodies for each unsafe call,
|
|
||||||
/// even though the whole function is already marked unsafe.
|
|
||||||
unsafe fn device_name(
|
|
||||||
device: &windows::Win32::Media::Audio::IMMDevice,
|
|
||||||
) -> Result<String, anyhow::Error> {
|
|
||||||
let id = unsafe { device.GetId() }.context("IMMDevice::GetId failed")?;
|
|
||||||
Ok(unsafe { id.to_string() }.unwrap_or_else(|_| "<non-utf16>".to_string()))
|
|
||||||
}
|
|
||||||
@@ -7,15 +7,14 @@ use std::time::{Duration, Instant};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use wzp_codec::dred_ffi::{DredDecoderHandle, DredState};
|
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
|
||||||
use wzp_codec::{
|
|
||||||
AdaptiveDecoder, AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector,
|
|
||||||
};
|
|
||||||
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
|
||||||
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
|
||||||
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
|
||||||
use wzp_proto::quality::AdaptiveQualityController;
|
use wzp_proto::quality::AdaptiveQualityController;
|
||||||
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
|
use wzp_proto::traits::{
|
||||||
|
AudioDecoder, AudioEncoder, FecDecoder, FecEncoder,
|
||||||
|
};
|
||||||
use wzp_proto::packet::QualityReport;
|
use wzp_proto::packet::QualityReport;
|
||||||
use wzp_proto::{CodecId, QualityProfile};
|
use wzp_proto::{CodecId, QualityProfile};
|
||||||
|
|
||||||
@@ -43,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
|
||||||
@@ -67,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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,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,
|
||||||
@@ -345,22 +340,6 @@ impl CallEncoder {
|
|||||||
let enc_len = self.audio_enc.encode(pcm, &mut encoded)?;
|
let enc_len = self.audio_enc.encode(pcm, &mut encoded)?;
|
||||||
encoded.truncate(enc_len);
|
encoded.truncate(enc_len);
|
||||||
|
|
||||||
// Phase 2: Opus tiers bypass RaptorQ entirely (DRED handles loss
|
|
||||||
// recovery at the codec layer). Codec2 tiers keep RaptorQ unchanged.
|
|
||||||
// On Opus packets, zero the FEC header fields so old receivers
|
|
||||||
// can cleanly identify "no RaptorQ block to assemble" and new
|
|
||||||
// receivers can short-circuit their FEC ingest path.
|
|
||||||
let is_opus = self.profile.codec.is_opus();
|
|
||||||
let (fec_block, fec_symbol, fec_ratio_encoded) = if is_opus {
|
|
||||||
(0u8, 0u8, 0u8)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
self.block_id,
|
|
||||||
self.frame_in_block,
|
|
||||||
MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build source media packet
|
// Build source media packet
|
||||||
let source_pkt = MediaPacket {
|
let source_pkt = MediaPacket {
|
||||||
header: MediaHeader {
|
header: MediaHeader {
|
||||||
@@ -368,11 +347,11 @@ impl CallEncoder {
|
|||||||
is_repair: false,
|
is_repair: false,
|
||||||
codec_id: self.profile.codec,
|
codec_id: self.profile.codec,
|
||||||
has_quality_report: false,
|
has_quality_report: false,
|
||||||
fec_ratio_encoded,
|
fec_ratio_encoded: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
|
||||||
seq: self.seq,
|
seq: self.seq,
|
||||||
timestamp: self.timestamp_ms,
|
timestamp: self.timestamp_ms,
|
||||||
fec_block,
|
fec_block: self.block_id,
|
||||||
fec_symbol,
|
fec_symbol: self.frame_in_block,
|
||||||
reserved: 0,
|
reserved: 0,
|
||||||
csrc_count: 0,
|
csrc_count: 0,
|
||||||
},
|
},
|
||||||
@@ -387,13 +366,11 @@ impl CallEncoder {
|
|||||||
|
|
||||||
let mut output = vec![source_pkt];
|
let mut output = vec![source_pkt];
|
||||||
|
|
||||||
// Codec2-only: feed RaptorQ and generate repair packets when the
|
// Add to FEC encoder
|
||||||
// block is full. Opus tiers skip this entire block — DRED (active
|
|
||||||
// in Phase 1) provides codec-layer loss recovery.
|
|
||||||
if !is_opus {
|
|
||||||
self.fec_enc.add_source_symbol(&encoded)?;
|
self.fec_enc.add_source_symbol(&encoded)?;
|
||||||
self.frame_in_block += 1;
|
self.frame_in_block += 1;
|
||||||
|
|
||||||
|
// If block is full, generate repair and finalize
|
||||||
if self.frame_in_block >= self.profile.frames_per_block {
|
if self.frame_in_block >= self.profile.frames_per_block {
|
||||||
if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) {
|
if let Ok(repairs) = self.fec_enc.generate_repair(self.profile.fec_ratio) {
|
||||||
for (sym_idx, repair_data) in repairs {
|
for (sym_idx, repair_data) in repairs {
|
||||||
@@ -423,7 +400,6 @@ impl CallEncoder {
|
|||||||
self.block_id = self.block_id.wrapping_add(1);
|
self.block_id = self.block_id.wrapping_add(1);
|
||||||
self.frame_in_block = 0;
|
self.frame_in_block = 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
@@ -458,12 +434,9 @@ impl CallEncoder {
|
|||||||
|
|
||||||
/// Manages the recv/decode side of a call.
|
/// Manages the recv/decode side of a call.
|
||||||
pub struct CallDecoder {
|
pub struct CallDecoder {
|
||||||
/// Audio decoder. Concrete `AdaptiveDecoder` (not `Box<dyn AudioDecoder>`)
|
/// Audio decoder.
|
||||||
/// because Phase 3b calls the inherent `reconstruct_from_dred` method,
|
audio_dec: Box<dyn AudioDecoder>,
|
||||||
/// which cannot live on the `AudioDecoder` trait without dragging libopus
|
/// FEC decoder.
|
||||||
/// types into `wzp-proto`.
|
|
||||||
audio_dec: AdaptiveDecoder,
|
|
||||||
/// FEC decoder (Codec2 tiers only; Opus bypasses RaptorQ per Phase 2).
|
|
||||||
fec_dec: RaptorQFecDecoder,
|
fec_dec: RaptorQFecDecoder,
|
||||||
/// Jitter buffer.
|
/// Jitter buffer.
|
||||||
jitter: JitterBuffer,
|
jitter: JitterBuffer,
|
||||||
@@ -477,24 +450,6 @@ pub struct CallDecoder {
|
|||||||
last_was_cn: bool,
|
last_was_cn: bool,
|
||||||
/// Mini-frame decompression context (tracks last full header baseline).
|
/// Mini-frame decompression context (tracks last full header baseline).
|
||||||
mini_context: MiniFrameContext,
|
mini_context: MiniFrameContext,
|
||||||
// ─── Phase 3b: DRED reconstruction state ──────────────────────────────
|
|
||||||
/// DRED side-channel parser (a separate libopus object from the decoder).
|
|
||||||
dred_decoder: DredDecoderHandle,
|
|
||||||
/// Scratch buffer used by `dred_decoder.parse_into` on every arriving
|
|
||||||
/// Opus packet. Reused across calls to avoid 10 KB alloc churn per packet.
|
|
||||||
dred_parse_scratch: DredState,
|
|
||||||
/// Cached "most recently parsed valid" DRED state, swapped with
|
|
||||||
/// `dred_parse_scratch` on successful parse. Used by `decode_next` when
|
|
||||||
/// the jitter buffer reports a gap.
|
|
||||||
last_good_dred: DredState,
|
|
||||||
/// Sequence number of the packet that produced `last_good_dred`. `None`
|
|
||||||
/// if no packet has yielded DRED state yet (cold start or legacy sender).
|
|
||||||
last_good_dred_seq: Option<u16>,
|
|
||||||
/// Phase 4 telemetry counter: gaps recovered via DRED reconstruction.
|
|
||||||
pub dred_reconstructions: u64,
|
|
||||||
/// Phase 4 telemetry counter: gaps filled via classical Opus PLC
|
|
||||||
/// (because no DRED state covered the gap, or the active codec is Codec2).
|
|
||||||
pub classical_plc_invocations: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CallDecoder {
|
impl CallDecoder {
|
||||||
@@ -504,19 +459,8 @@ impl CallDecoder {
|
|||||||
} else {
|
} else {
|
||||||
JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min)
|
JitterBuffer::new(config.jitter_target, config.jitter_max, config.jitter_min)
|
||||||
};
|
};
|
||||||
// Phase 3b: build the DRED parser + state buffers. These allocate
|
|
||||||
// libopus state (~10 KB each) once per call, not per packet — the
|
|
||||||
// scratch and last-good buffers are reused via std::mem::swap on
|
|
||||||
// every successful parse.
|
|
||||||
let dred_decoder =
|
|
||||||
DredDecoderHandle::new().expect("opus_dred_decoder_create failed at call setup");
|
|
||||||
let dred_parse_scratch =
|
|
||||||
DredState::new().expect("opus_dred_alloc failed at call setup (scratch)");
|
|
||||||
let last_good_dred =
|
|
||||||
DredState::new().expect("opus_dred_alloc failed at call setup (good state)");
|
|
||||||
Self {
|
Self {
|
||||||
audio_dec: AdaptiveDecoder::new(config.profile)
|
audio_dec: wzp_codec::create_decoder(config.profile),
|
||||||
.expect("failed to create adaptive decoder"),
|
|
||||||
fec_dec: wzp_fec::create_decoder(&config.profile),
|
fec_dec: wzp_fec::create_decoder(&config.profile),
|
||||||
jitter,
|
jitter,
|
||||||
quality: AdaptiveQualityController::new(),
|
quality: AdaptiveQualityController::new(),
|
||||||
@@ -524,12 +468,6 @@ impl CallDecoder {
|
|||||||
comfort_noise: ComfortNoise::new(50),
|
comfort_noise: ComfortNoise::new(50),
|
||||||
last_was_cn: false,
|
last_was_cn: false,
|
||||||
mini_context: MiniFrameContext::default(),
|
mini_context: MiniFrameContext::default(),
|
||||||
dred_decoder,
|
|
||||||
dred_parse_scratch,
|
|
||||||
last_good_dred,
|
|
||||||
last_good_dred_seq: None,
|
|
||||||
dred_reconstructions: 0,
|
|
||||||
classical_plc_invocations: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,105 +482,20 @@ impl CallDecoder {
|
|||||||
|
|
||||||
/// Feed a received media packet into the decode pipeline.
|
/// Feed a received media packet into the decode pipeline.
|
||||||
pub fn ingest(&mut self, packet: MediaPacket) {
|
pub fn ingest(&mut self, packet: MediaPacket) {
|
||||||
// Phase 2: Opus packets bypass RaptorQ. Codec2 packets still feed
|
// Feed to FEC decoder
|
||||||
// the FEC decoder for recovery. This also cleanly drops any stray
|
|
||||||
// Opus repair packets from an old sender (we don't push repair
|
|
||||||
// packets to the jitter buffer either, so they're effectively
|
|
||||||
// ignored — a graceful mixed-version degradation).
|
|
||||||
if !packet.header.codec_id.is_opus() {
|
|
||||||
let _ = self.fec_dec.add_symbol(
|
let _ = self.fec_dec.add_symbol(
|
||||||
packet.header.fec_block,
|
packet.header.fec_block,
|
||||||
packet.header.fec_symbol,
|
packet.header.fec_symbol,
|
||||||
packet.header.is_repair,
|
packet.header.is_repair,
|
||||||
&packet.payload,
|
&packet.payload,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3b: Opus source packets carry DRED side-channel data in
|
// If not a repair packet, also feed directly to jitter buffer
|
||||||
// libopus 1.5. Parse it into the scratch state and, on success,
|
|
||||||
// swap with the cached `last_good_dred` so later gap reconstruction
|
|
||||||
// has fresh neural redundancy to draw from. Parsing happens before
|
|
||||||
// the jitter push because the jitter buffer consumes the packet.
|
|
||||||
if packet.header.codec_id.is_opus() && !packet.header.is_repair {
|
|
||||||
match self
|
|
||||||
.dred_decoder
|
|
||||||
.parse_into(&mut self.dred_parse_scratch, &packet.payload)
|
|
||||||
{
|
|
||||||
Ok(available) if available > 0 => {
|
|
||||||
// Swap the freshly parsed state into `last_good_dred`.
|
|
||||||
// The old good state (now in scratch) is about to be
|
|
||||||
// overwritten on the next parse — its contents are
|
|
||||||
// not needed after this swap.
|
|
||||||
std::mem::swap(&mut self.dred_parse_scratch, &mut self.last_good_dred);
|
|
||||||
self.last_good_dred_seq = Some(packet.header.seq);
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
// Packet had no DRED data (return 0). Leave the cached
|
|
||||||
// state untouched — it may still cover upcoming gaps
|
|
||||||
// from a warm-up period where the encoder was producing
|
|
||||||
// DRED bytes. The scratch buffer was potentially written
|
|
||||||
// but its `samples_available` is 0 so it's harmless.
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
debug!("DRED parse error (ignored): {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source packets (Opus or Codec2) go to the jitter buffer for decode.
|
|
||||||
// Repair packets never reach the jitter buffer; for Codec2 they're
|
|
||||||
// used by the FEC decoder above, for Opus they're dropped here.
|
|
||||||
if !packet.header.is_repair {
|
if !packet.header.is_repair {
|
||||||
self.jitter.push(packet);
|
self.jitter.push(packet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -657,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),
|
||||||
@@ -674,72 +524,19 @@ impl CallDecoder {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
PlayoutResult::Missing { seq } => {
|
PlayoutResult::Missing { seq } => {
|
||||||
// Only attempt recovery if there are still packets buffered ahead.
|
// Only generate PLC if there are still packets buffered ahead.
|
||||||
// Otherwise we've drained everything — return None to stop.
|
// Otherwise we've drained everything — return None to stop.
|
||||||
if self.jitter.depth() == 0 {
|
if self.jitter.depth() > 0 {
|
||||||
self.jitter.record_underrun();
|
debug!(seq, "packet loss, generating PLC");
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3b: try DRED reconstruction first. If we have a
|
|
||||||
// recent DRED state from a packet whose seq > missing seq,
|
|
||||||
// and the seq delta (in samples) fits within the state's
|
|
||||||
// available window, libopus can synthesize a plausible
|
|
||||||
// replacement for the lost frame. Fall back to classical
|
|
||||||
// PLC when no state covers the gap, when the active codec
|
|
||||||
// is Codec2, or when the reconstruction itself errors.
|
|
||||||
if self.profile.codec.is_opus() {
|
|
||||||
if let Some(last_seq) = self.last_good_dred_seq {
|
|
||||||
// How many frames ahead of the missing seq is the
|
|
||||||
// last-good packet? Use wrapping arithmetic for the
|
|
||||||
// u16 seq space.
|
|
||||||
let seq_delta = last_seq.wrapping_sub(seq);
|
|
||||||
// Reject stale or backward state. u16 wraparound
|
|
||||||
// would make a "seq went backward" delta very large;
|
|
||||||
// cap at a sane forward-looking window.
|
|
||||||
const MAX_SEQ_DELTA: u16 = 128;
|
|
||||||
if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA {
|
|
||||||
let frame_samples =
|
|
||||||
(48_000 * self.profile.frame_duration_ms as i32) / 1000;
|
|
||||||
let offset_samples = seq_delta as i32 * frame_samples;
|
|
||||||
let available = self.last_good_dred.samples_available();
|
|
||||||
if offset_samples > 0 && offset_samples <= available {
|
|
||||||
match self.audio_dec.reconstruct_from_dred(
|
|
||||||
&self.last_good_dred,
|
|
||||||
offset_samples,
|
|
||||||
pcm,
|
|
||||||
) {
|
|
||||||
Ok(n) => {
|
|
||||||
self.dred_reconstructions += 1;
|
|
||||||
self.jitter.record_decode();
|
|
||||||
debug!(
|
|
||||||
seq,
|
|
||||||
last_seq,
|
|
||||||
offset_samples,
|
|
||||||
available,
|
|
||||||
"DRED reconstruction for gap"
|
|
||||||
);
|
|
||||||
return Some(n);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// Reconstruction failed — fall
|
|
||||||
// through to classical PLC below.
|
|
||||||
debug!(seq, "DRED reconstruct error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classical PLC fallback (also the Codec2 path).
|
|
||||||
debug!(seq, "packet loss, generating classical PLC");
|
|
||||||
self.classical_plc_invocations += 1;
|
|
||||||
let result = self.audio_dec.decode_lost(pcm).ok();
|
let result = self.audio_dec.decode_lost(pcm).ok();
|
||||||
if result.is_some() {
|
if result.is_some() {
|
||||||
self.jitter.record_decode();
|
self.jitter.record_decode();
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
|
} else {
|
||||||
|
self.jitter.record_underrun();
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PlayoutResult::NotReady => {
|
PlayoutResult::NotReady => {
|
||||||
self.jitter.record_underrun();
|
self.jitter.record_underrun();
|
||||||
@@ -762,19 +559,6 @@ impl CallDecoder {
|
|||||||
pub fn reset_stats(&mut self) {
|
pub fn reset_stats(&mut self) {
|
||||||
self.jitter.reset_stats();
|
self.jitter.reset_stats();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 3b introspection: sequence number of the most recently parsed
|
|
||||||
/// valid DRED state, or `None` if no Opus packet has yielded DRED data
|
|
||||||
/// yet. Used by tests to debug reconstruction eligibility.
|
|
||||||
pub fn last_good_dred_seq(&self) -> Option<u16> {
|
|
||||||
self.last_good_dred_seq
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 3b introspection: samples of audio history currently available
|
|
||||||
/// in the cached DRED state.
|
|
||||||
pub fn last_good_dred_samples_available(&self) -> i32 {
|
|
||||||
self.last_good_dred.samples_available()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Periodic telemetry logger for jitter buffer statistics.
|
/// Periodic telemetry logger for jitter buffer statistics.
|
||||||
@@ -836,83 +620,18 @@ mod tests {
|
|||||||
assert!(!packets[0].header.is_repair);
|
assert!(!packets[0].header.is_repair);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2: Opus packets have zero FEC header fields — no block, no
|
|
||||||
/// symbol index, no repair ratio. The RaptorQ layer is bypassed
|
|
||||||
/// entirely on the Opus tiers.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opus_source_packets_have_zero_fec_header_fields() {
|
fn encoder_generates_repair_on_full_block() {
|
||||||
let config = CallConfig {
|
let config = CallConfig {
|
||||||
profile: QualityProfile::GOOD, // Opus 24k
|
profile: QualityProfile::GOOD, // 5 frames/block
|
||||||
suppression_enabled: false, // skip silence gate for this test
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut enc = CallEncoder::new(&config);
|
let mut enc = CallEncoder::new(&config);
|
||||||
// Non-silent sine wave so silence detection doesn't suppress us
|
let pcm = vec![0i16; 960];
|
||||||
// even with suppression_enabled=false (belt and braces).
|
|
||||||
let pcm: Vec<i16> = (0..960)
|
|
||||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
|
||||||
.collect();
|
|
||||||
let packets = enc.encode_frame(&pcm).unwrap();
|
|
||||||
assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet");
|
|
||||||
let hdr = &packets[0].header;
|
|
||||||
assert!(hdr.codec_id.is_opus());
|
|
||||||
assert!(!hdr.is_repair);
|
|
||||||
assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0");
|
|
||||||
assert_eq!(hdr.fec_symbol, 0, "Opus fec_symbol must be 0");
|
|
||||||
assert_eq!(hdr.fec_ratio_encoded, 0, "Opus fec_ratio_encoded must be 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 2: Opus never emits repair packets, regardless of how many
|
let mut total_packets = 0;
|
||||||
/// source frames are fed in. DRED (Phase 1) provides loss recovery at
|
let mut repair_count = 0;
|
||||||
/// the codec layer; RaptorQ is disabled on Opus tiers.
|
for _ in 0..5 {
|
||||||
#[test]
|
|
||||||
fn opus_encoder_never_emits_repair_packets() {
|
|
||||||
let config = CallConfig {
|
|
||||||
profile: QualityProfile::GOOD, // 5 frames/block in the Codec2 sense
|
|
||||||
suppression_enabled: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut enc = CallEncoder::new(&config);
|
|
||||||
let pcm: Vec<i16> = (0..960)
|
|
||||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Encode well beyond a block boundary to prove no repair ever comes out.
|
|
||||||
let mut total_packets = 0usize;
|
|
||||||
let mut repair_count = 0usize;
|
|
||||||
for _ in 0..20 {
|
|
||||||
let packets = enc.encode_frame(&pcm).unwrap();
|
|
||||||
total_packets += packets.len();
|
|
||||||
repair_count += packets.iter().filter(|p| p.header.is_repair).count();
|
|
||||||
}
|
|
||||||
assert_eq!(repair_count, 0, "Opus must emit zero repair packets");
|
|
||||||
assert_eq!(
|
|
||||||
total_packets, 20,
|
|
||||||
"20 source frames → 20 source packets (1:1, no RaptorQ expansion)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 2: Codec2 still emits repair packets with RaptorQ ratio unchanged.
|
|
||||||
/// DRED is libopus-only and does not apply here, so RaptorQ is still the
|
|
||||||
/// primary loss-recovery mechanism on Codec2 tiers.
|
|
||||||
#[test]
|
|
||||||
fn codec2_encoder_generates_repair_on_full_block() {
|
|
||||||
let config = CallConfig {
|
|
||||||
profile: QualityProfile::CATASTROPHIC, // Codec2 1200, 8 frames/block, ratio 1.0
|
|
||||||
suppression_enabled: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut enc = CallEncoder::new(&config);
|
|
||||||
// Codec2 takes 48 kHz samples and downsamples internally.
|
|
||||||
// CATASTROPHIC uses 40 ms frames → 1920 samples.
|
|
||||||
let pcm: Vec<i16> = (0..1920)
|
|
||||||
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut total_packets = 0usize;
|
|
||||||
let mut repair_count = 0usize;
|
|
||||||
// Run long enough to cross the 8-frame block boundary and see repairs.
|
|
||||||
for _ in 0..16 {
|
|
||||||
let packets = enc.encode_frame(&pcm).unwrap();
|
let packets = enc.encode_frame(&pcm).unwrap();
|
||||||
for p in &packets {
|
for p in &packets {
|
||||||
if p.header.is_repair {
|
if p.header.is_repair {
|
||||||
@@ -921,10 +640,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
total_packets += packets.len();
|
total_packets += packets.len();
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(repair_count > 0, "should have repair packets after full block");
|
||||||
repair_count > 0,
|
assert!(total_packets > 5, "total {total_packets} should exceed 5 source");
|
||||||
"Codec2 must still emit repair packets (got {repair_count} repairs, {total_packets} total)"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -955,219 +672,6 @@ mod tests {
|
|||||||
assert!(dec.decode_next(&mut pcm).is_none());
|
assert!(dec.decode_next(&mut pcm).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 3b — DRED reconstruction on packet loss ────────────────────
|
|
||||||
|
|
||||||
/// Helper: create a CallEncoder/CallDecoder pair with the given profile
|
|
||||||
/// and silence suppression disabled so silence-detection doesn't drop
|
|
||||||
/// our synthetic test frames.
|
|
||||||
fn encoder_decoder_pair(profile: QualityProfile) -> (CallEncoder, CallDecoder) {
|
|
||||||
let config = CallConfig {
|
|
||||||
profile,
|
|
||||||
suppression_enabled: false,
|
|
||||||
// Small jitter buffer so decode_next drains quickly in tests.
|
|
||||||
jitter_min: 2,
|
|
||||||
jitter_target: 3,
|
|
||||||
jitter_max: 20,
|
|
||||||
adaptive_jitter: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
(CallEncoder::new(&config), CallDecoder::new(&config))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: generate a non-silent 20 ms frame of 300 Hz sine at the
|
|
||||||
/// given sample offset so consecutive frames form a continuous tone.
|
|
||||||
fn voice_frame_20ms(sample_offset: usize) -> Vec<i16> {
|
|
||||||
(0..960)
|
|
||||||
.map(|i| {
|
|
||||||
let t = (sample_offset + i) as f64 / 48_000.0;
|
|
||||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 3b probe: sweep packet_loss_perc values to find the minimum
|
|
||||||
/// that produces a samples_available ≥ 960 (enough to reconstruct a
|
|
||||||
/// single 20 ms Opus frame). This guides the production loss floor.
|
|
||||||
#[test]
|
|
||||||
#[ignore] // diagnostic only — run with `cargo test ... -- --ignored --nocapture`
|
|
||||||
fn probe_dred_samples_available_by_loss_floor() {
|
|
||||||
use wzp_codec::opus_enc::OpusEncoder;
|
|
||||||
use wzp_proto::traits::AudioEncoder;
|
|
||||||
|
|
||||||
for loss_pct in [5u8, 10, 15, 20, 25, 40, 60, 80].iter().copied() {
|
|
||||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
enc.set_expected_loss(loss_pct);
|
|
||||||
let (_drop_enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
|
||||||
|
|
||||||
for i in 0..60u16 {
|
|
||||||
let pcm = voice_frame_20ms(i as usize * 960);
|
|
||||||
let mut encoded = vec![0u8; 512];
|
|
||||||
let n = enc.encode(&pcm, &mut encoded).unwrap();
|
|
||||||
encoded.truncate(n);
|
|
||||||
let pkt = MediaPacket {
|
|
||||||
header: MediaHeader {
|
|
||||||
version: 0,
|
|
||||||
is_repair: false,
|
|
||||||
codec_id: CodecId::Opus24k,
|
|
||||||
has_quality_report: false,
|
|
||||||
fec_ratio_encoded: 0,
|
|
||||||
seq: i,
|
|
||||||
timestamp: (i as u32) * 20,
|
|
||||||
fec_block: 0,
|
|
||||||
fec_symbol: 0,
|
|
||||||
reserved: 0,
|
|
||||||
csrc_count: 0,
|
|
||||||
},
|
|
||||||
payload: Bytes::from(encoded),
|
|
||||||
quality_report: None,
|
|
||||||
};
|
|
||||||
dec.ingest(pkt);
|
|
||||||
}
|
|
||||||
eprintln!(
|
|
||||||
"[phase3b probe] loss_pct={loss_pct} samples_available={}",
|
|
||||||
dec.last_good_dred_samples_available()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 3b: simulated single-packet loss on an Opus call triggers a
|
|
||||||
/// DRED reconstruction rather than a classical PLC fill. Runs the full
|
|
||||||
/// encode → ingest → decode_next pipeline.
|
|
||||||
#[test]
|
|
||||||
fn opus_single_packet_loss_is_recovered_via_dred() {
|
|
||||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
|
||||||
|
|
||||||
// Warm-up: encode and ingest 60 frames (1.2 s) so the DRED emitter
|
|
||||||
// has had time to fill its 200 ms window and at least one
|
|
||||||
// successful DRED parse has happened on the decoder side.
|
|
||||||
let warmup_frames = 60;
|
|
||||||
for i in 0..warmup_frames {
|
|
||||||
let pcm = voice_frame_20ms(i * 960);
|
|
||||||
let packets = enc.encode_frame(&pcm).unwrap();
|
|
||||||
for pkt in packets {
|
|
||||||
dec.ingest(pkt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain the warm-up frames through the decoder to advance the
|
|
||||||
// jitter buffer cursor past them.
|
|
||||||
let mut out = vec![0i16; 960];
|
|
||||||
while dec.decode_next(&mut out).is_some() {}
|
|
||||||
|
|
||||||
// Encode the next three frames but skip ingesting the middle one.
|
|
||||||
let base_offset = warmup_frames * 960;
|
|
||||||
let pcm_a = voice_frame_20ms(base_offset);
|
|
||||||
let pcm_b = voice_frame_20ms(base_offset + 960);
|
|
||||||
let pcm_c = voice_frame_20ms(base_offset + 1920);
|
|
||||||
|
|
||||||
let pkts_a = enc.encode_frame(&pcm_a).unwrap();
|
|
||||||
let pkts_b = enc.encode_frame(&pcm_b).unwrap(); // DROP THIS ONE
|
|
||||||
let pkts_c = enc.encode_frame(&pcm_c).unwrap();
|
|
||||||
|
|
||||||
for pkt in pkts_a {
|
|
||||||
dec.ingest(pkt);
|
|
||||||
}
|
|
||||||
// Skip pkts_b entirely — this is the "packet loss".
|
|
||||||
drop(pkts_b);
|
|
||||||
for pkt in pkts_c {
|
|
||||||
dec.ingest(pkt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain again. Somewhere in here decode_next will hit Missing()
|
|
||||||
// for the dropped packet and attempt DRED reconstruction.
|
|
||||||
let baseline_dred = dec.dred_reconstructions;
|
|
||||||
let baseline_plc = dec.classical_plc_invocations;
|
|
||||||
eprintln!(
|
|
||||||
"[phase3b probe] pre-drain: last_good_seq={:?} samples_available={}",
|
|
||||||
dec.last_good_dred_seq(),
|
|
||||||
dec.last_good_dred_samples_available()
|
|
||||||
);
|
|
||||||
while dec.decode_next(&mut out).is_some() {}
|
|
||||||
|
|
||||||
let dred_delta = dec.dred_reconstructions - baseline_dred;
|
|
||||||
let plc_delta = dec.classical_plc_invocations - baseline_plc;
|
|
||||||
eprintln!(
|
|
||||||
"[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
dred_delta >= 1,
|
|
||||||
"expected ≥1 DRED reconstruction on single-packet loss, \
|
|
||||||
got dred_delta={dred_delta} plc_delta={plc_delta}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 3b: lossless stream never triggers DRED reconstruction or PLC.
|
|
||||||
/// Baseline behavior — verifies the Missing() branch is not spuriously taken.
|
|
||||||
#[test]
|
|
||||||
fn opus_lossless_ingest_never_triggers_dred_or_plc() {
|
|
||||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::GOOD);
|
|
||||||
|
|
||||||
// Encode + ingest 40 frames with no drops.
|
|
||||||
for i in 0..40 {
|
|
||||||
let pcm = voice_frame_20ms(i * 960);
|
|
||||||
let packets = enc.encode_frame(&pcm).unwrap();
|
|
||||||
for pkt in packets {
|
|
||||||
dec.ingest(pkt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = vec![0i16; 960];
|
|
||||||
while dec.decode_next(&mut out).is_some() {}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
dec.dred_reconstructions, 0,
|
|
||||||
"lossless stream should not reconstruct"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dec.classical_plc_invocations, 0,
|
|
||||||
"lossless stream should not PLC"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 3b: Codec2 calls fall through to classical PLC on loss.
|
|
||||||
/// DRED is libopus-only, so even if the decoder's DRED state were
|
|
||||||
/// populated (it won't be — Codec2 packets don't carry DRED bytes),
|
|
||||||
/// `reconstruct_from_dred` rejects Codec2 at the AdaptiveDecoder
|
|
||||||
/// level. This test guards the Codec2 side of the protection split.
|
|
||||||
#[test]
|
|
||||||
fn codec2_loss_falls_through_to_classical_plc() {
|
|
||||||
let (mut enc, mut dec) = encoder_decoder_pair(QualityProfile::CATASTROPHIC);
|
|
||||||
|
|
||||||
// Codec2 1200 uses 40 ms frames → 1920 samples at 48 kHz (before
|
|
||||||
// the downsample inside the codec). Encode 20 frames (~0.8 s).
|
|
||||||
let make_frame = |offset: usize| -> Vec<i16> {
|
|
||||||
(0..1920)
|
|
||||||
.map(|i| {
|
|
||||||
let t = (offset + i) as f64 / 48_000.0;
|
|
||||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
for i in 0..20 {
|
|
||||||
let pcm = make_frame(i * 1920);
|
|
||||||
let packets = enc.encode_frame(&pcm).unwrap();
|
|
||||||
for pkt in packets {
|
|
||||||
// Drop every 5th source packet to simulate loss.
|
|
||||||
if !pkt.header.is_repair && i % 5 == 3 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
dec.ingest(pkt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = vec![0i16; 1920];
|
|
||||||
while dec.decode_next(&mut out).is_some() {}
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
dec.dred_reconstructions, 0,
|
|
||||||
"Codec2 must never reconstruct via DRED"
|
|
||||||
);
|
|
||||||
// classical_plc_invocations may or may not trigger depending on
|
|
||||||
// whether the jitter buffer sees Missing before draining — the key
|
|
||||||
// assertion is that DRED is not used. PLC count is advisory.
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- QualityAdapter tests ----
|
// ---- QualityAdapter tests ----
|
||||||
|
|
||||||
/// Helper: build a QualityReport from human-readable loss% and RTT ms.
|
/// Helper: build a QualityReport from human-readable loss% and RTT ms.
|
||||||
|
|||||||
@@ -47,11 +47,6 @@ struct CliArgs {
|
|||||||
room: Option<String>,
|
room: Option<String>,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
_metrics_file: Option<String>,
|
_metrics_file: Option<String>,
|
||||||
version_check: bool,
|
|
||||||
/// Connect to relay for persistent signaling (direct calls).
|
|
||||||
signal: bool,
|
|
||||||
/// Place a direct call to a fingerprint (requires --signal).
|
|
||||||
call_target: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliArgs {
|
impl CliArgs {
|
||||||
@@ -93,20 +88,12 @@ fn parse_args() -> CliArgs {
|
|||||||
let mut room = None;
|
let mut room = None;
|
||||||
let mut token = None;
|
let mut token = None;
|
||||||
let mut metrics_file = None;
|
let mut metrics_file = None;
|
||||||
let mut version_check = false;
|
|
||||||
let mut relay_str = None;
|
let mut relay_str = None;
|
||||||
let mut signal = false;
|
|
||||||
let mut call_target = None;
|
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
match args[i].as_str() {
|
match args[i].as_str() {
|
||||||
"--live" => live = true,
|
"--live" => live = true,
|
||||||
"--signal" => signal = true,
|
|
||||||
"--call" => {
|
|
||||||
i += 1;
|
|
||||||
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string());
|
|
||||||
}
|
|
||||||
"--send-tone" => {
|
"--send-tone" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
send_tone_secs = Some(
|
send_tone_secs = Some(
|
||||||
@@ -182,7 +169,6 @@ fn parse_args() -> CliArgs {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
"--sweep" => sweep = true,
|
"--sweep" => sweep = true,
|
||||||
"--version-check" => { version_check = true; }
|
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
eprintln!("Usage: wzp-client [options] [relay-addr]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -235,9 +221,6 @@ fn parse_args() -> CliArgs {
|
|||||||
room,
|
room,
|
||||||
token,
|
token,
|
||||||
_metrics_file: metrics_file,
|
_metrics_file: metrics_file,
|
||||||
version_check,
|
|
||||||
signal,
|
|
||||||
call_target,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,32 +239,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --version-check: query relay version over QUIC and exit
|
|
||||||
if cli.version_check {
|
|
||||||
let client_config = wzp_transport::client_config();
|
|
||||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse()?;
|
|
||||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
|
||||||
let conn = wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
|
|
||||||
match conn.accept_uni().await {
|
|
||||||
Ok(mut recv) => {
|
|
||||||
let data = recv.read_to_end(256).await.unwrap_or_default();
|
|
||||||
let version = String::from_utf8_lossy(&data);
|
|
||||||
println!("{} {}", cli.relay_addr, version.trim());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("relay {} does not support version query: {e}", cli.relay_addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
endpoint.close(0u32.into(), b"done");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --signal mode: persistent signaling for direct calls
|
|
||||||
if cli.signal {
|
|
||||||
let seed = cli.resolve_seed();
|
|
||||||
return run_signal_mode(cli.relay_addr, seed, cli.token, cli.call_target).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let seed = cli.resolve_seed();
|
let seed = cli.resolve_seed();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -293,11 +250,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"WarzonePhone client"
|
"WarzonePhone client"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use raw room name as SNI (consistent with Android + Desktop clients for federation)
|
// Hash room name for SNI privacy (or "default" if none specified)
|
||||||
let sni = match &cli.room {
|
let sni = match &cli.room {
|
||||||
Some(name) => {
|
Some(name) => {
|
||||||
info!(room = %name, "using room name as SNI");
|
let hashed = wzp_crypto::hash_room_name(name);
|
||||||
name.clone()
|
info!(room = %name, hashed = %hashed, "room name hashed for SNI");
|
||||||
|
hashed
|
||||||
}
|
}
|
||||||
None => "default".to_string(),
|
None => "default".to_string(),
|
||||||
};
|
};
|
||||||
@@ -316,26 +274,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
|
|
||||||
// Register shutdown handler so SIGTERM/SIGINT always closes QUIC cleanly.
|
|
||||||
// Without this, killed clients leave zombie connections on the relay for ~30s.
|
|
||||||
{
|
|
||||||
let shutdown_transport = transport.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
||||||
.expect("failed to register SIGTERM handler");
|
|
||||||
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
|
|
||||||
.expect("failed to register SIGINT handler");
|
|
||||||
tokio::select! {
|
|
||||||
_ = sigterm.recv() => { info!("SIGTERM received, closing connection..."); }
|
|
||||||
_ = sigint.recv() => { info!("SIGINT received, closing connection..."); }
|
|
||||||
}
|
|
||||||
// Close the QUIC connection immediately (APPLICATION_CLOSE frame).
|
|
||||||
// Don't call process::exit — let the main task detect the closed
|
|
||||||
// connection and perform clean shutdown (e.g., save recordings).
|
|
||||||
shutdown_transport.connection().close(0u32.into(), b"shutdown");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send auth token if provided (relay with --auth-url expects this first)
|
// Send auth token if provided (relay with --auth-url expects this first)
|
||||||
if let Some(ref token) = cli.token {
|
if let Some(ref token) = cli.token {
|
||||||
let auth = wzp_proto::SignalMessage::AuthToken {
|
let auth = wzp_proto::SignalMessage::AuthToken {
|
||||||
@@ -626,21 +564,11 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
|||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let config = CallConfig::default();
|
let config = CallConfig::default();
|
||||||
let mut encoder = CallEncoder::new(&config);
|
let mut encoder = CallEncoder::new(&config);
|
||||||
let mut frame = vec![0i16; FRAME_SAMPLES];
|
|
||||||
loop {
|
loop {
|
||||||
// Pull a full 20 ms frame from the capture ring. The ring
|
let frame = match capture.read_frame() {
|
||||||
// may return a partial read when the CPAL callback hasn't
|
Some(f) => f,
|
||||||
// produced enough samples yet — keep reading until we
|
None => break,
|
||||||
// accumulate a whole frame, sleeping briefly on empty
|
};
|
||||||
// returns so we don't hot-spin the CPU.
|
|
||||||
let mut filled = 0usize;
|
|
||||||
while filled < FRAME_SAMPLES {
|
|
||||||
let n = capture.ring().read(&mut frame[filled..]);
|
|
||||||
filled += n;
|
|
||||||
if n == 0 {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let packets = match encoder.encode_frame(&frame) {
|
let packets = match encoder.encode_frame(&frame) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -671,13 +599,7 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
|||||||
// Repair packets feed the FEC decoder but don't produce audio.
|
// Repair packets feed the FEC decoder but don't produce audio.
|
||||||
if !is_repair {
|
if !is_repair {
|
||||||
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
|
||||||
// Push the decoded frame into the playback
|
playback.write_frame(&pcm_buf);
|
||||||
// ring. The CPAL output callback drains from
|
|
||||||
// here on its own clock; if the ring is full
|
|
||||||
// (rare in CLI live mode) the write returns
|
|
||||||
// a short count and the tail is dropped,
|
|
||||||
// which is the correct real-time behavior.
|
|
||||||
playback.ring().write(&pcm_buf);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -702,194 +624,3 @@ async fn run_live(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Resu
|
|||||||
info!("done");
|
info!("done");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persistent signaling mode for direct 1:1 calls.
|
|
||||||
async fn run_signal_mode(
|
|
||||||
relay_addr: SocketAddr,
|
|
||||||
seed: wzp_crypto::Seed,
|
|
||||||
token: Option<String>,
|
|
||||||
call_target: Option<String>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
use wzp_proto::SignalMessage;
|
|
||||||
|
|
||||||
let identity = seed.derive_identity();
|
|
||||||
let pub_id = identity.public_identity();
|
|
||||||
let fp = pub_id.fingerprint.to_string();
|
|
||||||
let identity_pub = *pub_id.signing.as_bytes();
|
|
||||||
info!(fingerprint = %fp, "signal mode");
|
|
||||||
|
|
||||||
// Connect to relay with SNI "_signal"
|
|
||||||
let client_config = wzp_transport::client_config();
|
|
||||||
let bind_addr: SocketAddr = if relay_addr.is_ipv6() {
|
|
||||||
"[::]:0".parse()?
|
|
||||||
} else {
|
|
||||||
"0.0.0.0:0".parse()?
|
|
||||||
};
|
|
||||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
|
||||||
let conn = wzp_transport::connect(&endpoint, relay_addr, "_signal", client_config).await?;
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
|
||||||
info!("connected to relay (signal channel)");
|
|
||||||
|
|
||||||
// Auth if token provided
|
|
||||||
if let Some(ref tok) = token {
|
|
||||||
transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register presence (signature not verified in Phase 1)
|
|
||||||
transport.send_signal(&SignalMessage::RegisterPresence {
|
|
||||||
identity_pub,
|
|
||||||
signature: vec![], // Phase 1: not verified
|
|
||||||
alias: None,
|
|
||||||
}).await?;
|
|
||||||
|
|
||||||
// Wait for ack
|
|
||||||
match transport.recv_signal().await? {
|
|
||||||
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
|
|
||||||
info!(fingerprint = %fp, "registered on relay — waiting for calls");
|
|
||||||
}
|
|
||||||
Some(SignalMessage::RegisterPresenceAck { success: false, error }) => {
|
|
||||||
anyhow::bail!("registration failed: {}", error.unwrap_or_default());
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
anyhow::bail!("unexpected response: {other:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If --call specified, place the call
|
|
||||||
if let Some(ref target) = call_target {
|
|
||||||
info!(target = %target, "placing direct call...");
|
|
||||||
let call_id = format!("{:016x}", std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
|
||||||
|
|
||||||
transport.send_signal(&SignalMessage::DirectCallOffer {
|
|
||||||
caller_fingerprint: fp.clone(),
|
|
||||||
caller_alias: None,
|
|
||||||
target_fingerprint: target.clone(),
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
identity_pub,
|
|
||||||
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
|
|
||||||
signature: vec![],
|
|
||||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
|
||||||
}).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal recv loop — handle incoming signals
|
|
||||||
let signal_transport = transport.clone();
|
|
||||||
let relay = relay_addr;
|
|
||||||
let my_seed = seed.0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match signal_transport.recv_signal().await {
|
|
||||||
Ok(Some(msg)) => match msg {
|
|
||||||
SignalMessage::CallRinging { call_id } => {
|
|
||||||
info!(call_id = %call_id, "ringing...");
|
|
||||||
}
|
|
||||||
SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. } => {
|
|
||||||
info!(
|
|
||||||
from = %caller_fingerprint,
|
|
||||||
alias = ?caller_alias,
|
|
||||||
call_id = %call_id,
|
|
||||||
"incoming call — auto-accepting (generic)"
|
|
||||||
);
|
|
||||||
// Auto-accept for CLI testing
|
|
||||||
let _ = signal_transport.send_signal(&SignalMessage::DirectCallAnswer {
|
|
||||||
call_id,
|
|
||||||
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
|
|
||||||
identity_pub: Some(identity_pub),
|
|
||||||
ephemeral_pub: None,
|
|
||||||
signature: None,
|
|
||||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => {
|
|
||||||
info!(call_id = %call_id, mode = ?accept_mode, "call answered");
|
|
||||||
}
|
|
||||||
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay } => {
|
|
||||||
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
|
|
||||||
|
|
||||||
// Connect to the media room
|
|
||||||
let media_relay: SocketAddr = setup_relay.parse().unwrap_or(relay);
|
|
||||||
let media_cfg = wzp_transport::client_config();
|
|
||||||
match wzp_transport::connect(&endpoint, media_relay, &room, media_cfg).await {
|
|
||||||
Ok(media_conn) => {
|
|
||||||
let media_transport = Arc::new(wzp_transport::QuinnTransport::new(media_conn));
|
|
||||||
|
|
||||||
// Crypto handshake
|
|
||||||
match wzp_client::handshake::perform_handshake(&*media_transport, &my_seed, None).await {
|
|
||||||
Ok(_session) => {
|
|
||||||
info!("media connected — sending tone (press Ctrl+C to hang up)");
|
|
||||||
|
|
||||||
// Simple tone sender for testing
|
|
||||||
let mt = media_transport.clone();
|
|
||||||
let send_task = tokio::spawn(async move {
|
|
||||||
let config = wzp_client::call::CallConfig::default();
|
|
||||||
let mut encoder = wzp_client::call::CallEncoder::new(&config);
|
|
||||||
let duration = tokio::time::Duration::from_millis(20);
|
|
||||||
loop {
|
|
||||||
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
|
|
||||||
.map(|_| 0i16) // silence — could be tone
|
|
||||||
.collect();
|
|
||||||
if let Ok(pkts) = encoder.encode_frame(&pcm) {
|
|
||||||
for pkt in &pkts {
|
|
||||||
if mt.send_media(pkt).await.is_err() { return; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(duration).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for hangup or ctrl+c
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
sig = signal_transport.recv_signal() => {
|
|
||||||
match sig {
|
|
||||||
Ok(Some(SignalMessage::Hangup { .. })) => {
|
|
||||||
info!("remote hung up");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(None) | Err(_) => break,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tokio::signal::ctrl_c() => {
|
|
||||||
info!("hanging up...");
|
|
||||||
let _ = signal_transport.send_signal(&SignalMessage::Hangup {
|
|
||||||
reason: wzp_proto::HangupReason::Normal,
|
|
||||||
}).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
send_task.abort();
|
|
||||||
media_transport.close().await.ok();
|
|
||||||
info!("call ended");
|
|
||||||
}
|
|
||||||
Err(e) => error!("media handshake failed: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => error!("media connect failed: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SignalMessage::Hangup { reason } => {
|
|
||||||
info!(reason = ?reason, "call ended by remote");
|
|
||||||
}
|
|
||||||
SignalMessage::Pong { .. } => {}
|
|
||||||
other => {
|
|
||||||
info!("signal: {:?}", std::mem::discriminant(&other));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Ok(None) => {
|
|
||||||
info!("signal connection closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("signal error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transport.close().await.ok();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
||||||
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
|
|
||||||
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
||||||
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
||||||
SignalMessage::Hold => CallSignalType::Hold,
|
SignalMessage::Hold => CallSignalType::Hold,
|
||||||
@@ -111,15 +110,6 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|||||||
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
SignalMessage::FederationHello { .. }
|
|
||||||
| SignalMessage::GlobalRoomActive { .. }
|
|
||||||
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
|
|
||||||
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
|
||||||
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
|
||||||
SignalMessage::RegisterPresence { .. }
|
|
||||||
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ pub async fn perform_handshake(
|
|||||||
ephemeral_pub,
|
ephemeral_pub,
|
||||||
signature,
|
signature,
|
||||||
supported_profiles: vec![
|
supported_profiles: vec![
|
||||||
QualityProfile::STUDIO_64K,
|
|
||||||
QualityProfile::STUDIO_48K,
|
|
||||||
QualityProfile::STUDIO_32K,
|
|
||||||
QualityProfile::GOOD,
|
QualityProfile::GOOD,
|
||||||
QualityProfile::DEGRADED,
|
QualityProfile::DEGRADED,
|
||||||
QualityProfile::CATASTROPHIC,
|
QualityProfile::CATASTROPHIC,
|
||||||
|
|||||||
@@ -8,24 +8,6 @@
|
|||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
pub mod audio_io;
|
pub mod audio_io;
|
||||||
#[cfg(feature = "audio")]
|
|
||||||
pub mod audio_ring;
|
|
||||||
// VoiceProcessingIO is an Apple Core Audio API — only compile the module
|
|
||||||
// when the `vpio` feature is on AND we're targeting macOS. Enabling the
|
|
||||||
// feature on Windows/Linux was previously silently broken.
|
|
||||||
#[cfg(all(feature = "vpio", target_os = "macos"))]
|
|
||||||
pub mod audio_vpio;
|
|
||||||
// WASAPI-direct capture with Windows's OS-level AEC (AudioCategory_Communications).
|
|
||||||
// Only compiled when `windows-aec` feature is on AND target is Windows. The
|
|
||||||
// `windows` dependency is itself gated to Windows in Cargo.toml, so enabling
|
|
||||||
// this feature on non-Windows targets is a no-op.
|
|
||||||
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
|
||||||
pub mod audio_wasapi;
|
|
||||||
// WebRTC AEC3 (Audio Processing Module) wrapper around CPAL capture + playback
|
|
||||||
// on Linux. Only compiled when `linux-aec` feature is on AND target is Linux.
|
|
||||||
// The webrtc-audio-processing dep is itself gated to Linux in Cargo.toml.
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub mod audio_linux_aec;
|
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
pub mod call;
|
pub mod call;
|
||||||
pub mod drift_test;
|
pub mod drift_test;
|
||||||
@@ -35,48 +17,7 @@ pub mod handshake;
|
|||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod sweep;
|
pub mod sweep;
|
||||||
|
|
||||||
// AudioPlayback: three possible backends depending on feature flags.
|
#[cfg(feature = "audio")]
|
||||||
// 1. Default CPAL (`audio_io::AudioPlayback`) — baseline on every platform.
|
pub use audio_io::{AudioCapture, AudioPlayback};
|
||||||
// 2. Linux AEC (`audio_linux_aec::LinuxAecPlayback`) — CPAL + WebRTC APM
|
|
||||||
// render-side tee, so echo from speakers gets cancelled from the mic.
|
|
||||||
//
|
|
||||||
// On macOS and Windows we always use the default CPAL playback because:
|
|
||||||
// - macOS: VoiceProcessingIO handles AEC at the capture side (Apple's
|
|
||||||
// native hardware AEC uses its own reference signal handling).
|
|
||||||
// - Windows: WASAPI AudioCategory_Communications AEC uses the system
|
|
||||||
// render mix as reference — no per-process plumbing needed.
|
|
||||||
//
|
|
||||||
// Linux is the only platform where the in-app approach is necessary, so
|
|
||||||
// the AEC playback path is gated to target_os = "linux".
|
|
||||||
|
|
||||||
#[cfg(all(
|
|
||||||
feature = "audio",
|
|
||||||
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
|
||||||
))]
|
|
||||||
pub use audio_io::AudioPlayback;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub use audio_linux_aec::LinuxAecPlayback as AudioPlayback;
|
|
||||||
|
|
||||||
// AudioCapture: three possible backends depending on feature flags.
|
|
||||||
// 1. Default CPAL (`audio_io::AudioCapture`) — baseline on every platform.
|
|
||||||
// 2. Windows AEC (`audio_wasapi::WasapiAudioCapture`) — direct WASAPI
|
|
||||||
// with AudioCategory_Communications, OS APO chain does AEC.
|
|
||||||
// 3. Linux AEC (`audio_linux_aec::LinuxAecCapture`) — CPAL + WebRTC APM
|
|
||||||
// capture-side echo cancellation using the playback tee as reference.
|
|
||||||
// All three expose the same public API (`start`, `ring`, `stop`, `Drop`).
|
|
||||||
|
|
||||||
#[cfg(all(
|
|
||||||
feature = "audio",
|
|
||||||
any(not(feature = "windows-aec"), not(target_os = "windows")),
|
|
||||||
any(not(feature = "linux-aec"), not(target_os = "linux"))
|
|
||||||
))]
|
|
||||||
pub use audio_io::AudioCapture;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "windows-aec", target_os = "windows"))]
|
|
||||||
pub use audio_wasapi::WasapiAudioCapture as AudioCapture;
|
|
||||||
|
|
||||||
#[cfg(all(feature = "linux-aec", target_os = "linux"))]
|
|
||||||
pub use audio_linux_aec::LinuxAecCapture as AudioCapture;
|
|
||||||
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
pub use call::{CallConfig, CallDecoder, CallEncoder};
|
||||||
pub use handshake::perform_handshake;
|
pub use handshake::perform_handshake;
|
||||||
|
|||||||
@@ -10,17 +10,8 @@ description = "WarzonePhone audio codec layer — Opus + Codec2 encoding/decodin
|
|||||||
wzp-proto = { workspace = true }
|
wzp-proto = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
# Opus bindings — libopus 1.5.2.
|
# Opus bindings
|
||||||
# opusic-c for the encoder (set_dred_duration lives here in Phase 1).
|
audiopus = { workspace = true }
|
||||||
# opusic-sys for the decoder — we wrap the raw *mut OpusDecoder ourselves
|
|
||||||
# because opusic-c::Decoder.inner is pub(crate), blocking the unified
|
|
||||||
# decoder + DRED path we need in Phase 3.
|
|
||||||
opusic-c = { workspace = true }
|
|
||||||
opusic-sys = { workspace = true }
|
|
||||||
|
|
||||||
# Zero-cost slice reinterpretation for the i16 ↔ u16 boundary between
|
|
||||||
# our PCM buffers and opusic-c's encode API.
|
|
||||||
bytemuck = { workspace = true }
|
|
||||||
|
|
||||||
# Pure-Rust Codec2 implementation
|
# Pure-Rust Codec2 implementation
|
||||||
codec2 = { workspace = true }
|
codec2 = { workspace = true }
|
||||||
|
|||||||
@@ -199,27 +199,6 @@ impl AdaptiveDecoder {
|
|||||||
fn codec2_frame_samples(&self) -> usize {
|
fn codec2_frame_samples(&self) -> usize {
|
||||||
self.codec2.frame_samples()
|
self.codec2.frame_samples()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstruct a lost frame from a previously parsed DRED state.
|
|
||||||
///
|
|
||||||
/// Phase 3b entry point for gap reconstruction. Dispatches to the
|
|
||||||
/// inner Opus decoder when active. Returns an error if the active
|
|
||||||
/// codec is Codec2 — DRED is libopus-only and has no Codec2 equivalent,
|
|
||||||
/// so callers must fall back to classical PLC on Codec2 tiers.
|
|
||||||
pub fn reconstruct_from_dred(
|
|
||||||
&mut self,
|
|
||||||
state: &crate::dred_ffi::DredState,
|
|
||||||
offset_samples: i32,
|
|
||||||
output: &mut [i16],
|
|
||||||
) -> Result<usize, CodecError> {
|
|
||||||
if is_codec2(self.active) {
|
|
||||||
return Err(CodecError::DecodeFailed(
|
|
||||||
"DRED reconstruction is Opus-only; Codec2 must use classical PLC".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.opus
|
|
||||||
.reconstruct_from_dred(state, offset_samples, output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 {
|
for k in 0..fl {
|
||||||
let fe_idx = (base + fl - k) % fl;
|
let fe_idx = (base + fl - k) % fl;
|
||||||
self.filter[k] += step * self.far_buf[fe_idx];
|
let fe = self.far_end_buf[fe_idx];
|
||||||
}
|
self.filter_coeffs[k] += step * fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,585 +0,0 @@
|
|||||||
//! Raw opusic-sys FFI wrappers for libopus 1.5.2 decoder + DRED reconstruction.
|
|
||||||
//!
|
|
||||||
//! # Why this module exists
|
|
||||||
//!
|
|
||||||
//! We cannot use `opusic_c::Decoder` because its inner `*mut OpusDecoder`
|
|
||||||
//! pointer is `pub(crate)` — not reachable from outside the opusic-c crate.
|
|
||||||
//! Phase 3 of the DRED integration needs to hand that same pointer to
|
|
||||||
//! `opus_decoder_dred_decode`, and running two parallel decoders (one from
|
|
||||||
//! opusic-c for normal audio, another from opusic-sys for DRED) would cause
|
|
||||||
//! the DRED-only decoder's internal state to drift out of sync with the
|
|
||||||
//! audio stream because it would not see normal decode calls.
|
|
||||||
//!
|
|
||||||
//! The fix is to own the raw decoder ourselves and use the same handle for
|
|
||||||
//! both normal decode AND DRED reconstruction. This module is the single
|
|
||||||
//! owner of `*mut OpusDecoder`, `*mut OpusDREDDecoder`, and `*mut OpusDRED`
|
|
||||||
//! in the WZP workspace.
|
|
||||||
//!
|
|
||||||
//! # Phase 3a scope
|
|
||||||
//!
|
|
||||||
//! Phase 0 added `DecoderHandle` (normal decode). Phase 3a adds:
|
|
||||||
//! - [`DredDecoderHandle`] — wraps `*mut OpusDREDDecoder` for parsing DRED
|
|
||||||
//! side-channel data out of arriving Opus packets.
|
|
||||||
//! - [`DredState`] — wraps `*mut OpusDRED` (a fixed 10,592-byte buffer
|
|
||||||
//! allocated by libopus) that holds parsed DRED state between the parse
|
|
||||||
//! and reconstruct steps.
|
|
||||||
//! - [`DredDecoderHandle::parse_into`] — wraps `opus_dred_parse`.
|
|
||||||
//! - [`DecoderHandle::reconstruct_from_dred`] — wraps `opus_decoder_dred_decode`.
|
|
||||||
//!
|
|
||||||
//! The pattern is: on every arriving Opus packet, the receiver calls
|
|
||||||
//! `parse_into` with a reusable `DredState`, then stores (seq, state_clone)
|
|
||||||
//! in a ring. On detected loss, the receiver computes the offset from the
|
|
||||||
//! freshest reachable DRED state and calls `reconstruct_from_dred` to
|
|
||||||
//! synthesize the missing audio.
|
|
||||||
|
|
||||||
use std::ptr::NonNull;
|
|
||||||
|
|
||||||
use opusic_sys::{
|
|
||||||
OPUS_OK, OpusDRED, OpusDREDDecoder, OpusDecoder as RawOpusDecoder, opus_decode,
|
|
||||||
opus_decoder_create, opus_decoder_destroy, opus_decoder_dred_decode, opus_dred_alloc,
|
|
||||||
opus_dred_decoder_create, opus_dred_decoder_destroy, opus_dred_free, opus_dred_parse,
|
|
||||||
};
|
|
||||||
use wzp_proto::CodecError;
|
|
||||||
|
|
||||||
/// libopus operates at 48 kHz for all Opus variants we use.
|
|
||||||
const SAMPLE_RATE_HZ: i32 = 48_000;
|
|
||||||
/// Mono.
|
|
||||||
const CHANNELS: i32 = 1;
|
|
||||||
|
|
||||||
/// Safe owner of a `*mut OpusDecoder` allocated via `opus_decoder_create`.
|
|
||||||
///
|
|
||||||
/// Releases the decoder in `Drop`. All FFI access goes through `&mut self`
|
|
||||||
/// methods, so there is no aliasing or race. The raw pointer is exposed via
|
|
||||||
/// [`Self::as_raw_ptr`] at a crate-internal visibility for the future Phase 3
|
|
||||||
/// DRED reconstruction path — external crates cannot reach it.
|
|
||||||
pub struct DecoderHandle {
|
|
||||||
inner: NonNull<RawOpusDecoder>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DecoderHandle {
|
|
||||||
/// Allocate a new Opus decoder at 48 kHz mono.
|
|
||||||
pub fn new() -> Result<Self, CodecError> {
|
|
||||||
let mut error: i32 = OPUS_OK;
|
|
||||||
// SAFETY: opus_decoder_create writes to `error` and returns either a
|
|
||||||
// valid heap pointer or null. We check both before constructing the
|
|
||||||
// NonNull wrapper.
|
|
||||||
let ptr = unsafe { opus_decoder_create(SAMPLE_RATE_HZ, CHANNELS, &mut error) };
|
|
||||||
if error != OPUS_OK {
|
|
||||||
// Even if ptr is non-null on error, libopus contracts guarantee
|
|
||||||
// it is unusable — do not attempt to free it.
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decoder_create failed: err={error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let inner = NonNull::new(ptr).ok_or_else(|| {
|
|
||||||
CodecError::DecodeFailed("opus_decoder_create returned null".into())
|
|
||||||
})?;
|
|
||||||
Ok(Self { inner })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode an Opus packet into PCM samples.
|
|
||||||
///
|
|
||||||
/// `pcm` must have enough capacity for the frame (960 for 20 ms, 1920
|
|
||||||
/// for 40 ms at 48 kHz mono). Returns the number of decoded samples
|
|
||||||
/// per channel — for mono streams this equals the total sample count.
|
|
||||||
pub fn decode(&mut self, packet: &[u8], pcm: &mut [i16]) -> Result<usize, CodecError> {
|
|
||||||
if packet.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed("empty packet".into()));
|
|
||||||
}
|
|
||||||
if pcm.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
|
||||||
}
|
|
||||||
// SAFETY: self.inner is a valid *mut OpusDecoder owned by this struct.
|
|
||||||
// `data` / `pcm` are live Rust slices, so their pointers and lengths
|
|
||||||
// are valid for the duration of the call. libopus reads len bytes
|
|
||||||
// from data and writes up to frame_size samples (per channel) to pcm.
|
|
||||||
let n = unsafe {
|
|
||||||
opus_decode(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
packet.as_ptr(),
|
|
||||||
packet.len() as i32,
|
|
||||||
pcm.as_mut_ptr(),
|
|
||||||
pcm.len() as i32,
|
|
||||||
/* decode_fec = */ 0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if n < 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decode failed: err={n}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(n as usize)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate packet-loss concealment audio for a missing frame.
|
|
||||||
///
|
|
||||||
/// Implemented via `opus_decode` with a null data pointer, per the
|
|
||||||
/// libopus API contract. `pcm` should be sized for the expected frame.
|
|
||||||
pub fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
|
||||||
if pcm.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed("empty output buffer".into()));
|
|
||||||
}
|
|
||||||
// SAFETY: same invariants as decode(). libopus documents that passing
|
|
||||||
// a null data pointer with len=0 triggers PLC synthesis into pcm.
|
|
||||||
let n = unsafe {
|
|
||||||
opus_decode(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
std::ptr::null(),
|
|
||||||
0,
|
|
||||||
pcm.as_mut_ptr(),
|
|
||||||
pcm.len() as i32,
|
|
||||||
/* decode_fec = */ 0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if n < 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decode PLC failed: err={n}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(n as usize)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reconstruct audio from a `DredState` into the `output` buffer.
|
|
||||||
///
|
|
||||||
/// `offset_samples` is the sample position (positive, measured backward
|
|
||||||
/// from the packet anchor that produced `state`) where reconstruction
|
|
||||||
/// begins. `output.len()` must match the number of samples to synthesize.
|
|
||||||
///
|
|
||||||
/// The libopus API: `opus_decoder_dred_decode(st, dred, dred_offset, pcm,
|
|
||||||
/// frame_size)` where `dred_offset` is "position of the redundancy to
|
|
||||||
/// decode, in samples before the beginning of the real audio data in the
|
|
||||||
/// packet." Valid values: `0 < offset_samples < state.samples_available()`.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples actually written (should equal
|
|
||||||
/// `output.len()` on success).
|
|
||||||
pub fn reconstruct_from_dred(
|
|
||||||
&mut self,
|
|
||||||
state: &DredState,
|
|
||||||
offset_samples: i32,
|
|
||||||
output: &mut [i16],
|
|
||||||
) -> Result<usize, CodecError> {
|
|
||||||
if output.is_empty() {
|
|
||||||
return Err(CodecError::DecodeFailed(
|
|
||||||
"empty reconstruction output buffer".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if offset_samples <= 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"DRED offset must be positive (got {offset_samples})"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if offset_samples > state.samples_available() {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"DRED offset {offset_samples} exceeds available samples {}",
|
|
||||||
state.samples_available()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
// SAFETY: self.inner is a valid *mut OpusDecoder, state.inner is a
|
|
||||||
// valid *const OpusDRED populated by a prior parse_into call, and
|
|
||||||
// output is a live mutable slice. libopus reads from dred and writes
|
|
||||||
// exactly frame_size samples (the output.len()) to pcm.
|
|
||||||
let n = unsafe {
|
|
||||||
opus_decoder_dred_decode(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
state.inner.as_ptr(),
|
|
||||||
offset_samples,
|
|
||||||
output.as_mut_ptr(),
|
|
||||||
output.len() as i32,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if n < 0 {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_decoder_dred_decode failed: err={n}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(n as usize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DecoderHandle {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: we own the pointer and no further access happens after
|
|
||||||
// this call because Drop consumes self.
|
|
||||||
unsafe { opus_decoder_destroy(self.inner.as_ptr()) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: The underlying OpusDecoder is a plain heap allocation with no
|
|
||||||
// thread-local or lock-free state. It is safe to move between threads
|
|
||||||
// (Send), and all method access is gated by &mut self so Rust's borrow
|
|
||||||
// checker prevents simultaneous access from multiple threads (Sync).
|
|
||||||
unsafe impl Send for DecoderHandle {}
|
|
||||||
unsafe impl Sync for DecoderHandle {}
|
|
||||||
|
|
||||||
// ─── DRED decoder (parser) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Safe owner of a `*mut OpusDREDDecoder` allocated via
|
|
||||||
/// `opus_dred_decoder_create`.
|
|
||||||
///
|
|
||||||
/// The DRED decoder is a **separate** libopus object from the regular
|
|
||||||
/// `OpusDecoder`. It's used exclusively for parsing DRED side-channel data
|
|
||||||
/// out of arriving Opus packets via [`Self::parse_into`]. Actual audio
|
|
||||||
/// reconstruction from the parsed state uses the regular `DecoderHandle`
|
|
||||||
/// via [`DecoderHandle::reconstruct_from_dred`].
|
|
||||||
pub struct DredDecoderHandle {
|
|
||||||
inner: NonNull<OpusDREDDecoder>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DredDecoderHandle {
|
|
||||||
/// Allocate a new DRED decoder.
|
|
||||||
pub fn new() -> Result<Self, CodecError> {
|
|
||||||
let mut error: i32 = OPUS_OK;
|
|
||||||
// SAFETY: opus_dred_decoder_create writes to `error` and returns
|
|
||||||
// either a valid heap pointer or null. Both are checked.
|
|
||||||
let ptr = unsafe { opus_dred_decoder_create(&mut error) };
|
|
||||||
if error != OPUS_OK {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_dred_decoder_create failed: err={error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let inner = NonNull::new(ptr).ok_or_else(|| {
|
|
||||||
CodecError::DecodeFailed("opus_dred_decoder_create returned null".into())
|
|
||||||
})?;
|
|
||||||
Ok(Self { inner })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse DRED side-channel data from an Opus packet into `state`.
|
|
||||||
///
|
|
||||||
/// Returns the number of samples of audio history available for
|
|
||||||
/// reconstruction, or 0 if the packet carries no DRED data. Subsequent
|
|
||||||
/// `DecoderHandle::reconstruct_from_dred` calls using this `state` can
|
|
||||||
/// reconstruct any sample position in `(0, samples_available]`.
|
|
||||||
///
|
|
||||||
/// libopus API: `opus_dred_parse(dred_dec, dred, data, len,
|
|
||||||
/// max_dred_samples, sampling_rate, dred_end, defer_processing)`. We
|
|
||||||
/// pass `max_dred_samples = 48000` (1 s at 48 kHz, the DRED maximum),
|
|
||||||
/// `sampling_rate = 48000`, `defer_processing = 0` (process immediately).
|
|
||||||
/// The `dred_end` output is the silence gap at the tail of the DRED
|
|
||||||
/// window; we subtract it from the total offset to give callers the
|
|
||||||
/// truly usable sample count.
|
|
||||||
pub fn parse_into(
|
|
||||||
&mut self,
|
|
||||||
state: &mut DredState,
|
|
||||||
packet: &[u8],
|
|
||||||
) -> Result<i32, CodecError> {
|
|
||||||
if packet.is_empty() {
|
|
||||||
state.samples_available = 0;
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
let mut dred_end: i32 = 0;
|
|
||||||
// SAFETY: self.inner is a valid *mut OpusDREDDecoder; state.inner is
|
|
||||||
// a valid *mut OpusDRED allocated via opus_dred_alloc; packet is a
|
|
||||||
// live slice; dred_end is a stack int. libopus reads packet bytes
|
|
||||||
// and writes parsed DRED state into *state.inner.
|
|
||||||
let ret = unsafe {
|
|
||||||
opus_dred_parse(
|
|
||||||
self.inner.as_ptr(),
|
|
||||||
state.inner.as_ptr(),
|
|
||||||
packet.as_ptr(),
|
|
||||||
packet.len() as i32,
|
|
||||||
/* max_dred_samples = */ 48_000, // 1s max per libopus 1.5
|
|
||||||
/* sampling_rate = */ 48_000,
|
|
||||||
&mut dred_end,
|
|
||||||
/* defer_processing = */ 0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if ret < 0 {
|
|
||||||
state.samples_available = 0;
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_dred_parse failed: err={ret}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
// ret is the positive offset of the first decodable DRED sample,
|
|
||||||
// or 0 if no DRED is present. dred_end is the silence gap at the
|
|
||||||
// tail. The usable sample range is (dred_end, ret], so the count
|
|
||||||
// of usable samples is ret - dred_end. We store `ret` as the max
|
|
||||||
// usable offset — callers should pass dred_offset values in the
|
|
||||||
// range (dred_end, ret] to reconstruct_from_dred. For simplicity
|
|
||||||
// we expose just samples_available = ret and let callers treat
|
|
||||||
// the full window as valid (the silence gap is small and libopus
|
|
||||||
// handles minor boundary cases gracefully).
|
|
||||||
state.samples_available = ret;
|
|
||||||
Ok(ret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DredDecoderHandle {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: we own the pointer and no further access happens after
|
|
||||||
// this call because Drop consumes self.
|
|
||||||
unsafe { opus_dred_decoder_destroy(self.inner.as_ptr()) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: same reasoning as DecoderHandle — heap allocation with no
|
|
||||||
// thread-local state, &mut self access discipline prevents races.
|
|
||||||
unsafe impl Send for DredDecoderHandle {}
|
|
||||||
unsafe impl Sync for DredDecoderHandle {}
|
|
||||||
|
|
||||||
// ─── DRED state buffer ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Safe owner of a `*mut OpusDRED` allocated via `opus_dred_alloc`.
|
|
||||||
///
|
|
||||||
/// Holds a fixed-size (10,592-byte per libopus 1.5) buffer that
|
|
||||||
/// `DredDecoderHandle::parse_into` populates from an Opus packet. The state
|
|
||||||
/// is reusable — the caller can call `parse_into` again on the same
|
|
||||||
/// `DredState` to overwrite it with a fresh packet's data.
|
|
||||||
///
|
|
||||||
/// `samples_available` tracks the last-parsed result so reconstruction
|
|
||||||
/// callers don't need to thread the return value separately. A fresh
|
|
||||||
/// state (before any `parse_into`) has `samples_available == 0`.
|
|
||||||
pub struct DredState {
|
|
||||||
inner: NonNull<OpusDRED>,
|
|
||||||
samples_available: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DredState {
|
|
||||||
/// Allocate a new DRED state buffer.
|
|
||||||
pub fn new() -> Result<Self, CodecError> {
|
|
||||||
let mut error: i32 = OPUS_OK;
|
|
||||||
// SAFETY: opus_dred_alloc writes to `error` and returns either a
|
|
||||||
// valid heap pointer or null.
|
|
||||||
let ptr = unsafe { opus_dred_alloc(&mut error) };
|
|
||||||
if error != OPUS_OK {
|
|
||||||
return Err(CodecError::DecodeFailed(format!(
|
|
||||||
"opus_dred_alloc failed: err={error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let inner = NonNull::new(ptr)
|
|
||||||
.ok_or_else(|| CodecError::DecodeFailed("opus_dred_alloc returned null".into()))?;
|
|
||||||
Ok(Self {
|
|
||||||
inner,
|
|
||||||
samples_available: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many samples of audio history this state currently covers.
|
|
||||||
///
|
|
||||||
/// Returns 0 if the state is fresh or the last parse found no DRED
|
|
||||||
/// data. Otherwise returns the positive offset set by the most recent
|
|
||||||
/// `DredDecoderHandle::parse_into` call — the maximum valid
|
|
||||||
/// `offset_samples` value for `DecoderHandle::reconstruct_from_dred`.
|
|
||||||
pub fn samples_available(&self) -> i32 {
|
|
||||||
self.samples_available
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the state to "fresh" without freeing the underlying buffer.
|
|
||||||
/// The next `parse_into` will overwrite the contents.
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.samples_available = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DredState {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: we own the pointer and no further access happens after
|
|
||||||
// this call because Drop consumes self.
|
|
||||||
unsafe { opus_dred_free(self.inner.as_ptr()) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: same reasoning as DecoderHandle.
|
|
||||||
unsafe impl Send for DredState {}
|
|
||||||
unsafe impl Sync for DredState {}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decoder_handle_creates_and_drops() {
|
|
||||||
let handle = DecoderHandle::new().expect("decoder create");
|
|
||||||
// Dropping the handle must not panic or leak — validated by miri
|
|
||||||
// and the absence of sanitizer complaints in CI.
|
|
||||||
drop(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_lost_produces_full_frame_of_silence_on_cold_start() {
|
|
||||||
let mut handle = DecoderHandle::new().unwrap();
|
|
||||||
// 20 ms @ 48 kHz mono.
|
|
||||||
let mut pcm = vec![0i16; 960];
|
|
||||||
let n = handle.decode_lost(&mut pcm).unwrap();
|
|
||||||
assert_eq!(n, 960);
|
|
||||||
// On a fresh decoder, PLC output is silence (no past audio to extend).
|
|
||||||
assert!(pcm.iter().all(|&s| s == 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_empty_packet_errors() {
|
|
||||||
let mut handle = DecoderHandle::new().unwrap();
|
|
||||||
let mut pcm = vec![0i16; 960];
|
|
||||||
let err = handle.decode(&[], &mut pcm);
|
|
||||||
assert!(err.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 3a — DRED decoder + state ────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_decoder_handle_creates_and_drops() {
|
|
||||||
let h = DredDecoderHandle::new().expect("dred decoder create");
|
|
||||||
drop(h);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_state_creates_and_drops() {
|
|
||||||
let s = DredState::new().expect("dred state alloc");
|
|
||||||
assert_eq!(s.samples_available(), 0);
|
|
||||||
drop(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_state_reset_zeroes_counter() {
|
|
||||||
let mut s = DredState::new().unwrap();
|
|
||||||
s.samples_available = 480; // pretend a parse populated it
|
|
||||||
assert_eq!(s.samples_available(), 480);
|
|
||||||
s.reset();
|
|
||||||
assert_eq!(s.samples_available(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phase 3a end-to-end: encode a DRED-enabled stream, parse state out
|
|
||||||
/// of packets, and reconstruct audio at a past offset. Validates the
|
|
||||||
/// full parse → reconstruct pipeline against a real libopus 1.5.2
|
|
||||||
/// encoder so we catch FFI-layer bugs early.
|
|
||||||
#[test]
|
|
||||||
fn dred_parse_and_reconstruct_roundtrip() {
|
|
||||||
use crate::opus_enc::OpusEncoder;
|
|
||||||
use wzp_proto::{AudioEncoder, QualityProfile};
|
|
||||||
|
|
||||||
// Encoder with DRED at Opus 24k / 200 ms duration (Phase 1 default
|
|
||||||
// for GOOD profile). The loss floor is 5% per Phase 1.
|
|
||||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
|
|
||||||
// Decode-side handles.
|
|
||||||
let mut dec = DecoderHandle::new().unwrap();
|
|
||||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
|
||||||
let mut state = DredState::new().unwrap();
|
|
||||||
|
|
||||||
// Generate 60 frames (1.2 s) of a voice-like 300 Hz sine wave so
|
|
||||||
// the encoder's DRED emitter has real content to encode rather
|
|
||||||
// than compressing silence.
|
|
||||||
let frame_len = 960usize; // 20 ms @ 48 kHz
|
|
||||||
let make_frame = |offset: usize| -> Vec<i16> {
|
|
||||||
(0..frame_len)
|
|
||||||
.map(|i| {
|
|
||||||
let t = (offset + i) as f64 / 48_000.0;
|
|
||||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track the freshest packet that carried non-zero DRED state.
|
|
||||||
let mut best_samples_available = 0;
|
|
||||||
let mut best_packet: Option<Vec<u8>> = None;
|
|
||||||
|
|
||||||
for frame_idx in 0..60 {
|
|
||||||
let pcm = make_frame(frame_idx * frame_len);
|
|
||||||
let mut encoded = vec![0u8; 512];
|
|
||||||
let n = enc.encode(&pcm, &mut encoded).unwrap();
|
|
||||||
encoded.truncate(n);
|
|
||||||
|
|
||||||
// Run the packet through the normal decode path so dec's
|
|
||||||
// internal state mirrors the full stream — this is necessary
|
|
||||||
// for DRED reconstruction to produce meaningful output.
|
|
||||||
let mut decoded = vec![0i16; frame_len];
|
|
||||||
dec.decode(&encoded, &mut decoded).unwrap();
|
|
||||||
|
|
||||||
// Parse DRED state out of the same packet. Early packets may
|
|
||||||
// have samples_available == 0 while the DRED encoder warms up;
|
|
||||||
// later packets should carry the full window.
|
|
||||||
match dred_dec.parse_into(&mut state, &encoded) {
|
|
||||||
Ok(available) => {
|
|
||||||
if available > best_samples_available {
|
|
||||||
best_samples_available = available;
|
|
||||||
best_packet = Some(encoded.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => panic!("parse_into errored unexpectedly: {e:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// By the time we're 60 frames in, DRED should have emitted data.
|
|
||||||
assert!(
|
|
||||||
best_samples_available > 0,
|
|
||||||
"DRED emitted zero samples across 60 frames — the encoder isn't \
|
|
||||||
producing DRED bytes (check set_dred_duration and packet_loss floor)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse the best packet into a fresh state and reconstruct some
|
|
||||||
// audio from somewhere inside its DRED window. We use frame_len/2
|
|
||||||
// as the offset to pick a point squarely inside the reconstructable
|
|
||||||
// range rather than at an edge.
|
|
||||||
let packet = best_packet.expect("at least one packet had DRED state");
|
|
||||||
let mut fresh_state = DredState::new().unwrap();
|
|
||||||
let available = dred_dec.parse_into(&mut fresh_state, &packet).unwrap();
|
|
||||||
assert!(available > 0, "re-parse of known-good packet returned 0");
|
|
||||||
|
|
||||||
// Need a decoder that's in the right state to reconstruct — rewind
|
|
||||||
// by creating a fresh one and feeding it the same stream up to the
|
|
||||||
// point of the best packet. Simpler: just use a fresh decoder and
|
|
||||||
// accept that the reconstructed samples may not be phase-matched.
|
|
||||||
// The test here only asserts *non-silent energy*, not signal fidelity.
|
|
||||||
let mut recon_dec = DecoderHandle::new().unwrap();
|
|
||||||
// Warm up the decoder with one frame so its internal state is valid.
|
|
||||||
let warmup_pcm = vec![0i16; frame_len];
|
|
||||||
let warmup_encoded = {
|
|
||||||
let mut warmup_enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
let mut buf = vec![0u8; 512];
|
|
||||||
let n = warmup_enc.encode(&warmup_pcm, &mut buf).unwrap();
|
|
||||||
buf.truncate(n);
|
|
||||||
buf
|
|
||||||
};
|
|
||||||
let mut throwaway = vec![0i16; frame_len];
|
|
||||||
let _ = recon_dec.decode(&warmup_encoded, &mut throwaway);
|
|
||||||
|
|
||||||
// Reconstruct 20 ms from some position inside the DRED window.
|
|
||||||
let offset = (available / 2).max(480).min(available);
|
|
||||||
let mut recon_pcm = vec![0i16; frame_len];
|
|
||||||
let n = recon_dec
|
|
||||||
.reconstruct_from_dred(&fresh_state, offset, &mut recon_pcm)
|
|
||||||
.expect("reconstruct_from_dred failed");
|
|
||||||
assert_eq!(n, frame_len);
|
|
||||||
|
|
||||||
// Energy check: reconstructed audio should not be all zeros. A
|
|
||||||
// loose threshold — the DRED reconstruction won't be phase-matched
|
|
||||||
// to our sine wave because we fed a cold decoder only one warmup
|
|
||||||
// frame, but it should still produce non-silent speech-like output
|
|
||||||
// since the DRED state was parsed from real speech content.
|
|
||||||
let energy: u64 = recon_pcm.iter().map(|&s| (s as i32).unsigned_abs() as u64).sum();
|
|
||||||
assert!(
|
|
||||||
energy > 0,
|
|
||||||
"reconstructed audio has zero total energy — DRED reconstruction produced silence"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A second roundtrip variant: offset too large errors cleanly rather
|
|
||||||
/// than crashing the FFI.
|
|
||||||
#[test]
|
|
||||||
fn reconstruct_with_out_of_range_offset_errors() {
|
|
||||||
let mut dec = DecoderHandle::new().unwrap();
|
|
||||||
let state = DredState::new().unwrap();
|
|
||||||
// state has samples_available == 0 (fresh), so any positive offset
|
|
||||||
// should be out of range.
|
|
||||||
let mut out = vec![0i16; 960];
|
|
||||||
let err = dec.reconstruct_from_dred(&state, 480, &mut out);
|
|
||||||
assert!(err.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reconstruct_with_zero_offset_errors() {
|
|
||||||
let mut dec = DecoderHandle::new().unwrap();
|
|
||||||
let state = DredState::new().unwrap();
|
|
||||||
let mut out = vec![0i16; 960];
|
|
||||||
let err = dec.reconstruct_from_dred(&state, 0, &mut out);
|
|
||||||
assert!(err.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_parse_empty_packet_returns_zero() {
|
|
||||||
let mut dred_dec = DredDecoderHandle::new().unwrap();
|
|
||||||
let mut state = DredState::new().unwrap();
|
|
||||||
let result = dred_dec.parse_into(&mut state, &[]).unwrap();
|
|
||||||
assert_eq!(result, 0);
|
|
||||||
assert_eq!(state.samples_available(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ pub mod agc;
|
|||||||
pub mod codec2_dec;
|
pub mod codec2_dec;
|
||||||
pub mod codec2_enc;
|
pub mod codec2_enc;
|
||||||
pub mod denoise;
|
pub mod denoise;
|
||||||
pub mod dred_ffi;
|
|
||||||
pub mod opus_dec;
|
pub mod opus_dec;
|
||||||
pub mod opus_enc;
|
pub mod opus_enc;
|
||||||
pub mod resample;
|
pub mod resample;
|
||||||
@@ -28,26 +27,6 @@ pub use denoise::NoiseSupressor;
|
|||||||
pub use silence::{ComfortNoise, SilenceDetector};
|
pub use silence::{ComfortNoise, SilenceDetector};
|
||||||
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
pub use wzp_proto::{AudioDecoder, AudioEncoder, CodecId, QualityProfile};
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
|
|
||||||
/// Global verbose-logging flag for DRED. Off by default — when enabled
|
|
||||||
/// (via the GUI debug toggle wired through Tauri), the encoder logs its
|
|
||||||
/// DRED config + libopus version, and the recv path logs every DRED
|
|
||||||
/// reconstruction, classical PLC fill, and parse heartbeat. Off in
|
|
||||||
/// "normal" mode keeps logcat clean.
|
|
||||||
static DRED_VERBOSE_LOGS: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
/// Returns whether DRED verbose logging is currently enabled.
|
|
||||||
#[inline]
|
|
||||||
pub fn dred_verbose_logs() -> bool {
|
|
||||||
DRED_VERBOSE_LOGS.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable/disable DRED verbose logging at runtime.
|
|
||||||
pub fn set_dred_verbose_logs(enabled: bool) {
|
|
||||||
DRED_VERBOSE_LOGS.store(enabled, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an adaptive encoder starting at the given quality profile.
|
/// Create an adaptive encoder starting at the given quality profile.
|
||||||
///
|
///
|
||||||
/// The returned encoder accepts 48 kHz mono PCM regardless of the active
|
/// The returned encoder accepts 48 kHz mono PCM regardless of the active
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
//! Opus decoder built on top of the raw opusic-sys `DecoderHandle`.
|
//! Opus decoder wrapping the `audiopus` crate.
|
||||||
//!
|
|
||||||
//! Phase 0 of the DRED integration: we went straight to a custom
|
|
||||||
//! `DecoderHandle` instead of `opusic_c::Decoder` because the latter's
|
|
||||||
//! inner pointer is `pub(crate)` and we need to reach it in Phase 3 for
|
|
||||||
//! `opus_decoder_dred_decode`. See `dred_ffi.rs` for the rationale and
|
|
||||||
//! `docs/PRD-dred-integration.md` for the full plan.
|
|
||||||
|
|
||||||
use crate::dred_ffi::{DecoderHandle, DredState};
|
use audiopus::coder::Decoder;
|
||||||
|
use audiopus::{Channels, MutSignals, SampleRate};
|
||||||
|
use audiopus::packet::Packet;
|
||||||
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
use wzp_proto::{AudioDecoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
/// Opus decoder implementing [`AudioDecoder`].
|
/// Opus decoder implementing `AudioDecoder`.
|
||||||
///
|
///
|
||||||
/// Operates at 48 kHz mono output. 20 ms and 40 ms frames supported via
|
/// Operates at 48 kHz mono output.
|
||||||
/// the active `QualityProfile`. Behavior is intentionally identical to
|
|
||||||
/// the pre-swap audiopus-based decoder at this phase — DRED reconstruction
|
|
||||||
/// lands in Phase 3.
|
|
||||||
pub struct OpusDecoder {
|
pub struct OpusDecoder {
|
||||||
inner: DecoderHandle,
|
inner: Decoder,
|
||||||
codec_id: CodecId,
|
codec_id: CodecId,
|
||||||
frame_duration_ms: u8,
|
frame_duration_ms: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SAFETY: Same reasoning as OpusEncoder — exclusive access via &mut self.
|
||||||
|
unsafe impl Sync for OpusDecoder {}
|
||||||
|
|
||||||
impl OpusDecoder {
|
impl OpusDecoder {
|
||||||
/// Create a new Opus decoder for the given quality profile.
|
/// Create a new Opus decoder for the given quality profile.
|
||||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
let inner = DecoderHandle::new()?;
|
let decoder = Decoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus decoder init: {e}")))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner,
|
inner: decoder,
|
||||||
codec_id: profile.codec,
|
codec_id: profile.codec,
|
||||||
frame_duration_ms: profile.frame_duration_ms,
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
})
|
})
|
||||||
@@ -36,24 +34,6 @@ impl OpusDecoder {
|
|||||||
pub fn frame_samples(&self) -> usize {
|
pub fn frame_samples(&self) -> usize {
|
||||||
(48_000 * self.frame_duration_ms as usize) / 1000
|
(48_000 * self.frame_duration_ms as usize) / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstruct a lost frame from a previously parsed `DredState`.
|
|
||||||
///
|
|
||||||
/// Phase 3b entry point: callers (CallDecoder / engine.rs) use this to
|
|
||||||
/// synthesize audio for gaps detected by the jitter buffer when DRED
|
|
||||||
/// side-channel state from a later-arriving packet covers the gap's
|
|
||||||
/// sample offset. `offset_samples` is measured backward from the anchor
|
|
||||||
/// packet that produced `state`. See `DecoderHandle::reconstruct_from_dred`
|
|
||||||
/// for the full semantics.
|
|
||||||
pub fn reconstruct_from_dred(
|
|
||||||
&mut self,
|
|
||||||
state: &DredState,
|
|
||||||
offset_samples: i32,
|
|
||||||
output: &mut [i16],
|
|
||||||
) -> Result<usize, CodecError> {
|
|
||||||
self.inner
|
|
||||||
.reconstruct_from_dred(state, offset_samples, output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioDecoder for OpusDecoder {
|
impl AudioDecoder for OpusDecoder {
|
||||||
@@ -65,7 +45,15 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
pcm.len()
|
pcm.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.inner.decode(encoded, pcm)
|
let packet = Packet::try_from(encoded)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("invalid packet: {e}")))?;
|
||||||
|
let signals = MutSignals::try_from(pcm)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||||
|
let n = self
|
||||||
|
.inner
|
||||||
|
.decode(Some(packet), signals, false)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus decode: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
fn decode_lost(&mut self, pcm: &mut [i16]) -> Result<usize, CodecError> {
|
||||||
@@ -76,7 +64,13 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
pcm.len()
|
pcm.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.inner.decode_lost(pcm)
|
let signals = MutSignals::try_from(pcm)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("output signals: {e}")))?;
|
||||||
|
let n = self
|
||||||
|
.inner
|
||||||
|
.decode(None, signals, false)
|
||||||
|
.map_err(|e| CodecError::DecodeFailed(format!("opus PLC: {e}")))?;
|
||||||
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_id(&self) -> CodecId {
|
fn codec_id(&self) -> CodecId {
|
||||||
@@ -85,7 +79,7 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
c if c.is_opus() => {
|
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,220 +1,58 @@
|
|||||||
//! Opus encoder wrapping the `opusic-c` crate (libopus 1.5.2).
|
//! Opus encoder wrapping the `audiopus` crate.
|
||||||
//!
|
|
||||||
//! Phase 1 of the DRED integration: encoder-side DRED is enabled on every
|
|
||||||
//! Opus profile with a tiered duration (studio 100 ms / normal 200 ms /
|
|
||||||
//! degraded 500 ms), and Opus inband FEC (LBRR) is disabled because DRED
|
|
||||||
//! is the stronger mechanism for the same failure mode. The legacy behavior
|
|
||||||
//! is preserved behind the `AUDIO_USE_LEGACY_FEC` environment variable as a
|
|
||||||
//! runtime escape hatch for rollout. See `docs/PRD-dred-integration.md`.
|
|
||||||
//!
|
|
||||||
//! # DRED duration policy
|
|
||||||
//!
|
|
||||||
//! Rationale from the PRD:
|
|
||||||
//! - Studio tiers (Opus 32k/48k/64k): 100 ms — loss is rare on high-quality
|
|
||||||
//! networks; short window keeps decoder CPU modest.
|
|
||||||
//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common
|
|
||||||
//! VoIP loss patterns (20–150 ms bursts from wifi roam, transient congestion).
|
|
||||||
//! - Degraded tier (Opus 6k): 500 ms — users on 6k are by definition on a
|
|
||||||
//! bad link; longer DRED buys maximum burst resilience where it matters.
|
|
||||||
//!
|
|
||||||
//! # Why the 15% packet loss floor
|
|
||||||
//!
|
|
||||||
//! libopus 1.5's DRED emitter is gated on `OPUS_SET_PACKET_LOSS_PERC` and
|
|
||||||
//! scales the emitted window proportionally to the assumed loss:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! loss_pct samples_available effective_ms
|
|
||||||
//! 5% 720 15
|
|
||||||
//! 10% 2640 55
|
|
||||||
//! 15% 4560 95
|
|
||||||
//! 20% 6480 135
|
|
||||||
//! 25%+ 8400 (capped) 175 (≈ 87% of the 200ms configured max)
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Measured empirically against libopus 1.5.2 on Opus 24k / 200 ms DRED
|
|
||||||
//! duration during Phase 3b. At 5% loss the window is only 15 ms — too
|
|
||||||
//! small to even reconstruct a single 20 ms Opus frame. 15% gives 95 ms
|
|
||||||
//! (enough for single-frame recovery plus modest burst margin) while
|
|
||||||
//! keeping the bitrate overhead modest compared to 25%. Real measurements
|
|
||||||
//! from the quality adapter override upward when loss exceeds the floor.
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
use audiopus::coder::Encoder;
|
||||||
|
use audiopus::{Application, Bitrate, Channels, SampleRate, Signal};
|
||||||
use opusic_c::{Application, Bitrate, Channels, Encoder, InbandFec, SampleRate, Signal};
|
use tracing::debug;
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
use wzp_proto::{AudioEncoder, CodecError, CodecId, QualityProfile};
|
||||||
|
|
||||||
/// Logged exactly once per process the first time an OpusEncoder is built.
|
|
||||||
/// Confirms that libopus 1.5.2 (the version with DRED) is actually linked
|
|
||||||
/// at runtime — invaluable when chasing "is the new codec loaded?"
|
|
||||||
/// regressions on Android, where the only debug surface is logcat.
|
|
||||||
static LIBOPUS_VERSION_LOGGED: OnceLock<()> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Minimum `OPUS_SET_PACKET_LOSS_PERC` value used in DRED mode. libopus
|
|
||||||
/// scales the DRED emission window with the assumed loss percentage:
|
|
||||||
/// empirically, 5% gives a 15 ms window (useless), 10% gives 55 ms, 15%
|
|
||||||
/// gives 95 ms, and 25%+ saturates the configured max (~175 ms at 200 ms
|
|
||||||
/// duration). 15% is the minimum value that produces a DRED window larger
|
|
||||||
/// than a single 20 ms frame, making it the minimum floor that actually
|
|
||||||
/// gives DRED something useful to reconstruct. Real loss measurements from
|
|
||||||
/// the quality adapter override this upward.
|
|
||||||
const DRED_LOSS_FLOOR_PCT: u8 = 15;
|
|
||||||
|
|
||||||
/// Environment variable that reverts Phase 1 behavior to Phase 0 (inband FEC
|
|
||||||
/// on, DRED off, no loss floor). Read once per encoder construction.
|
|
||||||
const LEGACY_FEC_ENV: &str = "AUDIO_USE_LEGACY_FEC";
|
|
||||||
|
|
||||||
/// Returns the DRED duration in 10 ms frame units for a given Opus codec.
|
|
||||||
///
|
|
||||||
/// Unit: each frame is 10 ms, so the max value of 104 corresponds to 1040 ms
|
|
||||||
/// of reconstructable history. Returns 0 for non-Opus codecs (DRED is not
|
|
||||||
/// emitted by the libopus encoder in that case anyway, but we avoid a
|
|
||||||
/// pointless FFI call).
|
|
||||||
///
|
|
||||||
/// See the DRED duration policy in the module docs for per-tier rationale.
|
|
||||||
pub fn dred_duration_for(codec: CodecId) -> u8 {
|
|
||||||
match codec {
|
|
||||||
// Studio tiers — loss is rare, short window.
|
|
||||||
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10,
|
|
||||||
// Normal tiers — balanced baseline.
|
|
||||||
CodecId::Opus16k | CodecId::Opus24k => 20,
|
|
||||||
// Degraded tier — maximum burst resilience.
|
|
||||||
CodecId::Opus6k => 50,
|
|
||||||
// Non-Opus (Codec2 / CN): DRED is N/A.
|
|
||||||
CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether the legacy-FEC escape hatch is active.
|
|
||||||
///
|
|
||||||
/// Read from `AUDIO_USE_LEGACY_FEC`. Any non-empty value activates legacy
|
|
||||||
/// mode; unset or empty leaves DRED enabled.
|
|
||||||
fn read_legacy_fec_env() -> bool {
|
|
||||||
match std::env::var(LEGACY_FEC_ENV) {
|
|
||||||
Ok(v) => !v.is_empty() && v != "0" && v.to_ascii_lowercase() != "false",
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opus encoder implementing `AudioEncoder`.
|
/// Opus encoder implementing `AudioEncoder`.
|
||||||
///
|
///
|
||||||
/// Operates at 48 kHz mono. Supports 20 ms and 40 ms frames via the active
|
/// Operates at 48 kHz mono. Supports frame sizes of 20 ms (960 samples)
|
||||||
/// `QualityProfile`.
|
/// and 40 ms (1920 samples).
|
||||||
pub struct OpusEncoder {
|
pub struct OpusEncoder {
|
||||||
inner: Encoder,
|
inner: Encoder,
|
||||||
codec_id: CodecId,
|
codec_id: CodecId,
|
||||||
frame_duration_ms: u8,
|
frame_duration_ms: u8,
|
||||||
/// When `true`, revert to the Phase 0 behavior: inband FEC Mode1, DRED
|
|
||||||
/// disabled, no loss floor. Captured at construction time and not
|
|
||||||
/// re-read mid-call.
|
|
||||||
legacy_fec_mode: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: OpusEncoder is only used via `&mut self` methods. The inner
|
// SAFETY: OpusEncoder is only used via `&mut self` methods. The inner
|
||||||
// opusic-c Encoder wraps a non-null pointer that is !Sync by default,
|
// audiopus Encoder contains a raw pointer that is !Sync, but we never
|
||||||
// but we never share it across threads without exclusive access.
|
// share it across threads without exclusive access.
|
||||||
unsafe impl Sync for OpusEncoder {}
|
unsafe impl Sync for OpusEncoder {}
|
||||||
|
|
||||||
impl OpusEncoder {
|
impl OpusEncoder {
|
||||||
/// Create a new Opus encoder for the given quality profile.
|
/// Create a new Opus encoder for the given quality profile.
|
||||||
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
pub fn new(profile: QualityProfile) -> Result<Self, CodecError> {
|
||||||
// opusic-c argument order: (Channels, SampleRate, Application)
|
let encoder = Encoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||||
// — different from audiopus's (SampleRate, Channels, Application).
|
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e}")))?;
|
||||||
let encoder = Encoder::new(Channels::Mono, SampleRate::Hz48000, Application::Voip)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encoder init: {e:?}")))?;
|
|
||||||
|
|
||||||
let legacy_fec_mode = read_legacy_fec_env();
|
|
||||||
if legacy_fec_mode {
|
|
||||||
warn!(
|
|
||||||
"AUDIO_USE_LEGACY_FEC active — reverting Opus encoder to Phase 0 \
|
|
||||||
behavior (inband FEC Mode1, no DRED)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut enc = Self {
|
let mut enc = Self {
|
||||||
inner: encoder,
|
inner: encoder,
|
||||||
codec_id: profile.codec,
|
codec_id: profile.codec,
|
||||||
frame_duration_ms: profile.frame_duration_ms,
|
frame_duration_ms: profile.frame_duration_ms,
|
||||||
legacy_fec_mode,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common setup — bitrate, DTX, signal hint, complexity. These are
|
|
||||||
// identical regardless of the protection mode below.
|
|
||||||
enc.apply_bitrate(profile.codec)?;
|
enc.apply_bitrate(profile.codec)?;
|
||||||
|
enc.set_inband_fec(true);
|
||||||
enc.set_dtx(true);
|
enc.set_dtx(true);
|
||||||
|
|
||||||
|
// Voice signal type hint for better compression
|
||||||
enc.inner
|
enc.inner
|
||||||
.set_signal(Signal::Voice)
|
.set_signal(Signal::Voice)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e:?}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set signal: {e}")))?;
|
||||||
|
|
||||||
|
// Default complexity 7 — good quality/CPU trade-off for VoIP
|
||||||
enc.inner
|
enc.inner
|
||||||
.set_complexity(7)
|
.set_complexity(7)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e:?}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set complexity: {e}")))?;
|
||||||
|
|
||||||
// Protection mode: DRED (Phase 1 default) or legacy inband FEC.
|
|
||||||
enc.apply_protection_mode(profile.codec)?;
|
|
||||||
|
|
||||||
Ok(enc)
|
Ok(enc)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure the protection mode for the active codec.
|
|
||||||
///
|
|
||||||
/// In DRED mode (default): disable inband FEC, set DRED duration for the
|
|
||||||
/// codec tier, clamp packet_loss to the 5% floor so DRED stays active.
|
|
||||||
///
|
|
||||||
/// In legacy mode: enable inband FEC Mode1 (Phase 0 behavior), leave
|
|
||||||
/// DRED and packet_loss at libopus defaults.
|
|
||||||
fn apply_protection_mode(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
|
||||||
if self.legacy_fec_mode {
|
|
||||||
self.inner
|
|
||||||
.set_inband_fec(InbandFec::Mode1)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC: {e:?}")))?;
|
|
||||||
// Leave DRED at 0 and packet_loss at default — matches Phase 0.
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// DRED path: disable the overlapping inband FEC, enable DRED with
|
|
||||||
// per-profile duration, floor packet_loss so DRED emits.
|
|
||||||
self.inner
|
|
||||||
.set_inband_fec(InbandFec::Off)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set inband FEC off: {e:?}")))?;
|
|
||||||
|
|
||||||
let dred_frames = dred_duration_for(codec);
|
|
||||||
self.inner
|
|
||||||
.set_dred_duration(dred_frames)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set DRED duration: {e:?}")))?;
|
|
||||||
|
|
||||||
self.inner
|
|
||||||
.set_packet_loss(DRED_LOSS_FLOOR_PCT)
|
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set packet loss floor: {e:?}")))?;
|
|
||||||
|
|
||||||
// Both of these are gated behind the GUI debug toggle so logcat
|
|
||||||
// stays clean in normal mode. Flip "DRED verbose logs" in the
|
|
||||||
// settings panel to see the per-encoder config + libopus version.
|
|
||||||
if crate::dred_verbose_logs() {
|
|
||||||
info!(
|
|
||||||
codec = ?codec,
|
|
||||||
dred_frames,
|
|
||||||
dred_ms = dred_frames as u32 * 10,
|
|
||||||
loss_floor_pct = DRED_LOSS_FLOOR_PCT,
|
|
||||||
"opus encoder: DRED enabled"
|
|
||||||
);
|
|
||||||
|
|
||||||
// One-shot logging of the linked libopus version so we can
|
|
||||||
// confirm at a glance that opusic-c (libopus 1.5.2) is loaded.
|
|
||||||
// Pre-Phase-0 audiopus shipped libopus 1.3 which has no DRED;
|
|
||||||
// if this log says "libopus 1.3" something is very wrong.
|
|
||||||
LIBOPUS_VERSION_LOGGED.get_or_init(|| {
|
|
||||||
info!(libopus_version = %opusic_c::version(), "linked libopus version");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
fn apply_bitrate(&mut self, codec: CodecId) -> Result<(), CodecError> {
|
||||||
let bps = codec.bitrate_bps();
|
let bps = codec.bitrate_bps() as i32;
|
||||||
self.inner
|
self.inner
|
||||||
.set_bitrate(Bitrate::Value(bps))
|
.set_bitrate(Bitrate::BitsPerSecond(bps))
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e:?}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("set bitrate: {e}")))?;
|
||||||
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
debug!(bitrate_bps = bps, "opus encoder bitrate set");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -233,36 +71,10 @@ impl OpusEncoder {
|
|||||||
|
|
||||||
/// Hint the encoder about expected packet loss percentage (0-100).
|
/// Hint the encoder about expected packet loss percentage (0-100).
|
||||||
///
|
///
|
||||||
/// In DRED mode, the value is floored at `DRED_LOSS_FLOOR_PCT` so the
|
/// Higher values cause the encoder to use more redundancy to survive
|
||||||
/// encoder never drops DRED emission even on a perfect network. Real
|
/// packet loss, at the expense of slightly higher bitrate.
|
||||||
/// loss measurements from the quality adapter override upward.
|
|
||||||
///
|
|
||||||
/// In legacy mode, the value is passed through unchanged (min 0, max 100).
|
|
||||||
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
pub fn set_expected_loss(&mut self, loss_pct: u8) {
|
||||||
let clamped = if self.legacy_fec_mode {
|
let _ = self.inner.set_packet_loss_perc(loss_pct.min(100));
|
||||||
loss_pct.min(100)
|
|
||||||
} else {
|
|
||||||
loss_pct.max(DRED_LOSS_FLOOR_PCT).min(100)
|
|
||||||
};
|
|
||||||
let _ = self.inner.set_packet_loss(clamped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the DRED duration in 10 ms frame units (0 disables, max 104).
|
|
||||||
///
|
|
||||||
/// No-op in legacy mode. Normally driven automatically by the active
|
|
||||||
/// quality profile via `apply_protection_mode`; this setter exists for
|
|
||||||
/// tests and for the rare case where a caller needs to override the
|
|
||||||
/// per-profile default.
|
|
||||||
pub fn set_dred_duration(&mut self, frames: u8) {
|
|
||||||
if self.legacy_fec_mode {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let _ = self.inner.set_dred_duration(frames.min(104));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test/introspection accessor: whether legacy FEC mode is active.
|
|
||||||
pub fn is_legacy_fec_mode(&self) -> bool {
|
|
||||||
self.legacy_fec_mode
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,14 +87,10 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
pcm.len()
|
pcm.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
// opusic-c takes &[u16] for the sample input. Bit pattern is
|
|
||||||
// identical to i16 — the cast is zero-cost and the encoder
|
|
||||||
// interprets the bytes the same way as libopus internally.
|
|
||||||
let pcm_u16: &[u16] = bytemuck::cast_slice(pcm);
|
|
||||||
let n = self
|
let n = self
|
||||||
.inner
|
.inner
|
||||||
.encode_to_slice(pcm_u16, out)
|
.encode(pcm, out)
|
||||||
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e:?}")))?;
|
.map_err(|e| CodecError::EncodeFailed(format!("opus encode: {e}")))?;
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,13 +100,10 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
|
|
||||||
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError> {
|
||||||
match profile.codec {
|
match profile.codec {
|
||||||
c if c.is_opus() => {
|
CodecId::Opus24k | CodecId::Opus16k | CodecId::Opus6k => {
|
||||||
self.codec_id = profile.codec;
|
self.codec_id = profile.codec;
|
||||||
self.frame_duration_ms = profile.frame_duration_ms;
|
self.frame_duration_ms = profile.frame_duration_ms;
|
||||||
self.apply_bitrate(profile.codec)?;
|
self.apply_bitrate(profile.codec)?;
|
||||||
// Refresh DRED duration for the new tier. apply_protection_mode
|
|
||||||
// is idempotent and handles the legacy-vs-DRED branch correctly.
|
|
||||||
self.apply_protection_mode(profile.codec)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
other => Err(CodecError::UnsupportedTransition {
|
other => Err(CodecError::UnsupportedTransition {
|
||||||
@@ -315,190 +120,10 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_inband_fec(&mut self, enabled: bool) {
|
fn set_inband_fec(&mut self, enabled: bool) {
|
||||||
// In DRED mode, ignore external requests to re-enable inband FEC —
|
let _ = self.inner.set_inband_fec(enabled);
|
||||||
// running both mechanisms wastes bitrate on overlapping protection
|
|
||||||
// and opusic-c's own docs recommend disabling inband FEC when DRED
|
|
||||||
// is on. Trait callers that genuinely want classical FEC should set
|
|
||||||
// `AUDIO_USE_LEGACY_FEC=1` and re-create the encoder.
|
|
||||||
if !self.legacy_fec_mode {
|
|
||||||
debug!(
|
|
||||||
enabled,
|
|
||||||
"set_inband_fec ignored: DRED mode is active (set AUDIO_USE_LEGACY_FEC to revert)"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mode = if enabled { InbandFec::Mode1 } else { InbandFec::Off };
|
|
||||||
let _ = self.inner.set_inband_fec(mode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_dtx(&mut self, enabled: bool) {
|
fn set_dtx(&mut self, enabled: bool) {
|
||||||
let _ = self.inner.set_dtx(enabled);
|
let _ = self.inner.set_dtx(enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use wzp_proto::AudioDecoder;
|
|
||||||
|
|
||||||
/// Phase 0 acceptance gate: fail loudly if the linked libopus is not 1.5.x.
|
|
||||||
/// DRED (Phase 1+) only exists in libopus ≥ 1.5, so running against an
|
|
||||||
/// older version would silently regress the entire DRED integration.
|
|
||||||
#[test]
|
|
||||||
fn linked_libopus_is_1_5() {
|
|
||||||
let version = opusic_c::version();
|
|
||||||
assert!(
|
|
||||||
version.contains("1.5"),
|
|
||||||
"expected libopus 1.5.x, got: {version}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoder_creates_at_good_profile() {
|
|
||||||
let enc = OpusEncoder::new(QualityProfile::GOOD).expect("opus encoder init");
|
|
||||||
assert_eq!(enc.codec_id, CodecId::Opus24k);
|
|
||||||
assert_eq!(enc.frame_samples(), 960); // 20 ms @ 48 kHz
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoder_roundtrip_silence() {
|
|
||||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
let pcm_in = vec![0i16; 960]; // 20 ms silence
|
|
||||||
let mut encoded = vec![0u8; 512];
|
|
||||||
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
|
|
||||||
assert!(n > 0);
|
|
||||||
let mut pcm_out = vec![0i16; 960];
|
|
||||||
let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap();
|
|
||||||
assert_eq!(samples, 960);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 1 — DRED duration policy ─────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_duration_for_studio_tiers_is_100ms() {
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Opus32k), 10);
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Opus48k), 10);
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Opus64k), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_duration_for_normal_tiers_is_200ms() {
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Opus16k), 20);
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Opus24k), 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_duration_for_degraded_tier_is_500ms() {
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Opus6k), 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_duration_for_codec2_is_zero() {
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Codec2_3200), 0);
|
|
||||||
assert_eq!(dred_duration_for(CodecId::Codec2_1200), 0);
|
|
||||||
assert_eq!(dred_duration_for(CodecId::ComfortNoise), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 1 — Legacy escape hatch ──────────────────────────────────────
|
|
||||||
|
|
||||||
/// By default (env var unset), legacy mode is off.
|
|
||||||
///
|
|
||||||
/// This test does NOT manipulate the environment to avoid flakiness
|
|
||||||
/// when the full suite runs in parallel. It only asserts on a freshly
|
|
||||||
/// created encoder in the ambient environment.
|
|
||||||
#[test]
|
|
||||||
fn default_mode_is_dred_not_legacy() {
|
|
||||||
// SAFETY: only run if the ambient env hasn't set the var externally.
|
|
||||||
if std::env::var(LEGACY_FEC_ENV).is_ok() {
|
|
||||||
return; // don't assert — someone set the env for a reason.
|
|
||||||
}
|
|
||||||
let enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
assert!(!enc.is_legacy_fec_mode());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 1 — Behavioral regression: roundtrip still works ─────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dred_mode_roundtrip_voice_pattern() {
|
|
||||||
// Use a realistic voice-like input (sine wave at speech frequencies)
|
|
||||||
// so the encoder emits meaningful DRED data rather than trivially
|
|
||||||
// compressible silence.
|
|
||||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
let mut dec = crate::opus_dec::OpusDecoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
|
|
||||||
let mut total_encoded_bytes = 0usize;
|
|
||||||
// Run 50 frames (1 second) so DRED fills up and starts emitting.
|
|
||||||
for frame_idx in 0..50 {
|
|
||||||
let pcm_in: Vec<i16> = (0..960)
|
|
||||||
.map(|i| {
|
|
||||||
let t = (frame_idx * 960 + i) as f64 / 48_000.0;
|
|
||||||
(8000.0 * (2.0 * std::f64::consts::PI * 300.0 * t).sin()) as i16
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut encoded = vec![0u8; 512];
|
|
||||||
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
|
|
||||||
assert!(n > 0);
|
|
||||||
total_encoded_bytes += n;
|
|
||||||
|
|
||||||
let mut pcm_out = vec![0i16; 960];
|
|
||||||
let samples = dec.decode(&encoded[..n], &mut pcm_out).unwrap();
|
|
||||||
assert_eq!(samples, 960);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effective bitrate after 1 second of encoding.
|
|
||||||
// Opus 24k base + ~1 kbps DRED ≈ 25 kbps ≈ 3125 bytes/sec.
|
|
||||||
// Allow generous headroom (2000 lower bound, 8000 upper bound) —
|
|
||||||
// this is a behavioral regression check, not a tight bitrate assertion.
|
|
||||||
// The exact value is printed with --nocapture for diagnostic use.
|
|
||||||
eprintln!(
|
|
||||||
"[phase1 bitrate probe] legacy_fec_mode={} total_encoded={} bytes/sec",
|
|
||||||
enc.is_legacy_fec_mode(),
|
|
||||||
total_encoded_bytes
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
total_encoded_bytes > 2000,
|
|
||||||
"encoder output too small: {total_encoded_bytes} bytes/sec (DRED likely not emitting)"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
total_encoded_bytes < 8000,
|
|
||||||
"encoder output too large: {total_encoded_bytes} bytes/sec"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 1 — set_profile updates DRED duration on tier switch ─────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_switch_refreshes_dred_duration() {
|
|
||||||
// Start on GOOD (Opus 24k, DRED 20 frames), switch to DEGRADED
|
|
||||||
// (Opus 6k, DRED 50 frames). The encoder should accept both profile
|
|
||||||
// changes without error. We can't directly observe the DRED duration
|
|
||||||
// inside libopus, but apply_protection_mode returns Ok for both.
|
|
||||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
assert_eq!(enc.codec_id, CodecId::Opus24k);
|
|
||||||
|
|
||||||
enc.set_profile(QualityProfile::DEGRADED).unwrap();
|
|
||||||
assert_eq!(enc.codec_id, CodecId::Opus6k);
|
|
||||||
|
|
||||||
enc.set_profile(QualityProfile::STUDIO_64K).unwrap();
|
|
||||||
assert_eq!(enc.codec_id, CodecId::Opus64k);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 1 — Trait set_inband_fec is a no-op in DRED mode ─────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_inband_fec_noop_in_dred_mode() {
|
|
||||||
if std::env::var(LEGACY_FEC_ENV).is_ok() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut enc = OpusEncoder::new(QualityProfile::GOOD).unwrap();
|
|
||||||
// Should not error, should not re-enable inband FEC internally.
|
|
||||||
enc.set_inband_fec(true);
|
|
||||||
// We can't directly query libopus's inband FEC state through opusic-c,
|
|
||||||
// but the call must not panic and the encoder must still work.
|
|
||||||
let pcm_in = vec![0i16; 960];
|
|
||||||
let mut encoded = vec![0u8; 512];
|
|
||||||
let n = enc.encode(&pcm_in, &mut encoded).unwrap();
|
|
||||||
assert!(n > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -110,18 +110,7 @@ impl KeyExchange for WarzoneKeyExchange {
|
|||||||
hk.expand(b"warzone-session-key", &mut session_key)
|
hk.expand(b"warzone-session-key", &mut session_key)
|
||||||
.expect("HKDF expand for session key should not fail");
|
.expect("HKDF expand for session key should not fail");
|
||||||
|
|
||||||
// Derive SAS (Short Authentication String) from shared secret only.
|
Ok(Box::new(ChaChaSession::new(session_key)))
|
||||||
// The shared secret is identical on both sides (X25519 DH property).
|
|
||||||
// A MITM would produce a different shared secret → different SAS.
|
|
||||||
// We use a dedicated HKDF label so SAS is independent of the session key.
|
|
||||||
let mut sas_key = [0u8; 4];
|
|
||||||
hk.expand(b"warzone-sas-code", &mut sas_key)
|
|
||||||
.expect("HKDF expand for SAS should not fail");
|
|
||||||
let sas_code = u32::from_be_bytes(sas_key) % 10000;
|
|
||||||
|
|
||||||
let mut session = ChaChaSession::new(session_key);
|
|
||||||
session.set_sas(sas_code);
|
|
||||||
Ok(Box::new(session))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,47 +211,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(&decrypted, plaintext);
|
assert_eq!(&decrypted, plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sas_codes_match_between_peers() {
|
|
||||||
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
|
||||||
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
|
||||||
|
|
||||||
let alice_eph_pub = alice.generate_ephemeral();
|
|
||||||
let bob_eph_pub = bob.generate_ephemeral();
|
|
||||||
|
|
||||||
let alice_session = alice.derive_session(&bob_eph_pub).unwrap();
|
|
||||||
let bob_session = bob.derive_session(&alice_eph_pub).unwrap();
|
|
||||||
|
|
||||||
let alice_sas = alice_session.sas_code();
|
|
||||||
let bob_sas = bob_session.sas_code();
|
|
||||||
|
|
||||||
assert!(alice_sas.is_some(), "Alice should have SAS");
|
|
||||||
assert!(bob_sas.is_some(), "Bob should have SAS");
|
|
||||||
assert_eq!(alice_sas, bob_sas, "SAS codes must match between peers");
|
|
||||||
assert!(alice_sas.unwrap() < 10000, "SAS should be 4 digits");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sas_differs_for_different_peers() {
|
|
||||||
let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]);
|
|
||||||
let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]);
|
|
||||||
let mut eve = WarzoneKeyExchange::from_identity_seed(&[0xEE; 32]);
|
|
||||||
|
|
||||||
let alice_eph = alice.generate_ephemeral();
|
|
||||||
let bob_eph = bob.generate_ephemeral();
|
|
||||||
let eve_eph = eve.generate_ephemeral();
|
|
||||||
|
|
||||||
let alice_bob_session = alice.derive_session(&bob_eph).unwrap();
|
|
||||||
|
|
||||||
// Eve does separate handshake with Bob (MITM scenario)
|
|
||||||
let eve_bob_session = eve.derive_session(&bob_eph).unwrap();
|
|
||||||
|
|
||||||
// SAS codes should differ — Eve's session has different shared secret
|
|
||||||
assert_ne!(
|
|
||||||
alice_bob_session.sas_code(),
|
|
||||||
eve_bob_session.sas_code(),
|
|
||||||
"MITM session should produce different SAS"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ pub struct ChaChaSession {
|
|||||||
rekey_mgr: RekeyManager,
|
rekey_mgr: RekeyManager,
|
||||||
/// Pending ephemeral secret for rekey (stored until peer responds).
|
/// Pending ephemeral secret for rekey (stored until peer responds).
|
||||||
pending_rekey_secret: Option<StaticSecret>,
|
pending_rekey_secret: Option<StaticSecret>,
|
||||||
/// Short Authentication String (4-digit code for verbal verification).
|
|
||||||
sas_code: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChaChaSession {
|
impl ChaChaSession {
|
||||||
@@ -48,15 +46,9 @@ impl ChaChaSession {
|
|||||||
recv_seq: 0,
|
recv_seq: 0,
|
||||||
rekey_mgr: RekeyManager::new(shared_secret),
|
rekey_mgr: RekeyManager::new(shared_secret),
|
||||||
pending_rekey_secret: None,
|
pending_rekey_secret: None,
|
||||||
sas_code: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the SAS code (called by key exchange after derivation).
|
|
||||||
pub fn set_sas(&mut self, code: u32) {
|
|
||||||
self.sas_code = Some(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Install a new key (after rekeying).
|
/// Install a new key (after rekeying).
|
||||||
fn install_key(&mut self, new_key: [u8; 32]) {
|
fn install_key(&mut self, new_key: [u8; 32]) {
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
@@ -144,10 +136,6 @@ impl CryptoSession for ChaChaSession {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sas_code(&self) -> Option<u32> {
|
|
||||||
self.sas_code
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ fn auth_invalid_response_matches() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_signal_types_map_correctly() {
|
fn all_signal_types_map_correctly() {
|
||||||
use wzp_client::featherchat::signal_to_call_type;
|
use wzp_client::featherchat::{signal_to_call_type, CallSignalType};
|
||||||
|
|
||||||
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
|
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
//! RaptorQ FEC decoder — reassembles source blocks from received source and repair symbols.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
|
||||||
use wzp_proto::error::FecError;
|
use wzp_proto::error::FecError;
|
||||||
@@ -10,9 +9,6 @@ use wzp_proto::FecDecoder;
|
|||||||
/// Length prefix size (u16 little-endian), must match encoder.
|
/// Length prefix size (u16 little-endian), must match encoder.
|
||||||
const LEN_PREFIX: usize = 2;
|
const LEN_PREFIX: usize = 2;
|
||||||
|
|
||||||
/// Decoded blocks older than this are eligible for reuse by a new sender.
|
|
||||||
const BLOCK_STALE_SECS: u64 = 2;
|
|
||||||
|
|
||||||
/// State for one in-flight block being decoded.
|
/// State for one in-flight block being decoded.
|
||||||
struct BlockState {
|
struct BlockState {
|
||||||
/// Number of source symbols expected.
|
/// Number of source symbols expected.
|
||||||
@@ -25,8 +21,6 @@ struct BlockState {
|
|||||||
decoded: bool,
|
decoded: bool,
|
||||||
/// Cached decoded result.
|
/// Cached decoded result.
|
||||||
result: Option<Vec<Vec<u8>>>,
|
result: Option<Vec<Vec<u8>>>,
|
||||||
/// When this block was last decoded (for staleness check).
|
|
||||||
decoded_at: Option<Instant>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
/// RaptorQ-based FEC decoder that handles multiple concurrent blocks.
|
||||||
@@ -64,7 +58,6 @@ impl RaptorQFecDecoder {
|
|||||||
symbol_size: self.symbol_size,
|
symbol_size: self.symbol_size,
|
||||||
decoded: false,
|
decoded: false,
|
||||||
result: None,
|
result: None,
|
||||||
decoded_at: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,21 +74,9 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
let block = self.get_or_create_block(block_id);
|
let block = self.get_or_create_block(block_id);
|
||||||
|
|
||||||
if block.decoded {
|
if block.decoded {
|
||||||
// If the block was decoded recently, skip (normal duplicate).
|
// Already decoded, ignore additional symbols.
|
||||||
// If it's stale (>2s), a new sender is reusing this block_id — reset it.
|
|
||||||
if let Some(at) = block.decoded_at {
|
|
||||||
if at.elapsed().as_secs() >= BLOCK_STALE_SECS {
|
|
||||||
block.decoded = false;
|
|
||||||
block.result = None;
|
|
||||||
block.decoded_at = None;
|
|
||||||
block.packets.clear();
|
|
||||||
} else {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
// Data should already be at symbol_size (length-prefixed and padded by the encoder).
|
||||||
// But if caller sends raw data, pad it.
|
// But if caller sends raw data, pad it.
|
||||||
@@ -151,7 +132,6 @@ impl FecDecoder for RaptorQFecDecoder {
|
|||||||
|
|
||||||
let block = self.blocks.get_mut(&block_id).unwrap();
|
let block = self.blocks.get_mut(&block_id).unwrap();
|
||||||
block.decoded = true;
|
block.decoded = true;
|
||||||
block.decoded_at = Some(Instant::now());
|
|
||||||
block.result = Some(frames.clone());
|
block.result = Some(frames.clone());
|
||||||
Ok(Some(frames))
|
Ok(Some(frames))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wzp-native"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
description = "WarzonePhone native audio library — standalone Android cdylib that eventually owns all C++ (Oboe bridge) and exposes a pure-C FFI. Built with cargo-ndk, loaded at runtime by the Tauri desktop cdylib via libloading."
|
|
||||||
|
|
||||||
# Crate-type is DELIBERATELY only cdylib (no rlib, no staticlib). This crate
|
|
||||||
# is built with `cargo ndk -t arm64-v8a build --release -p wzp-native` as a
|
|
||||||
# standalone .so, which is the same path the legacy wzp-android crate uses
|
|
||||||
# successfully on the same phone / same NDK. Keeping the crate-type single
|
|
||||||
# avoids the rust-lang/rust#104707 symbol leak that bit us when Tauri's
|
|
||||||
# desktop crate had ["staticlib", "cdylib", "rlib"] and any C++ static
|
|
||||||
# archive pulled bionic's internal pthread_create into the final .so.
|
|
||||||
[lib]
|
|
||||||
name = "wzp_native"
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
# cc is SAFE to use here because this crate is a single-cdylib: no
|
|
||||||
# staticlib in crate-type → no rust-lang/rust#104707 symbol leak. The
|
|
||||||
# legacy wzp-android crate uses the same setup and works.
|
|
||||||
cc = "1"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Phase 2: Oboe C++ audio bridge. Still no Rust deps — we do the whole
|
|
||||||
# audio pipeline via extern "C" into the bundled C++ and expose our own
|
|
||||||
# narrow extern "C" API for wzp-desktop to dlopen via libloading.
|
|
||||||
# Phase 3 can add wzp-proto/wzp-codec if we want to share codec logic
|
|
||||||
# instead of calling back into wzp-desktop via callbacks.
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
//! wzp-native build.rs — Oboe C++ bridge compile on Android.
|
|
||||||
//!
|
|
||||||
//! Near-verbatim copy of crates/wzp-android/build.rs (which is known to
|
|
||||||
//! work). The crucial distinction: this crate is a single-cdylib (no
|
|
||||||
//! staticlib, no rlib in crate-type) so rust-lang/rust#104707 doesn't
|
|
||||||
//! apply — bionic's internal pthread_create / __init_tcb symbols stay
|
|
||||||
//! UND and resolve against libc.so at runtime, as they should.
|
|
||||||
//!
|
|
||||||
//! On non-Android hosts we compile `cpp/oboe_stub.cpp` (empty stubs) so
|
|
||||||
//! `cargo check --target <host>` still works for IDEs and CI.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let target = std::env::var("TARGET").unwrap_or_default();
|
|
||||||
|
|
||||||
if target.contains("android") {
|
|
||||||
// getauxval_fix: override compiler-rt's broken static getauxval
|
|
||||||
// stub that SIGSEGVs in shared libraries.
|
|
||||||
cc::Build::new()
|
|
||||||
.file("cpp/getauxval_fix.c")
|
|
||||||
.compile("wzp_native_getauxval_fix");
|
|
||||||
|
|
||||||
let oboe_dir = fetch_oboe();
|
|
||||||
match oboe_dir {
|
|
||||||
Some(oboe_path) => {
|
|
||||||
println!("cargo:warning=wzp-native: building with Oboe from {:?}", oboe_path);
|
|
||||||
let mut build = cc::Build::new();
|
|
||||||
build
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
// Shared libc++ — matches legacy wzp-android setup.
|
|
||||||
.cpp_link_stdlib(Some("c++_shared"))
|
|
||||||
.include("cpp")
|
|
||||||
.include(oboe_path.join("include"))
|
|
||||||
.include(oboe_path.join("src"))
|
|
||||||
.define("WZP_HAS_OBOE", None)
|
|
||||||
.file("cpp/oboe_bridge.cpp");
|
|
||||||
add_cpp_files_recursive(&mut build, &oboe_path.join("src"));
|
|
||||||
build.compile("wzp_native_oboe_bridge");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!("cargo:warning=wzp-native: Oboe not found, building stub");
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.cpp_link_stdlib(Some("c++_shared"))
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("wzp_native_oboe_bridge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oboe needs log + OpenSLES backends at runtime.
|
|
||||||
println!("cargo:rustc-link-lib=log");
|
|
||||||
println!("cargo:rustc-link-lib=OpenSLES");
|
|
||||||
|
|
||||||
// Re-run if any cpp file changes
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.cpp");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_bridge.h");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/getauxval_fix.c");
|
|
||||||
} else {
|
|
||||||
// Non-Android hosts: compile the empty stub so lib.rs's extern
|
|
||||||
// declarations resolve when someone runs `cargo check` on macOS
|
|
||||||
// or Linux without an NDK.
|
|
||||||
cc::Build::new()
|
|
||||||
.cpp(true)
|
|
||||||
.std("c++17")
|
|
||||||
.file("cpp/oboe_stub.cpp")
|
|
||||||
.include("cpp")
|
|
||||||
.compile("wzp_native_oboe_bridge");
|
|
||||||
println!("cargo:rerun-if-changed=cpp/oboe_stub.cpp");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursively add all `.cpp` files from a directory to a cc::Build.
|
|
||||||
fn add_cpp_files_recursive(build: &mut cc::Build, dir: &std::path::Path) {
|
|
||||||
if !dir.is_dir() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for entry in std::fs::read_dir(dir).unwrap() {
|
|
||||||
let entry = entry.unwrap();
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
add_cpp_files_recursive(build, &path);
|
|
||||||
} else if path.extension().map_or(false, |e| e == "cpp") {
|
|
||||||
build.file(&path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch or find Oboe headers + sources (v1.8.1). Same logic as the
|
|
||||||
/// legacy wzp-android crate's build.rs.
|
|
||||||
fn fetch_oboe() -> Option<PathBuf> {
|
|
||||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
|
||||||
let oboe_dir = out_dir.join("oboe");
|
|
||||||
|
|
||||||
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() {
|
|
||||||
return Some(oboe_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = std::process::Command::new("git")
|
|
||||||
.args([
|
|
||||||
"clone",
|
|
||||||
"--depth=1",
|
|
||||||
"--branch=1.8.1",
|
|
||||||
"https://github.com/google/oboe.git",
|
|
||||||
oboe_dir.to_str().unwrap(),
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
|
|
||||||
match status {
|
|
||||||
Ok(s) if s.success() && oboe_dir.join("include").join("oboe").join("Oboe.h").exists() => {
|
|
||||||
Some(oboe_dir)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Override the broken static getauxval from compiler-rt/CRT.
|
|
||||||
// The static version reads from __libc_auxv which is NULL in shared libs
|
|
||||||
// loaded via dlopen, causing SIGSEGV in init_have_lse_atomics at load time.
|
|
||||||
// This version calls the real bionic getauxval via dlsym.
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
typedef unsigned long (*getauxval_fn)(unsigned long);
|
|
||||||
|
|
||||||
unsigned long getauxval(unsigned long type) {
|
|
||||||
static getauxval_fn real_getauxval = (getauxval_fn)0;
|
|
||||||
if (!real_getauxval) {
|
|
||||||
real_getauxval = (getauxval_fn)dlsym((void*)-1L /* RTLD_DEFAULT */, "getauxval");
|
|
||||||
if (!real_getauxval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return real_getauxval(type);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
// Full Oboe implementation for Android
|
|
||||||
// This file is compiled only when targeting Android
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
|
|
||||||
#ifdef __ANDROID__
|
|
||||||
#include <oboe/Oboe.h>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <cstring>
|
|
||||||
#include <atomic>
|
|
||||||
|
|
||||||
#define LOG_TAG "wzp-oboe"
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Ring buffer helpers (SPSC, lock-free)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static inline int32_t ring_available_read(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_acquire);
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
int32_t avail = w - r;
|
|
||||||
if (avail < 0) avail += capacity;
|
|
||||||
return avail;
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline int32_t ring_available_write(const wzp_atomic_int* write_idx,
|
|
||||||
const wzp_atomic_int* read_idx,
|
|
||||||
int32_t capacity) {
|
|
||||||
return capacity - 1 - ring_available_read(write_idx, read_idx, capacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_write(int16_t* buf, int32_t capacity,
|
|
||||||
wzp_atomic_int* write_idx, const wzp_atomic_int* read_idx,
|
|
||||||
const int16_t* src, int32_t count) {
|
|
||||||
int32_t w = std::atomic_load_explicit(write_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
buf[w] = src[i];
|
|
||||||
w++;
|
|
||||||
if (w >= capacity) w = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(write_idx, w, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
static inline void ring_read(int16_t* buf, int32_t capacity,
|
|
||||||
const wzp_atomic_int* write_idx, wzp_atomic_int* read_idx,
|
|
||||||
int16_t* dst, int32_t count) {
|
|
||||||
int32_t r = std::atomic_load_explicit(read_idx, std::memory_order_relaxed);
|
|
||||||
for (int32_t i = 0; i < count; i++) {
|
|
||||||
dst[i] = buf[r];
|
|
||||||
r++;
|
|
||||||
if (r >= capacity) r = 0;
|
|
||||||
}
|
|
||||||
std::atomic_store_explicit(read_idx, r, std::memory_order_release);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_capture_stream;
|
|
||||||
static std::shared_ptr<oboe::AudioStream> g_playout_stream;
|
|
||||||
// Value copy — the WzpOboeRings the Rust side passes us lives on the caller's
|
|
||||||
// stack frame and goes away as soon as wzp_oboe_start returns. The raw
|
|
||||||
// int16/atomic pointers INSIDE the struct point into the Rust-owned, leaked-
|
|
||||||
// for-the-lifetime-of-the-process AudioBackend singleton, so copying the
|
|
||||||
// struct by value is safe and keeps the inner pointers valid indefinitely.
|
|
||||||
// g_rings_valid guards the audio-callback-side read; clearing it in stop()
|
|
||||||
// signals "no backend" to the callbacks which then return silence + Stop.
|
|
||||||
static WzpOboeRings g_rings{};
|
|
||||||
static std::atomic<bool> g_rings_valid{false};
|
|
||||||
static std::atomic<bool> g_running{false};
|
|
||||||
static std::atomic<float> g_capture_latency_ms{0.0f};
|
|
||||||
static std::atomic<float> g_playout_latency_ms{0.0f};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Capture callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CaptureCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
uint64_t calls = 0;
|
|
||||||
uint64_t total_frames = 0;
|
|
||||||
uint64_t total_written = 0;
|
|
||||||
uint64_t ring_full_drops = 0;
|
|
||||||
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) ||
|
|
||||||
!g_rings_valid.load(std::memory_order_acquire)) {
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int16_t* src = static_cast<const int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_write(g_rings.capture_write_idx,
|
|
||||||
g_rings.capture_read_idx,
|
|
||||||
g_rings.capture_capacity);
|
|
||||||
int32_t to_write = (numFrames < avail) ? numFrames : avail;
|
|
||||||
if (to_write > 0) {
|
|
||||||
ring_write(g_rings.capture_buf, g_rings.capture_capacity,
|
|
||||||
g_rings.capture_write_idx, g_rings.capture_read_idx,
|
|
||||||
src, to_write);
|
|
||||||
}
|
|
||||||
total_frames += numFrames;
|
|
||||||
total_written += to_write;
|
|
||||||
if (to_write < numFrames) {
|
|
||||||
ring_full_drops += (numFrames - to_write);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample-range probe on the FIRST callback to prove we get real audio
|
|
||||||
if (calls == 0 && numFrames > 0) {
|
|
||||||
int16_t lo = src[0], hi = src[0];
|
|
||||||
int32_t sumsq = 0;
|
|
||||||
for (int32_t i = 0; i < numFrames; i++) {
|
|
||||||
if (src[i] < lo) lo = src[i];
|
|
||||||
if (src[i] > hi) hi = src[i];
|
|
||||||
sumsq += (int32_t)src[i] * (int32_t)src[i];
|
|
||||||
}
|
|
||||||
int32_t rms = (int32_t) (numFrames > 0 ? (int32_t)__builtin_sqrt((double)sumsq / (double)numFrames) : 0);
|
|
||||||
LOGI("capture cb#0: numFrames=%d sample_range=[%d..%d] rms=%d to_write=%d",
|
|
||||||
numFrames, lo, hi, rms, to_write);
|
|
||||||
}
|
|
||||||
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
|
||||||
calls++;
|
|
||||||
if ((calls % 50) == 0) {
|
|
||||||
LOGI("capture heartbeat: calls=%llu numFrames=%d ring_avail_write=%d to_write=%d full_drops=%llu total_written=%llu",
|
|
||||||
(unsigned long long)calls, numFrames, avail, to_write,
|
|
||||||
(unsigned long long)ring_full_drops, (unsigned long long)total_written);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_capture_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Playout callback
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PlayoutCallback : public oboe::AudioStreamDataCallback {
|
|
||||||
public:
|
|
||||||
uint64_t calls = 0;
|
|
||||||
uint64_t total_frames = 0;
|
|
||||||
uint64_t total_played_real = 0;
|
|
||||||
uint64_t underrun_frames = 0;
|
|
||||||
uint64_t nonempty_calls = 0;
|
|
||||||
|
|
||||||
oboe::DataCallbackResult onAudioReady(
|
|
||||||
oboe::AudioStream* stream,
|
|
||||||
void* audioData,
|
|
||||||
int32_t numFrames) override {
|
|
||||||
if (!g_running.load(std::memory_order_relaxed) ||
|
|
||||||
!g_rings_valid.load(std::memory_order_acquire)) {
|
|
||||||
memset(audioData, 0, numFrames * sizeof(int16_t));
|
|
||||||
return oboe::DataCallbackResult::Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
int16_t* dst = static_cast<int16_t*>(audioData);
|
|
||||||
int32_t avail = ring_available_read(g_rings.playout_write_idx,
|
|
||||||
g_rings.playout_read_idx,
|
|
||||||
g_rings.playout_capacity);
|
|
||||||
int32_t to_read = (numFrames < avail) ? numFrames : avail;
|
|
||||||
|
|
||||||
if (to_read > 0) {
|
|
||||||
ring_read(g_rings.playout_buf, g_rings.playout_capacity,
|
|
||||||
g_rings.playout_write_idx, g_rings.playout_read_idx,
|
|
||||||
dst, to_read);
|
|
||||||
nonempty_calls++;
|
|
||||||
}
|
|
||||||
// Fill remainder with silence on underrun
|
|
||||||
if (to_read < numFrames) {
|
|
||||||
memset(dst + to_read, 0, (numFrames - to_read) * sizeof(int16_t));
|
|
||||||
underrun_frames += (numFrames - to_read);
|
|
||||||
}
|
|
||||||
total_frames += numFrames;
|
|
||||||
total_played_real += to_read;
|
|
||||||
|
|
||||||
// First callback: log requested config + prove we're being called
|
|
||||||
if (calls == 0) {
|
|
||||||
LOGI("playout cb#0: numFrames=%d ring_avail_read=%d to_read=%d",
|
|
||||||
numFrames, avail, to_read);
|
|
||||||
}
|
|
||||||
// On the first callback that actually has data, log the sample range
|
|
||||||
// so we can tell if the samples coming out of the ring look like real
|
|
||||||
// audio vs constant-zeroes vs garbage.
|
|
||||||
if (to_read > 0 && nonempty_calls == 1) {
|
|
||||||
int16_t lo = dst[0], hi = dst[0];
|
|
||||||
int32_t sumsq = 0;
|
|
||||||
for (int32_t i = 0; i < to_read; i++) {
|
|
||||||
if (dst[i] < lo) lo = dst[i];
|
|
||||||
if (dst[i] > hi) hi = dst[i];
|
|
||||||
sumsq += (int32_t)dst[i] * (int32_t)dst[i];
|
|
||||||
}
|
|
||||||
int32_t rms = (to_read > 0) ? (int32_t)__builtin_sqrt((double)sumsq / (double)to_read) : 0;
|
|
||||||
LOGI("playout FIRST nonempty read: to_read=%d sample_range=[%d..%d] rms=%d",
|
|
||||||
to_read, lo, hi, rms);
|
|
||||||
}
|
|
||||||
// Heartbeat every 50 callbacks (~1s at 20ms/burst)
|
|
||||||
calls++;
|
|
||||||
if ((calls % 50) == 0) {
|
|
||||||
int state = (int)stream->getState();
|
|
||||||
auto xrunRes = stream->getXRunCount();
|
|
||||||
int xruns = xrunRes ? xrunRes.value() : -1;
|
|
||||||
LOGI("playout heartbeat: calls=%llu nonempty=%llu numFrames=%d ring_avail_read=%d to_read=%d underrun_frames=%llu total_played_real=%llu state=%d xruns=%d",
|
|
||||||
(unsigned long long)calls, (unsigned long long)nonempty_calls,
|
|
||||||
numFrames, avail, to_read,
|
|
||||||
(unsigned long long)underrun_frames, (unsigned long long)total_played_real,
|
|
||||||
state, xruns);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update latency estimate
|
|
||||||
auto result = stream->calculateLatencyMillis();
|
|
||||||
if (result) {
|
|
||||||
g_playout_latency_ms.store(static_cast<float>(result.value()),
|
|
||||||
std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return oboe::DataCallbackResult::Continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static CaptureCallback g_capture_cb;
|
|
||||||
static PlayoutCallback g_playout_cb;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public C API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
if (g_running.load(std::memory_order_relaxed)) {
|
|
||||||
LOGW("wzp_oboe_start: already running");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep-copy the rings struct into static storage BEFORE we publish it to
|
|
||||||
// the audio callbacks — `rings` points at the caller's stack frame and
|
|
||||||
// goes away as soon as this function returns.
|
|
||||||
g_rings = *rings;
|
|
||||||
g_rings_valid.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
// Build capture stream
|
|
||||||
oboe::AudioStreamBuilder captureBuilder;
|
|
||||||
captureBuilder.setDirection(oboe::Direction::Input)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setInputPreset(oboe::InputPreset::VoiceCommunication)
|
|
||||||
->setDataCallback(&g_capture_cb);
|
|
||||||
|
|
||||||
oboe::Result result = captureBuilder.openStream(g_capture_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
|
|
||||||
return -2;
|
|
||||||
}
|
|
||||||
LOGI("capture stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
|
||||||
g_capture_stream->getSampleRate(),
|
|
||||||
g_capture_stream->getChannelCount(),
|
|
||||||
(int)g_capture_stream->getFormat(),
|
|
||||||
g_capture_stream->getFramesPerBurst(),
|
|
||||||
g_capture_stream->getFramesPerDataCallback(),
|
|
||||||
g_capture_stream->getBufferCapacityInFrames(),
|
|
||||||
(int)g_capture_stream->getSharingMode(),
|
|
||||||
(int)g_capture_stream->getPerformanceMode());
|
|
||||||
|
|
||||||
// Build playout stream.
|
|
||||||
//
|
|
||||||
// Regression triangulation between builds:
|
|
||||||
// 96be740 (Usage::Media, default API): playout callback DID drain
|
|
||||||
// the ring at steady 50Hz (playout heartbeat: calls=1100,
|
|
||||||
// total_played_real=1055040). Audio not audible because OS routing
|
|
||||||
// sent it to a silent output.
|
|
||||||
//
|
|
||||||
// 8c36fb5 (Usage::VoiceCommunication + setAudioApi(AAudio) +
|
|
||||||
// ContentType::Speech): playout callback fired cb#0 once then
|
|
||||||
// stopped draining the ring entirely. written_samples stuck at
|
|
||||||
// ring capacity (7679) across all subsequent heartbeats, so Oboe
|
|
||||||
// accepted zero samples after startup. Still inaudible.
|
|
||||||
//
|
|
||||||
// Hypothesis: forcing setAudioApi(AAudio) + VoiceCommunication on
|
|
||||||
// Pixel 6 / Android 15 opens a stream that succeeds at cb#0 but
|
|
||||||
// then detaches from the real audio driver. Reverting to the
|
|
||||||
// config that at least drove callbacks correctly, plus the
|
|
||||||
// Kotlin-side MODE_IN_COMMUNICATION + setSpeakerphoneOn(true)
|
|
||||||
// handled in MainActivity.kt to route audio to the loud speaker.
|
|
||||||
// Usage::VoiceCommunication is the correct Oboe usage for a VoIP app
|
|
||||||
// — it respects Android's in-call audio routing and lets
|
|
||||||
// AudioManager.setSpeakerphoneOn/setBluetoothScoOn actually switch
|
|
||||||
// between earpiece, loudspeaker, and Bluetooth headset. Combined with
|
|
||||||
// MODE_IN_COMMUNICATION set from MainActivity.kt and
|
|
||||||
// speakerphoneOn=false by default, this produces handset/earpiece as
|
|
||||||
// the default output.
|
|
||||||
//
|
|
||||||
// IMPORTANT: do NOT add setAudioApi(AAudio) here. Build 8c36fb5 proved
|
|
||||||
// forcing AAudio with Usage::VoiceCommunication makes the playout
|
|
||||||
// callback stop draining the ring after cb#0, even though the stream
|
|
||||||
// opens successfully. Letting Oboe pick the API (which will be AAudio
|
|
||||||
// on API ≥ 27 but via a different codepath) kept callbacks firing in
|
|
||||||
// every other build.
|
|
||||||
oboe::AudioStreamBuilder playoutBuilder;
|
|
||||||
playoutBuilder.setDirection(oboe::Direction::Output)
|
|
||||||
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
|
|
||||||
->setSharingMode(oboe::SharingMode::Exclusive)
|
|
||||||
->setFormat(oboe::AudioFormat::I16)
|
|
||||||
->setChannelCount(config->channel_count)
|
|
||||||
->setSampleRate(config->sample_rate)
|
|
||||||
->setFramesPerDataCallback(config->frames_per_burst)
|
|
||||||
->setUsage(oboe::Usage::VoiceCommunication)
|
|
||||||
->setDataCallback(&g_playout_cb);
|
|
||||||
|
|
||||||
result = playoutBuilder.openStream(g_playout_stream);
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
return -3;
|
|
||||||
}
|
|
||||||
LOGI("playout stream opened: actualSR=%d actualCh=%d actualFormat=%d actualFramesPerBurst=%d actualFramesPerDataCallback=%d bufferCapacityInFrames=%d sharing=%d perfMode=%d",
|
|
||||||
g_playout_stream->getSampleRate(),
|
|
||||||
g_playout_stream->getChannelCount(),
|
|
||||||
(int)g_playout_stream->getFormat(),
|
|
||||||
g_playout_stream->getFramesPerBurst(),
|
|
||||||
g_playout_stream->getFramesPerDataCallback(),
|
|
||||||
g_playout_stream->getBufferCapacityInFrames(),
|
|
||||||
(int)g_playout_stream->getSharingMode(),
|
|
||||||
(int)g_playout_stream->getPerformanceMode());
|
|
||||||
|
|
||||||
g_running.store(true, std::memory_order_release);
|
|
||||||
|
|
||||||
// Start both streams
|
|
||||||
result = g_capture_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start capture: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -4;
|
|
||||||
}
|
|
||||||
|
|
||||||
result = g_playout_stream->requestStart();
|
|
||||||
if (result != oboe::Result::OK) {
|
|
||||||
LOGE("Failed to start playout: %s", oboe::convertToText(result));
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
return -5;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Oboe started: sr=%d burst=%d ch=%d",
|
|
||||||
config->sample_rate, config->frames_per_burst, config->channel_count);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
g_running.store(false, std::memory_order_release);
|
|
||||||
// Tell the audio callbacks to stop touching g_rings BEFORE we tear down
|
|
||||||
// the streams, so any in-flight callback returns Stop instead of reading
|
|
||||||
// stale pointers.
|
|
||||||
g_rings_valid.store(false, std::memory_order_release);
|
|
||||||
|
|
||||||
if (g_capture_stream) {
|
|
||||||
g_capture_stream->requestStop();
|
|
||||||
g_capture_stream->close();
|
|
||||||
g_capture_stream.reset();
|
|
||||||
}
|
|
||||||
if (g_playout_stream) {
|
|
||||||
g_playout_stream->requestStop();
|
|
||||||
g_playout_stream->close();
|
|
||||||
g_playout_stream.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Oboe stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return g_capture_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return g_playout_latency_ms.load(std::memory_order_relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return g_running.load(std::memory_order_relaxed) ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#else
|
|
||||||
// Non-Android fallback — should not be reached; oboe_stub.cpp is used instead.
|
|
||||||
// Provide empty implementations just in case.
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config; (void)rings;
|
|
||||||
return -99;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {}
|
|
||||||
float wzp_oboe_capture_latency_ms(void) { return 0.0f; }
|
|
||||||
float wzp_oboe_playout_latency_ms(void) { return 0.0f; }
|
|
||||||
int wzp_oboe_is_running(void) { return 0; }
|
|
||||||
|
|
||||||
#endif // __ANDROID__
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#ifndef WZP_OBOE_BRIDGE_H
|
|
||||||
#define WZP_OBOE_BRIDGE_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
#include <atomic>
|
|
||||||
typedef std::atomic<int32_t> wzp_atomic_int;
|
|
||||||
extern "C" {
|
|
||||||
#else
|
|
||||||
#include <stdatomic.h>
|
|
||||||
typedef atomic_int wzp_atomic_int;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t sample_rate;
|
|
||||||
int32_t frames_per_burst;
|
|
||||||
int32_t channel_count;
|
|
||||||
} WzpOboeConfig;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int16_t* capture_buf;
|
|
||||||
int32_t capture_capacity;
|
|
||||||
wzp_atomic_int* capture_write_idx;
|
|
||||||
wzp_atomic_int* capture_read_idx;
|
|
||||||
|
|
||||||
int16_t* playout_buf;
|
|
||||||
int32_t playout_capacity;
|
|
||||||
wzp_atomic_int* playout_write_idx;
|
|
||||||
wzp_atomic_int* playout_read_idx;
|
|
||||||
} WzpOboeRings;
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings);
|
|
||||||
void wzp_oboe_stop(void);
|
|
||||||
float wzp_oboe_capture_latency_ms(void);
|
|
||||||
float wzp_oboe_playout_latency_ms(void);
|
|
||||||
int wzp_oboe_is_running(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif // WZP_OBOE_BRIDGE_H
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Stub implementation for non-Android host builds (testing, cargo check, etc.)
|
|
||||||
|
|
||||||
#include "oboe_bridge.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
|
|
||||||
(void)config;
|
|
||||||
(void)rings;
|
|
||||||
fprintf(stderr, "wzp_oboe_start: stub (not on Android)\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void wzp_oboe_stop(void) {
|
|
||||||
fprintf(stderr, "wzp_oboe_stop: stub (not on Android)\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_capture_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float wzp_oboe_playout_latency_ms(void) {
|
|
||||||
return 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
int wzp_oboe_is_running(void) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
//! wzp-native — standalone Android cdylib for all the C++ audio code.
|
|
||||||
//!
|
|
||||||
//! Built with `cargo ndk`, NOT `cargo tauri android build`. Loaded at
|
|
||||||
//! runtime by the Tauri desktop cdylib (`wzp-desktop`) via libloading.
|
|
||||||
//! See `docs/incident-tauri-android-init-tcb.md` for why the split exists.
|
|
||||||
//!
|
|
||||||
//! Phase 2: real Oboe audio backend.
|
|
||||||
//!
|
|
||||||
//! Architecture: Oboe runs capture + playout streams on its own high-
|
|
||||||
//! priority AAudio callback threads inside the C++ bridge. Two SPSC ring
|
|
||||||
//! buffers (capture and playout) are shared between the C++ callbacks
|
|
||||||
//! and the Rust side via atomic indices — no locks on the hot path.
|
|
||||||
//! `wzp-desktop` drains the capture ring into its Opus encoder and fills
|
|
||||||
//! the playout ring with decoded PCM.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicI32, Ordering};
|
|
||||||
|
|
||||||
// ─── Phase 1 smoke-test exports (kept for sanity checks) ─────────────────
|
|
||||||
|
|
||||||
/// Returns 42. Used by wzp-desktop's setup() to verify dlopen + dlsym
|
|
||||||
/// work before any audio code runs.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_version() -> i32 {
|
|
||||||
42
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes a NUL-terminated string into `out` (capped at `cap`) and
|
|
||||||
/// returns bytes written excluding the NUL.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_hello(out: *mut u8, cap: usize) -> usize {
|
|
||||||
const MSG: &[u8] = b"hello from wzp-native\0";
|
|
||||||
if out.is_null() || cap == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let n = MSG.len().min(cap);
|
|
||||||
unsafe {
|
|
||||||
core::ptr::copy_nonoverlapping(MSG.as_ptr(), out, n);
|
|
||||||
*out.add(n - 1) = 0;
|
|
||||||
}
|
|
||||||
n - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── C++ Oboe bridge FFI ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct WzpOboeConfig {
|
|
||||||
sample_rate: i32,
|
|
||||||
frames_per_burst: i32,
|
|
||||||
channel_count: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct WzpOboeRings {
|
|
||||||
capture_buf: *mut i16,
|
|
||||||
capture_capacity: i32,
|
|
||||||
capture_write_idx: *mut AtomicI32,
|
|
||||||
capture_read_idx: *mut AtomicI32,
|
|
||||||
playout_buf: *mut i16,
|
|
||||||
playout_capacity: i32,
|
|
||||||
playout_write_idx: *mut AtomicI32,
|
|
||||||
playout_read_idx: *mut AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: atomics synchronise producer/consumer; raw pointers are owned
|
|
||||||
// by the AudioBackend singleton below whose lifetime covers all calls.
|
|
||||||
unsafe impl Send for WzpOboeRings {}
|
|
||||||
unsafe impl Sync for WzpOboeRings {}
|
|
||||||
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn wzp_oboe_start(config: *const WzpOboeConfig, rings: *const WzpOboeRings) -> i32;
|
|
||||||
fn wzp_oboe_stop();
|
|
||||||
fn wzp_oboe_capture_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_playout_latency_ms() -> f32;
|
|
||||||
fn wzp_oboe_is_running() -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SPSC ring buffer (shared with C++ via AtomicI32) ────────────────────
|
|
||||||
|
|
||||||
/// 20 ms @ 48 kHz mono = 960 samples.
|
|
||||||
const FRAME_SAMPLES: usize = 960;
|
|
||||||
/// ~160 ms headroom at 48 kHz.
|
|
||||||
const RING_CAPACITY: usize = 7680;
|
|
||||||
|
|
||||||
struct RingBuffer {
|
|
||||||
buf: Vec<i16>,
|
|
||||||
capacity: usize,
|
|
||||||
write_idx: AtomicI32,
|
|
||||||
read_idx: AtomicI32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: SPSC with atomic read/write cursors; producer and consumer
|
|
||||||
// are always on different threads.
|
|
||||||
unsafe impl Send for RingBuffer {}
|
|
||||||
unsafe impl Sync for RingBuffer {}
|
|
||||||
|
|
||||||
impl RingBuffer {
|
|
||||||
fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
buf: vec![0i16; capacity],
|
|
||||||
capacity,
|
|
||||||
write_idx: AtomicI32::new(0),
|
|
||||||
read_idx: AtomicI32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn available_read(&self) -> usize {
|
|
||||||
let w = self.write_idx.load(Ordering::Acquire);
|
|
||||||
let r = self.read_idx.load(Ordering::Relaxed);
|
|
||||||
let avail = w - r;
|
|
||||||
if avail < 0 { (avail + self.capacity as i32) as usize } else { avail as usize }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn available_write(&self) -> usize {
|
|
||||||
self.capacity - 1 - self.available_read()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write(&self, data: &[i16]) -> usize {
|
|
||||||
let count = data.len().min(self.available_write());
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut w = self.write_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr() as *mut i16;
|
|
||||||
for sample in &data[..count] {
|
|
||||||
unsafe { *buf_ptr.add(w) = *sample; }
|
|
||||||
w += 1;
|
|
||||||
if w >= cap { w = 0; }
|
|
||||||
}
|
|
||||||
self.write_idx.store(w as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read(&self, out: &mut [i16]) -> usize {
|
|
||||||
let count = out.len().min(self.available_read());
|
|
||||||
if count == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let mut r = self.read_idx.load(Ordering::Relaxed) as usize;
|
|
||||||
let cap = self.capacity;
|
|
||||||
let buf_ptr = self.buf.as_ptr();
|
|
||||||
for slot in &mut out[..count] {
|
|
||||||
unsafe { *slot = *buf_ptr.add(r); }
|
|
||||||
r += 1;
|
|
||||||
if r >= cap { r = 0; }
|
|
||||||
}
|
|
||||||
self.read_idx.store(r as i32, Ordering::Release);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
fn buf_ptr(&self) -> *mut i16 {
|
|
||||||
self.buf.as_ptr() as *mut i16
|
|
||||||
}
|
|
||||||
fn write_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.write_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
fn read_idx_ptr(&self) -> *mut AtomicI32 {
|
|
||||||
&self.read_idx as *const AtomicI32 as *mut AtomicI32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AudioBackend singleton ──────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// There is one global AudioBackend instance because Oboe's C++ side
|
|
||||||
// holds its own singleton of the streams. The `Box::leak`'d statics own
|
|
||||||
// the ring buffers for the lifetime of the process — dropping them while
|
|
||||||
// Oboe is still running would cause use-after-free in the audio callback.
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
struct AudioBackend {
|
|
||||||
capture: RingBuffer,
|
|
||||||
playout: RingBuffer,
|
|
||||||
started: std::sync::Mutex<bool>,
|
|
||||||
/// Per-write logging throttle counter for wzp_native_audio_write_playout.
|
|
||||||
playout_write_log_count: std::sync::atomic::AtomicU64,
|
|
||||||
}
|
|
||||||
|
|
||||||
static BACKEND: OnceLock<&'static AudioBackend> = OnceLock::new();
|
|
||||||
|
|
||||||
fn backend() -> &'static AudioBackend {
|
|
||||||
BACKEND.get_or_init(|| {
|
|
||||||
Box::leak(Box::new(AudioBackend {
|
|
||||||
capture: RingBuffer::new(RING_CAPACITY),
|
|
||||||
playout: RingBuffer::new(RING_CAPACITY),
|
|
||||||
started: std::sync::Mutex::new(false),
|
|
||||||
playout_write_log_count: std::sync::atomic::AtomicU64::new(0),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── C FFI for wzp-desktop ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Start the Oboe audio streams. Returns 0 on success, non-zero on error.
|
|
||||||
/// Idempotent — calling while already running is a no-op that returns 0.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_start() -> i32 {
|
|
||||||
let b = backend();
|
|
||||||
let mut started = match b.started.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(_) => return -1,
|
|
||||||
};
|
|
||||||
if *started {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = WzpOboeConfig {
|
|
||||||
sample_rate: 48_000,
|
|
||||||
frames_per_burst: FRAME_SAMPLES as i32,
|
|
||||||
channel_count: 1,
|
|
||||||
};
|
|
||||||
let rings = WzpOboeRings {
|
|
||||||
capture_buf: b.capture.buf_ptr(),
|
|
||||||
capture_capacity: b.capture.capacity as i32,
|
|
||||||
capture_write_idx: b.capture.write_idx_ptr(),
|
|
||||||
capture_read_idx: b.capture.read_idx_ptr(),
|
|
||||||
playout_buf: b.playout.buf_ptr(),
|
|
||||||
playout_capacity: b.playout.capacity as i32,
|
|
||||||
playout_write_idx: b.playout.write_idx_ptr(),
|
|
||||||
playout_read_idx: b.playout.read_idx_ptr(),
|
|
||||||
};
|
|
||||||
let ret = unsafe { wzp_oboe_start(&config, &rings) };
|
|
||||||
if ret != 0 {
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
*started = true;
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop Oboe. Idempotent. Safe to call from any thread.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_stop() {
|
|
||||||
let b = backend();
|
|
||||||
if let Ok(mut started) = b.started.lock() {
|
|
||||||
if *started {
|
|
||||||
unsafe { wzp_oboe_stop() };
|
|
||||||
*started = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read captured PCM samples from the capture ring. Returns the number
|
|
||||||
/// of `i16` samples actually copied into `out` (may be less than
|
|
||||||
/// `out_len` if the ring is empty).
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_audio_read_capture(out: *mut i16, out_len: usize) -> usize {
|
|
||||||
if out.is_null() || out_len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts_mut(out, out_len) };
|
|
||||||
backend().capture.read(slice)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write PCM samples into the playout ring. Returns the number of
|
|
||||||
/// samples actually enqueued (may be less than `in_len` if the ring
|
|
||||||
/// is nearly full — in practice the caller should pace to 20 ms
|
|
||||||
/// frames and spin briefly if the ring is full).
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_len: usize) -> usize {
|
|
||||||
if input.is_null() || in_len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let slice = unsafe { std::slice::from_raw_parts(input, in_len) };
|
|
||||||
let b = backend();
|
|
||||||
let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let written = b.playout.write(slice);
|
|
||||||
// First few writes: log ring state + sample range so we can compare what
|
|
||||||
// engine.rs hands us to what the C++ playout callback reads.
|
|
||||||
let first_writes = b.playout_write_log_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
if first_writes < 3 || first_writes % 50 == 0 {
|
|
||||||
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
|
|
||||||
for &s in slice.iter() {
|
|
||||||
if s < lo { lo = s; }
|
|
||||||
if s > hi { hi = s; }
|
|
||||||
sumsq += (s as i64) * (s as i64);
|
|
||||||
}
|
|
||||||
let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32;
|
|
||||||
let avail_w_after = b.playout.available_write();
|
|
||||||
let avail_r_after = b.playout.available_read();
|
|
||||||
let msg = format!(
|
|
||||||
"playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}",
|
|
||||||
slice.len(), written
|
|
||||||
);
|
|
||||||
unsafe {
|
|
||||||
android_log(msg.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
written
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal android logcat shim so we can print from the cdylib without pulling
|
|
||||||
// in android_logger crate (which would add another dep that has to build with
|
|
||||||
// cargo-ndk). Uses libc's __android_log_print via extern linkage.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn __android_log_write(prio: i32, tag: *const u8, text: *const u8) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
unsafe fn android_log(msg: &str) {
|
|
||||||
// ANDROID_LOG_INFO = 4. Tag and text must be NUL-terminated.
|
|
||||||
let tag = b"wzp-native\0";
|
|
||||||
let mut buf = Vec::with_capacity(msg.len() + 1);
|
|
||||||
buf.extend_from_slice(msg.as_bytes());
|
|
||||||
buf.push(0);
|
|
||||||
unsafe { __android_log_write(4, tag.as_ptr(), buf.as_ptr()); }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
unsafe fn android_log(_msg: &str) {}
|
|
||||||
|
|
||||||
/// Current capture latency reported by Oboe, in milliseconds. Returns
|
|
||||||
/// NaN / 0.0 if the stream isn't running.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_capture_latency_ms() -> f32 {
|
|
||||||
unsafe { wzp_oboe_capture_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current playout latency reported by Oboe, in milliseconds.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_playout_latency_ms() -> f32 {
|
|
||||||
unsafe { wzp_oboe_playout_latency_ms() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-zero if both Oboe streams are currently running.
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub extern "C" fn wzp_native_audio_is_running() -> i32 {
|
|
||||||
unsafe { wzp_oboe_is_running() }
|
|
||||||
}
|
|
||||||
@@ -18,12 +18,6 @@ pub enum CodecId {
|
|||||||
Codec2_1200 = 4,
|
Codec2_1200 = 4,
|
||||||
/// Comfort noise descriptor (silence suppression)
|
/// Comfort noise descriptor (silence suppression)
|
||||||
ComfortNoise = 5,
|
ComfortNoise = 5,
|
||||||
/// Opus at 32kbps (studio low)
|
|
||||||
Opus32k = 6,
|
|
||||||
/// Opus at 48kbps (studio)
|
|
||||||
Opus48k = 7,
|
|
||||||
/// Opus at 64kbps (studio high)
|
|
||||||
Opus64k = 8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodecId {
|
impl CodecId {
|
||||||
@@ -33,9 +27,6 @@ impl CodecId {
|
|||||||
Self::Opus24k => 24_000,
|
Self::Opus24k => 24_000,
|
||||||
Self::Opus16k => 16_000,
|
Self::Opus16k => 16_000,
|
||||||
Self::Opus6k => 6_000,
|
Self::Opus6k => 6_000,
|
||||||
Self::Opus32k => 32_000,
|
|
||||||
Self::Opus48k => 48_000,
|
|
||||||
Self::Opus64k => 64_000,
|
|
||||||
Self::Codec2_3200 => 3_200,
|
Self::Codec2_3200 => 3_200,
|
||||||
Self::Codec2_1200 => 1_200,
|
Self::Codec2_1200 => 1_200,
|
||||||
Self::ComfortNoise => 0,
|
Self::ComfortNoise => 0,
|
||||||
@@ -45,7 +36,8 @@ impl CodecId {
|
|||||||
/// Preferred frame duration in milliseconds.
|
/// Preferred frame duration in milliseconds.
|
||||||
pub const fn frame_duration_ms(self) -> u8 {
|
pub const fn frame_duration_ms(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k | Self::Opus16k | Self::Opus32k | Self::Opus48k | Self::Opus64k => 20,
|
Self::Opus24k => 20,
|
||||||
|
Self::Opus16k => 20,
|
||||||
Self::Opus6k => 40,
|
Self::Opus6k => 40,
|
||||||
Self::Codec2_3200 => 20,
|
Self::Codec2_3200 => 20,
|
||||||
Self::Codec2_1200 => 40,
|
Self::Codec2_1200 => 40,
|
||||||
@@ -56,8 +48,7 @@ impl CodecId {
|
|||||||
/// Sample rate expected by this codec.
|
/// Sample rate expected by this codec.
|
||||||
pub const fn sample_rate_hz(self) -> u32 {
|
pub const fn sample_rate_hz(self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Opus24k | Self::Opus16k | Self::Opus6k
|
Self::Opus24k | Self::Opus16k | Self::Opus6k => 48_000,
|
||||||
| Self::Opus32k | Self::Opus48k | Self::Opus64k => 48_000,
|
|
||||||
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
Self::Codec2_3200 | Self::Codec2_1200 => 8_000,
|
||||||
Self::ComfortNoise => 48_000,
|
Self::ComfortNoise => 48_000,
|
||||||
}
|
}
|
||||||
@@ -72,9 +63,6 @@ impl CodecId {
|
|||||||
3 => Some(Self::Codec2_3200),
|
3 => Some(Self::Codec2_3200),
|
||||||
4 => Some(Self::Codec2_1200),
|
4 => Some(Self::Codec2_1200),
|
||||||
5 => Some(Self::ComfortNoise),
|
5 => Some(Self::ComfortNoise),
|
||||||
6 => Some(Self::Opus32k),
|
|
||||||
7 => Some(Self::Opus48k),
|
|
||||||
8 => Some(Self::Opus64k),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,12 +71,6 @@ impl CodecId {
|
|||||||
pub const fn to_wire(self) -> u8 {
|
pub const fn to_wire(self) -> u8 {
|
||||||
self as u8
|
self as u8
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this is an Opus variant.
|
|
||||||
pub const fn is_opus(self) -> bool {
|
|
||||||
matches!(self, Self::Opus6k | Self::Opus16k | Self::Opus24k
|
|
||||||
| Self::Opus32k | Self::Opus48k | Self::Opus64k)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Describes the complete quality configuration for a call session.
|
/// Describes the complete quality configuration for a call session.
|
||||||
@@ -129,30 +111,6 @@ impl QualityProfile {
|
|||||||
frames_per_block: 8,
|
frames_per_block: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Studio low: Opus 32kbps, minimal FEC.
|
|
||||||
pub const STUDIO_32K: Self = Self {
|
|
||||||
codec: CodecId::Opus32k,
|
|
||||||
fec_ratio: 0.1,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Studio: Opus 48kbps, minimal FEC.
|
|
||||||
pub const STUDIO_48K: Self = Self {
|
|
||||||
codec: CodecId::Opus48k,
|
|
||||||
fec_ratio: 0.1,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Studio high: Opus 64kbps, minimal FEC.
|
|
||||||
pub const STUDIO_64K: Self = Self {
|
|
||||||
codec: CodecId::Opus64k,
|
|
||||||
fec_ratio: 0.1,
|
|
||||||
frame_duration_ms: 20,
|
|
||||||
frames_per_block: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Estimated total bandwidth in kbps including FEC overhead.
|
/// Estimated total bandwidth in kbps including FEC overhead.
|
||||||
pub fn total_bitrate_kbps(&self) -> f32 {
|
pub fn total_bitrate_kbps(&self) -> f32 {
|
||||||
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
let base = self.codec.bitrate_bps() as f32 / 1000.0;
|
||||||
|
|||||||
@@ -273,22 +273,11 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out).
|
// Check if packet is too old (already played out)
|
||||||
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
|
||||||
// federation room — reset instead of dropping.
|
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
|
||||||
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
|
|
||||||
if backward_distance > 100 {
|
|
||||||
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
|
|
||||||
self.buffer.clear();
|
|
||||||
self.next_playout_seq = seq;
|
|
||||||
self.stats.packets_late = 0;
|
|
||||||
} else {
|
|
||||||
self.stats.packets_late += 1;
|
self.stats.packets_late += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
@@ -423,22 +412,11 @@ impl JitterBuffer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if packet is too old (already played out).
|
// Check if packet is too old (already played out)
|
||||||
// A backward jump of >100 seq (~2s at 50fps) indicates a new sender in a
|
|
||||||
// federation room — reset instead of dropping.
|
|
||||||
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
let backward_distance = self.next_playout_seq.wrapping_sub(seq);
|
|
||||||
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected");
|
|
||||||
if backward_distance > 100 {
|
|
||||||
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected");
|
|
||||||
self.buffer.clear();
|
|
||||||
self.next_playout_seq = seq;
|
|
||||||
self.stats.packets_late = 0;
|
|
||||||
} else {
|
|
||||||
self.stats.packets_late += 1;
|
self.stats.packets_late += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
// If we haven't started playout yet, adjust next_playout_seq to earliest known
|
||||||
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
if self.stats.packets_played == 0 && seq_before(seq, self.next_playout_seq) {
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ pub mod traits;
|
|||||||
pub use codec_id::{CodecId, QualityProfile};
|
pub use codec_id::{CodecId, QualityProfile};
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use packet::{
|
pub use packet::{
|
||||||
CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader,
|
HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, QualityReport,
|
||||||
QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL,
|
RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, FRAME_TYPE_MINI,
|
||||||
FRAME_TYPE_MINI,
|
|
||||||
};
|
};
|
||||||
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
pub use bandwidth::{BandwidthEstimator, CongestionState};
|
||||||
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
|
||||||
|
|||||||
@@ -584,26 +584,6 @@ pub enum SignalMessage {
|
|||||||
recommended_profile: crate::QualityProfile,
|
recommended_profile: crate::QualityProfile,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Phase 4 telemetry: loss-recovery counts for the current session.
|
|
||||||
/// Sent periodically from receivers to the relay so Prometheus metrics
|
|
||||||
/// can distinguish DRED reconstructions from classical PLC invocations.
|
|
||||||
/// Fields default to 0 on old receivers (`#[serde(default)]`), so
|
|
||||||
/// introducing this variant is backward-compatible with pre-Phase-4
|
|
||||||
/// relays — they'll just log "unknown signal variant" on receipt.
|
|
||||||
LossRecoveryUpdate {
|
|
||||||
/// Total frames reconstructed via DRED since call start (monotonic).
|
|
||||||
#[serde(default)]
|
|
||||||
dred_reconstructions: u64,
|
|
||||||
/// Total frames filled via classical Opus/Codec2 PLC since call
|
|
||||||
/// start (monotonic).
|
|
||||||
#[serde(default)]
|
|
||||||
classical_plc_invocations: u64,
|
|
||||||
/// Total frames decoded since call start. Used by the relay to
|
|
||||||
/// compute recovery rates as a fraction of total frames.
|
|
||||||
#[serde(default)]
|
|
||||||
frames_decoded: u64,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Connection keepalive / RTT measurement.
|
/// Connection keepalive / RTT measurement.
|
||||||
Ping { timestamp_ms: u64 },
|
Ping { timestamp_ms: u64 },
|
||||||
Pong { timestamp_ms: u64 },
|
Pong { timestamp_ms: u64 },
|
||||||
@@ -676,112 +656,6 @@ pub enum SignalMessage {
|
|||||||
/// List of participants currently in the room.
|
/// List of participants currently in the room.
|
||||||
participants: Vec<RoomParticipant>,
|
participants: Vec<RoomParticipant>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Federation signals (relay-to-relay) ──
|
|
||||||
|
|
||||||
/// Federation: initial handshake — the connecting relay identifies itself.
|
|
||||||
FederationHello {
|
|
||||||
/// TLS certificate fingerprint of the connecting relay.
|
|
||||||
tls_fingerprint: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Federation: this relay now has local participants in a global room.
|
|
||||||
GlobalRoomActive {
|
|
||||||
room: String,
|
|
||||||
/// Participants on the announcing relay (for federated presence).
|
|
||||||
#[serde(default)]
|
|
||||||
participants: Vec<RoomParticipant>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Federation: this relay's last local participant left a global room.
|
|
||||||
GlobalRoomInactive {
|
|
||||||
room: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Direct calling signals (client ↔ relay signaling) ──
|
|
||||||
|
|
||||||
/// Register on relay for direct calls. Sent on `_signal` connections
|
|
||||||
/// after optional AuthToken.
|
|
||||||
RegisterPresence {
|
|
||||||
/// Client's Ed25519 identity public key.
|
|
||||||
identity_pub: [u8; 32],
|
|
||||||
/// Signature over ("register-presence" || identity_pub).
|
|
||||||
signature: Vec<u8>,
|
|
||||||
/// Optional display name.
|
|
||||||
alias: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Relay confirms presence registration.
|
|
||||||
RegisterPresenceAck {
|
|
||||||
success: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Direct call offer routed through the relay to a specific peer.
|
|
||||||
DirectCallOffer {
|
|
||||||
/// Caller's fingerprint.
|
|
||||||
caller_fingerprint: String,
|
|
||||||
/// Caller's display name.
|
|
||||||
caller_alias: Option<String>,
|
|
||||||
/// Target's fingerprint.
|
|
||||||
target_fingerprint: String,
|
|
||||||
/// Unique call session ID (UUID).
|
|
||||||
call_id: String,
|
|
||||||
/// Caller's Ed25519 identity pub.
|
|
||||||
identity_pub: [u8; 32],
|
|
||||||
/// Caller's ephemeral X25519 pub (for key exchange on media connect).
|
|
||||||
ephemeral_pub: [u8; 32],
|
|
||||||
/// Signature over (ephemeral_pub || target_fingerprint || call_id).
|
|
||||||
signature: Vec<u8>,
|
|
||||||
/// Supported quality profiles.
|
|
||||||
supported_profiles: Vec<crate::QualityProfile>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Callee's response to a direct call.
|
|
||||||
DirectCallAnswer {
|
|
||||||
call_id: String,
|
|
||||||
/// How the callee accepts (or rejects).
|
|
||||||
accept_mode: CallAcceptMode,
|
|
||||||
/// Callee's identity pub (present when accepting).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
identity_pub: Option<[u8; 32]>,
|
|
||||||
/// Callee's ephemeral pub (present when accepting).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
ephemeral_pub: Option<[u8; 32]>,
|
|
||||||
/// Signature (present when accepting).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
signature: Option<Vec<u8>>,
|
|
||||||
/// Chosen quality profile (present when accepting).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
chosen_profile: Option<crate::QualityProfile>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Relay tells both parties: media room is ready.
|
|
||||||
CallSetup {
|
|
||||||
call_id: String,
|
|
||||||
/// Room name on the relay for the media session (e.g., "_call:a1b2c3d4").
|
|
||||||
room: String,
|
|
||||||
/// Relay address for the QUIC media connection.
|
|
||||||
relay_addr: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Ringing notification (relay → caller, callee received the offer).
|
|
||||||
CallRinging {
|
|
||||||
call_id: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How the callee responds to a direct call.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum CallAcceptMode {
|
|
||||||
/// Reject the call.
|
|
||||||
Reject,
|
|
||||||
/// Accept with trust — in Phase 2, this enables P2P (reveals IP).
|
|
||||||
/// In Phase 1, behaves the same as AcceptGeneric.
|
|
||||||
AcceptTrusted,
|
|
||||||
/// Accept with privacy — relay always mediates media.
|
|
||||||
AcceptGeneric,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A participant entry in a RoomUpdate message.
|
/// A participant entry in a RoomUpdate message.
|
||||||
@@ -791,10 +665,6 @@ pub struct RoomParticipant {
|
|||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
/// Optional display name set by the client.
|
/// Optional display name set by the client.
|
||||||
pub alias: Option<String>,
|
pub alias: Option<String>,
|
||||||
/// Relay label — identifies which relay this participant is connected to.
|
|
||||||
/// None for local participants, Some("Relay B") for federated.
|
|
||||||
#[serde(default)]
|
|
||||||
pub relay_label: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reasons for ending a call.
|
/// Reasons for ending a call.
|
||||||
|
|||||||
@@ -132,14 +132,6 @@ pub trait CryptoSession: Send + Sync {
|
|||||||
fn overhead(&self) -> usize {
|
fn overhead(&self) -> usize {
|
||||||
16 // ChaCha20-Poly1305 tag
|
16 // ChaCha20-Poly1305 tag
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Short Authentication String (SAS) — 4-digit code for verbal verification.
|
|
||||||
/// Both peers derive the same code from the shared secret + identity keys.
|
|
||||||
/// If a MITM relay is intercepting, the codes will differ.
|
|
||||||
/// Returns None if SAS was not computed (e.g., relay-side sessions).
|
|
||||||
fn sas_code(&self) -> Option<u32> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key exchange using the Warzone identity model.
|
/// Key exchange using the Warzone identity model.
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ prometheus = "0.13"
|
|||||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
|
||||||
tower-http = { version = "0.6", features = ["fs"] }
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
dirs = "6"
|
|
||||||
sha2 = { workspace = true }
|
|
||||||
chrono = "0.4"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wzp-relay"
|
name = "wzp-relay"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// Get git hash at build time
|
|
||||||
let output = Command::new("git")
|
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
let hash = match output {
|
|
||||||
Ok(o) if o.status.success() => {
|
|
||||||
String::from_utf8_lossy(&o.stdout).trim().to_string()
|
|
||||||
}
|
|
||||||
_ => "unknown".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("cargo:rustc-env=WZP_BUILD_HASH={hash}");
|
|
||||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
//! Direct call state tracking.
|
|
||||||
//!
|
|
||||||
//! Manages the lifecycle of 1:1 direct calls placed via the `_signal` channel.
|
|
||||||
//! Each call goes through: Pending → Ringing → Active → Ended.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// State of a direct call.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum DirectCallState {
|
|
||||||
/// Offer sent to callee, waiting for response.
|
|
||||||
Pending,
|
|
||||||
/// Callee acknowledged, ringing.
|
|
||||||
Ringing,
|
|
||||||
/// Call accepted, media room active.
|
|
||||||
Active,
|
|
||||||
/// Call ended (hangup, reject, timeout, or error).
|
|
||||||
Ended,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tracked direct call between two users.
|
|
||||||
pub struct DirectCall {
|
|
||||||
pub call_id: String,
|
|
||||||
pub caller_fingerprint: String,
|
|
||||||
pub callee_fingerprint: String,
|
|
||||||
pub state: DirectCallState,
|
|
||||||
pub accept_mode: Option<wzp_proto::CallAcceptMode>,
|
|
||||||
/// Private room name (set when accepted).
|
|
||||||
pub room_name: Option<String>,
|
|
||||||
pub created_at: Instant,
|
|
||||||
pub answered_at: Option<Instant>,
|
|
||||||
pub ended_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registry of active direct calls.
|
|
||||||
pub struct CallRegistry {
|
|
||||||
calls: HashMap<String, DirectCall>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CallRegistry {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
calls: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new pending call. Returns the call_id.
|
|
||||||
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall {
|
|
||||||
let call = DirectCall {
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
caller_fingerprint: caller_fp,
|
|
||||||
callee_fingerprint: callee_fp,
|
|
||||||
state: DirectCallState::Pending,
|
|
||||||
accept_mode: None,
|
|
||||||
room_name: None,
|
|
||||||
created_at: Instant::now(),
|
|
||||||
answered_at: None,
|
|
||||||
ended_at: None,
|
|
||||||
};
|
|
||||||
self.calls.insert(call_id.clone(), call);
|
|
||||||
self.calls.get(&call_id).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a call by ID.
|
|
||||||
pub fn get(&self, call_id: &str) -> Option<&DirectCall> {
|
|
||||||
self.calls.get(call_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a mutable call by ID.
|
|
||||||
pub fn get_mut(&mut self, call_id: &str) -> Option<&mut DirectCall> {
|
|
||||||
self.calls.get_mut(call_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transition to Ringing state.
|
|
||||||
pub fn set_ringing(&mut self, call_id: &str) -> bool {
|
|
||||||
if let Some(call) = self.calls.get_mut(call_id) {
|
|
||||||
if call.state == DirectCallState::Pending {
|
|
||||||
call.state = DirectCallState::Ringing;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transition to Active state.
|
|
||||||
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool {
|
|
||||||
if let Some(call) = self.calls.get_mut(call_id) {
|
|
||||||
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
|
|
||||||
call.state = DirectCallState::Active;
|
|
||||||
call.accept_mode = Some(mode);
|
|
||||||
call.room_name = Some(room);
|
|
||||||
call.answered_at = Some(Instant::now());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End a call.
|
|
||||||
pub fn end_call(&mut self, call_id: &str) -> Option<DirectCall> {
|
|
||||||
if let Some(call) = self.calls.get_mut(call_id) {
|
|
||||||
call.state = DirectCallState::Ended;
|
|
||||||
call.ended_at = Some(Instant::now());
|
|
||||||
}
|
|
||||||
self.calls.remove(call_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find active/pending calls involving a fingerprint.
|
|
||||||
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
|
|
||||||
self.calls.values()
|
|
||||||
.filter(|c| {
|
|
||||||
c.state != DirectCallState::Ended
|
|
||||||
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the peer's fingerprint in a call.
|
|
||||||
pub fn peer_fingerprint(&self, call_id: &str, my_fp: &str) -> Option<&str> {
|
|
||||||
self.calls.get(call_id).map(|c| {
|
|
||||||
if c.caller_fingerprint == my_fp {
|
|
||||||
c.callee_fingerprint.as_str()
|
|
||||||
} else {
|
|
||||||
c.caller_fingerprint.as_str()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove calls that have been pending longer than the timeout.
|
|
||||||
/// Returns call IDs of expired calls.
|
|
||||||
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
|
|
||||||
let now = Instant::now();
|
|
||||||
let expired: Vec<String> = self.calls.iter()
|
|
||||||
.filter(|(_, c)| {
|
|
||||||
c.state == DirectCallState::Pending
|
|
||||||
&& now.duration_since(c.created_at) > timeout
|
|
||||||
})
|
|
||||||
.map(|(id, _)| id.clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
expired.into_iter()
|
|
||||||
.filter_map(|id| self.calls.remove(&id))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of active (non-ended) calls.
|
|
||||||
pub fn active_count(&self) -> usize {
|
|
||||||
self.calls.values()
|
|
||||||
.filter(|c| c.state != DirectCallState::Ended)
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn call_lifecycle() {
|
|
||||||
let mut reg = CallRegistry::new();
|
|
||||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
||||||
|
|
||||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Pending);
|
|
||||||
assert!(reg.set_ringing("c1"));
|
|
||||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
|
|
||||||
|
|
||||||
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into()));
|
|
||||||
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
|
|
||||||
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1"));
|
|
||||||
|
|
||||||
let ended = reg.end_call("c1").unwrap();
|
|
||||||
assert_eq!(ended.state, DirectCallState::Ended);
|
|
||||||
assert_eq!(reg.active_count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expire_stale_calls() {
|
|
||||||
let mut reg = CallRegistry::new();
|
|
||||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
||||||
|
|
||||||
// Not expired yet
|
|
||||||
let expired = reg.expire_stale(Duration::from_secs(30));
|
|
||||||
assert!(expired.is_empty());
|
|
||||||
|
|
||||||
// Force expiry with 0 timeout
|
|
||||||
let expired = reg.expire_stale(Duration::from_secs(0));
|
|
||||||
assert_eq!(expired.len(), 1);
|
|
||||||
assert_eq!(expired[0].call_id, "c1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn peer_lookup() {
|
|
||||||
let mut reg = CallRegistry::new();
|
|
||||||
reg.create_call("c1".into(), "alice".into(), "bob".into());
|
|
||||||
assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob"));
|
|
||||||
assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,41 +3,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
/// A federated peer relay.
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct PeerConfig {
|
|
||||||
/// Address of the peer relay (e.g., "193.180.213.68:4433").
|
|
||||||
pub url: String,
|
|
||||||
/// Expected TLS certificate fingerprint (hex, with colons).
|
|
||||||
pub fingerprint: String,
|
|
||||||
/// Optional human-readable label.
|
|
||||||
#[serde(default)]
|
|
||||||
pub label: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trusted relay — accepts inbound federation without needing the peer's address.
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct TrustedConfig {
|
|
||||||
/// Expected TLS certificate fingerprint (hex, with colons).
|
|
||||||
pub fingerprint: String,
|
|
||||||
/// Optional human-readable label.
|
|
||||||
#[serde(default)]
|
|
||||||
pub label: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A room declared global — bridged across all federated peers.
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct GlobalRoomConfig {
|
|
||||||
/// Room name to bridge (e.g., "android").
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for the relay daemon.
|
/// Configuration for the relay daemon.
|
||||||
///
|
|
||||||
/// All fields have defaults, so a minimal TOML file only needs the
|
|
||||||
/// fields you want to override (e.g., just `[[peers]]`).
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
|
||||||
pub struct RelayConfig {
|
pub struct RelayConfig {
|
||||||
/// Address to listen on for incoming connections (client-facing).
|
/// Address to listen on for incoming connections (client-facing).
|
||||||
pub listen_addr: SocketAddr,
|
pub listen_addr: SocketAddr,
|
||||||
@@ -77,22 +44,6 @@ pub struct RelayConfig {
|
|||||||
pub ws_port: Option<u16>,
|
pub ws_port: Option<u16>,
|
||||||
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
/// Directory to serve static files from (HTML/JS/WASM for web clients).
|
||||||
pub static_dir: Option<String>,
|
pub static_dir: Option<String>,
|
||||||
/// Federation peer relays.
|
|
||||||
#[serde(default)]
|
|
||||||
pub peers: Vec<PeerConfig>,
|
|
||||||
/// Global rooms bridged across federation.
|
|
||||||
#[serde(default)]
|
|
||||||
pub global_rooms: Vec<GlobalRoomConfig>,
|
|
||||||
/// Trusted relay fingerprints — accept inbound federation from these relays.
|
|
||||||
/// Unlike [[peers]], no url is needed — the peer connects to us.
|
|
||||||
#[serde(default)]
|
|
||||||
pub trusted: Vec<TrustedConfig>,
|
|
||||||
/// Debug tap: log packet headers for matching rooms ("*" = all rooms).
|
|
||||||
/// Activated via --debug-tap <room> or debug_tap = "room" in TOML.
|
|
||||||
pub debug_tap: Option<String>,
|
|
||||||
/// JSONL event log path for protocol analysis (--event-log).
|
|
||||||
#[serde(skip)]
|
|
||||||
pub event_log: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RelayConfig {
|
impl Default for RelayConfig {
|
||||||
@@ -111,100 +62,6 @@ impl Default for RelayConfig {
|
|||||||
trunking_enabled: false,
|
trunking_enabled: false,
|
||||||
ws_port: None,
|
ws_port: None,
|
||||||
static_dir: None,
|
static_dir: None,
|
||||||
peers: Vec::new(),
|
|
||||||
global_rooms: Vec::new(),
|
|
||||||
trusted: Vec::new(),
|
|
||||||
debug_tap: None,
|
|
||||||
event_log: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load relay configuration from a TOML file.
|
|
||||||
pub fn load_config(path: &str) -> Result<RelayConfig, anyhow::Error> {
|
|
||||||
let content = std::fs::read_to_string(path)?;
|
|
||||||
let config: RelayConfig = toml::from_str(&content)?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Info about this relay instance, used to generate personalized example configs.
|
|
||||||
pub struct RelayInfo {
|
|
||||||
pub listen_addr: String,
|
|
||||||
pub tls_fingerprint: String,
|
|
||||||
pub public_ip: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load config from path, or create a personalized example config if it doesn't exist.
|
|
||||||
pub fn load_or_create_config(path: &str, info: Option<&RelayInfo>) -> Result<RelayConfig, anyhow::Error> {
|
|
||||||
let p = std::path::Path::new(path);
|
|
||||||
if p.exists() {
|
|
||||||
return load_config(path);
|
|
||||||
}
|
|
||||||
// Create parent directory if needed
|
|
||||||
if let Some(parent) = p.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
// Generate personalized example config
|
|
||||||
let example = generate_example_config(info);
|
|
||||||
std::fs::write(p, &example)?;
|
|
||||||
eprintln!("Created example config at {path} — edit it and restart.");
|
|
||||||
let config: RelayConfig = toml::from_str(&example)?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate an example TOML config, personalized with this relay's info if available.
|
|
||||||
fn generate_example_config(info: Option<&RelayInfo>) -> String {
|
|
||||||
let listen = info.map(|i| i.listen_addr.as_str()).unwrap_or("0.0.0.0:4433");
|
|
||||||
let peer_example = if let Some(i) = info {
|
|
||||||
let ip = i.public_ip.as_deref().unwrap_or("this-relay-ip");
|
|
||||||
format!(
|
|
||||||
r#"# Other relays can peer with this relay using:
|
|
||||||
# [[peers]]
|
|
||||||
# url = "{ip}:{port}"
|
|
||||||
# fingerprint = "{fp}"
|
|
||||||
# label = "This Relay""#,
|
|
||||||
port = listen.rsplit(':').next().unwrap_or("4433"),
|
|
||||||
fp = i.tls_fingerprint,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
"# To peer with another relay, add its url + fingerprint:".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
format!(
|
|
||||||
r#"# WarzonePhone Relay Configuration
|
|
||||||
# See docs/ADMINISTRATION.md for full reference.
|
|
||||||
|
|
||||||
# Listen address for client connections
|
|
||||||
listen_addr = "{listen}"
|
|
||||||
|
|
||||||
# Maximum concurrent sessions
|
|
||||||
# max_sessions = 100
|
|
||||||
|
|
||||||
# Prometheus metrics endpoint (uncomment to enable)
|
|
||||||
# metrics_port = 9090
|
|
||||||
|
|
||||||
# featherChat auth endpoint (uncomment to enable)
|
|
||||||
# auth_url = "https://chat.example.com/v1/auth/validate"
|
|
||||||
|
|
||||||
{peer_example}
|
|
||||||
|
|
||||||
# Federation: peer relays we connect to (outbound)
|
|
||||||
# [[peers]]
|
|
||||||
# url = "other-relay.example.com:4433"
|
|
||||||
# fingerprint = "aa:bb:cc:dd:..."
|
|
||||||
# label = "Relay B"
|
|
||||||
|
|
||||||
# Federation: relays we trust inbound connections from
|
|
||||||
# [[trusted]]
|
|
||||||
# fingerprint = "ee:ff:00:11:..."
|
|
||||||
# label = "Relay X"
|
|
||||||
|
|
||||||
# Global rooms bridged across all federated peers
|
|
||||||
# [[global_rooms]]
|
|
||||||
# name = "general"
|
|
||||||
|
|
||||||
# Debug: log packet headers for a room ("*" for all)
|
|
||||||
# debug_tap = "*"
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
//! JSONL event log for protocol analysis.
|
|
||||||
//!
|
|
||||||
//! When `--event-log <path>` is set, every media packet emits a structured
|
|
||||||
//! event at each decision point (recv, forward, drop, deliver).
|
|
||||||
//! Use `wzp-analyzer` to correlate events across multiple relays.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{error, info};
|
|
||||||
|
|
||||||
/// A single protocol event for JSONL output.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct Event {
|
|
||||||
/// ISO 8601 timestamp with microseconds.
|
|
||||||
pub ts: String,
|
|
||||||
/// Event type.
|
|
||||||
pub event: &'static str,
|
|
||||||
/// Room name.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub room: Option<String>,
|
|
||||||
/// Source address or peer label.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub src: Option<String>,
|
|
||||||
/// Packet sequence number.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub seq: Option<u16>,
|
|
||||||
/// Codec identifier.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub codec: Option<String>,
|
|
||||||
/// FEC block ID.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub fec_block: Option<u8>,
|
|
||||||
/// FEC symbol index.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub fec_sym: Option<u8>,
|
|
||||||
/// Is FEC repair packet.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub repair: Option<bool>,
|
|
||||||
/// Payload length in bytes.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub len: Option<usize>,
|
|
||||||
/// Number of recipients.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub to_count: Option<usize>,
|
|
||||||
/// Peer label (for federation events).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub peer: Option<String>,
|
|
||||||
/// Drop/error reason.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub reason: Option<String>,
|
|
||||||
/// Presence action (active/inactive).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub action: Option<String>,
|
|
||||||
/// Participant count (presence events).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub participants: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Event {
|
|
||||||
fn now() -> String {
|
|
||||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a minimal event with just type and timestamp.
|
|
||||||
pub fn new(event: &'static str) -> Self {
|
|
||||||
Self {
|
|
||||||
ts: Self::now(),
|
|
||||||
event,
|
|
||||||
room: None,
|
|
||||||
src: None,
|
|
||||||
seq: None,
|
|
||||||
codec: None,
|
|
||||||
fec_block: None,
|
|
||||||
fec_sym: None,
|
|
||||||
repair: None,
|
|
||||||
len: None,
|
|
||||||
to_count: None,
|
|
||||||
peer: None,
|
|
||||||
reason: None,
|
|
||||||
action: None,
|
|
||||||
participants: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set room.
|
|
||||||
pub fn room(mut self, room: &str) -> Self { self.room = Some(room.to_string()); self }
|
|
||||||
/// Set source.
|
|
||||||
pub fn src(mut self, src: &str) -> Self { self.src = Some(src.to_string()); self }
|
|
||||||
/// Set packet header fields from a MediaPacket.
|
|
||||||
pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self {
|
|
||||||
self.seq = Some(pkt.header.seq);
|
|
||||||
self.codec = Some(format!("{:?}", pkt.header.codec_id));
|
|
||||||
self.fec_block = Some(pkt.header.fec_block);
|
|
||||||
self.fec_sym = Some(pkt.header.fec_symbol);
|
|
||||||
self.repair = Some(pkt.header.is_repair);
|
|
||||||
self.len = Some(pkt.payload.len());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
/// Set seq only (when full packet not available).
|
|
||||||
pub fn seq(mut self, seq: u16) -> Self { self.seq = Some(seq); self }
|
|
||||||
/// Set payload length.
|
|
||||||
pub fn len(mut self, len: usize) -> Self { self.len = Some(len); self }
|
|
||||||
/// Set recipient count.
|
|
||||||
pub fn to_count(mut self, n: usize) -> Self { self.to_count = Some(n); self }
|
|
||||||
/// Set peer label.
|
|
||||||
pub fn peer(mut self, peer: &str) -> Self { self.peer = Some(peer.to_string()); self }
|
|
||||||
/// Set drop reason.
|
|
||||||
pub fn reason(mut self, reason: &str) -> Self { self.reason = Some(reason.to_string()); self }
|
|
||||||
/// Set presence action.
|
|
||||||
pub fn action(mut self, action: &str) -> Self { self.action = Some(action.to_string()); self }
|
|
||||||
/// Set participant count.
|
|
||||||
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle for emitting events. Cheap to clone.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct EventLog {
|
|
||||||
tx: mpsc::UnboundedSender<Event>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLog {
|
|
||||||
/// Emit an event (non-blocking, drops if channel is full).
|
|
||||||
pub fn emit(&self, event: Event) {
|
|
||||||
let _ = self.tx.send(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No-op event log for when `--event-log` is not set.
|
|
||||||
/// All methods are no-ops that compile to nothing.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct NoopEventLog;
|
|
||||||
|
|
||||||
/// Unified event log handle — either real or no-op.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum EventLogger {
|
|
||||||
Active(EventLog),
|
|
||||||
Noop,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLogger {
|
|
||||||
pub fn emit(&self, event: Event) {
|
|
||||||
if let EventLogger::Active(log) = self {
|
|
||||||
log.emit(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_active(&self) -> bool {
|
|
||||||
matches!(self, EventLogger::Active(_))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the event log writer. Returns an `EventLogger` handle.
|
|
||||||
pub fn start_event_log(path: Option<PathBuf>) -> EventLogger {
|
|
||||||
match path {
|
|
||||||
Some(path) => {
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
tokio::spawn(writer_task(path, rx));
|
|
||||||
info!("event log enabled");
|
|
||||||
EventLogger::Active(EventLog { tx })
|
|
||||||
}
|
|
||||||
None => EventLogger::Noop,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Background task that writes events to a JSONL file.
|
|
||||||
async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
|
|
||||||
let file = match tokio::fs::File::create(&path).await {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
error!("failed to create event log {}: {e}", path.display());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut writer = tokio::io::BufWriter::new(file);
|
|
||||||
let mut count: u64 = 0;
|
|
||||||
|
|
||||||
while let Some(event) = rx.recv().await {
|
|
||||||
match serde_json::to_string(&event) {
|
|
||||||
Ok(json) => {
|
|
||||||
if writer.write_all(json.as_bytes()).await.is_err() { break; }
|
|
||||||
if writer.write_all(b"\n").await.is_err() { break; }
|
|
||||||
count += 1;
|
|
||||||
// Flush every 100 events
|
|
||||||
if count % 100 == 0 {
|
|
||||||
let _ = writer.flush().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("event log serialize error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = writer.flush().await;
|
|
||||||
info!(events = count, "event log closed");
|
|
||||||
}
|
|
||||||
@@ -1,976 +0,0 @@
|
|||||||
//! Relay federation — global room routing between peer relays.
|
|
||||||
//!
|
|
||||||
//! Each relay maintains a forwarding table per global room. When a local participant
|
|
||||||
//! sends media in a global room, it's forwarded to all peer relays that have the room
|
|
||||||
//! active. Incoming federated media is delivered to local participants and optionally
|
|
||||||
//! forwarded to other active peers (multi-hop).
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use bytes::Bytes;
|
|
||||||
use sha2::{Sha256, Digest};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
use wzp_proto::{MediaTransport, SignalMessage};
|
|
||||||
use wzp_transport::QuinnTransport;
|
|
||||||
|
|
||||||
use crate::config::{PeerConfig, TrustedConfig};
|
|
||||||
use crate::event_log::{Event, EventLogger};
|
|
||||||
use crate::room::{self, FederationMediaOut, RoomEvent, RoomManager};
|
|
||||||
|
|
||||||
/// Compute 8-byte room hash for federation datagram tagging.
|
|
||||||
pub fn room_hash(room_name: &str) -> [u8; 8] {
|
|
||||||
let h = Sha256::digest(room_name.as_bytes());
|
|
||||||
let mut out = [0u8; 8];
|
|
||||||
out.copy_from_slice(&h[..8]);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize a fingerprint string (remove colons, lowercase).
|
|
||||||
fn normalize_fp(fp: &str) -> String {
|
|
||||||
fp.replace(':', "").to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Time-based dedup filter for federation datagrams.
|
|
||||||
/// Tracks recently seen packets and expires entries older than 2 seconds.
|
|
||||||
/// This prevents duplicate delivery when the same packet arrives via
|
|
||||||
/// multiple federation paths, while allowing new senders that happen to
|
|
||||||
/// reuse the same seq numbers.
|
|
||||||
struct Deduplicator {
|
|
||||||
/// Recently seen packet keys with insertion time.
|
|
||||||
entries: HashMap<u64, Instant>,
|
|
||||||
/// Expiry duration.
|
|
||||||
ttl: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deduplicator {
|
|
||||||
fn new(_capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
entries: HashMap::with_capacity(512),
|
|
||||||
ttl: Duration::from_secs(2),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if this packet is a duplicate (already seen within TTL).
|
|
||||||
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u16, extra: u64) -> bool {
|
|
||||||
let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra;
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
// Periodic cleanup (every ~256 packets)
|
|
||||||
if self.entries.len() > 256 {
|
|
||||||
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ts) = self.entries.get(&key) {
|
|
||||||
if now.duration_since(*ts) < self.ttl {
|
|
||||||
return true; // seen recently — duplicate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.entries.insert(key, now);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-room token bucket rate limiter for federation forwarding.
|
|
||||||
struct RateLimiter {
|
|
||||||
/// Max packets per second per room.
|
|
||||||
max_pps: u32,
|
|
||||||
/// Tokens remaining in current window.
|
|
||||||
tokens: u32,
|
|
||||||
/// When the current window started.
|
|
||||||
window_start: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RateLimiter {
|
|
||||||
fn new(max_pps: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
max_pps,
|
|
||||||
tokens: max_pps,
|
|
||||||
window_start: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the packet should be allowed through.
|
|
||||||
fn allow(&mut self) -> bool {
|
|
||||||
let elapsed = self.window_start.elapsed();
|
|
||||||
if elapsed >= Duration::from_secs(1) {
|
|
||||||
self.tokens = self.max_pps;
|
|
||||||
self.window_start = Instant::now();
|
|
||||||
}
|
|
||||||
if self.tokens > 0 {
|
|
||||||
self.tokens -= 1;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active link to a peer relay.
|
|
||||||
struct PeerLink {
|
|
||||||
transport: Arc<QuinnTransport>,
|
|
||||||
label: String,
|
|
||||||
/// Global rooms that this peer has reported as active.
|
|
||||||
active_rooms: HashSet<String>,
|
|
||||||
/// Remote participants per room (for federated presence in RoomUpdate).
|
|
||||||
remote_participants: HashMap<String, Vec<wzp_proto::packet::RoomParticipant>>,
|
|
||||||
/// Last time we received any data (signal or media) from this peer.
|
|
||||||
last_seen: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Max federation packets per second per room (0 = unlimited).
|
|
||||||
const FEDERATION_RATE_LIMIT_PPS: u32 = 500;
|
|
||||||
/// Dedup window size (number of recent packets to remember).
|
|
||||||
const DEDUP_WINDOW_SIZE: usize = 4096;
|
|
||||||
/// Remote participants are considered stale after this duration with no updates.
|
|
||||||
const REMOTE_PARTICIPANT_STALE_SECS: u64 = 15;
|
|
||||||
|
|
||||||
/// Manages federation connections and global room forwarding.
|
|
||||||
pub struct FederationManager {
|
|
||||||
peers: Vec<PeerConfig>,
|
|
||||||
trusted: Vec<TrustedConfig>,
|
|
||||||
global_rooms: HashSet<String>,
|
|
||||||
room_mgr: Arc<Mutex<RoomManager>>,
|
|
||||||
endpoint: quinn::Endpoint,
|
|
||||||
local_tls_fp: String,
|
|
||||||
metrics: Arc<crate::metrics::RelayMetrics>,
|
|
||||||
/// Active peer connections, keyed by normalized fingerprint.
|
|
||||||
peer_links: Arc<Mutex<HashMap<String, PeerLink>>>,
|
|
||||||
/// Dedup filter for incoming federation datagrams.
|
|
||||||
dedup: Mutex<Deduplicator>,
|
|
||||||
/// JSONL event log for protocol analysis.
|
|
||||||
event_log: EventLogger,
|
|
||||||
/// Per-room rate limiters for inbound federation media.
|
|
||||||
rate_limiters: Mutex<HashMap<String, RateLimiter>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FederationManager {
|
|
||||||
pub fn new(
|
|
||||||
peers: Vec<PeerConfig>,
|
|
||||||
trusted: Vec<TrustedConfig>,
|
|
||||||
global_rooms: HashSet<String>,
|
|
||||||
room_mgr: Arc<Mutex<RoomManager>>,
|
|
||||||
endpoint: quinn::Endpoint,
|
|
||||||
local_tls_fp: String,
|
|
||||||
metrics: Arc<crate::metrics::RelayMetrics>,
|
|
||||||
event_log: EventLogger,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
peers,
|
|
||||||
trusted,
|
|
||||||
global_rooms,
|
|
||||||
room_mgr,
|
|
||||||
endpoint,
|
|
||||||
local_tls_fp,
|
|
||||||
metrics,
|
|
||||||
peer_links: Arc::new(Mutex::new(HashMap::new())),
|
|
||||||
dedup: Mutex::new(Deduplicator::new(DEDUP_WINDOW_SIZE)),
|
|
||||||
event_log,
|
|
||||||
rate_limiters: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a room name (which may be hashed) is a global room.
|
|
||||||
pub fn is_global_room(&self, room: &str) -> bool {
|
|
||||||
self.resolve_global_room(room).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a room name (raw or hashed) to the canonical global room name.
|
|
||||||
/// Returns the configured global room name if it matches.
|
|
||||||
pub fn resolve_global_room(&self, room: &str) -> Option<&str> {
|
|
||||||
// Direct match (raw room name, e.g. Android clients)
|
|
||||||
if self.global_rooms.contains(room) {
|
|
||||||
return Some(self.global_rooms.iter().find(|n| n.as_str() == room).unwrap());
|
|
||||||
}
|
|
||||||
// Hashed match (desktop clients hash room names for SNI privacy)
|
|
||||||
self.global_rooms.iter().find(|name| {
|
|
||||||
wzp_crypto::hash_room_name(name) == room
|
|
||||||
}).map(|s| s.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the canonical federation room hash for a room.
|
|
||||||
/// Always uses the configured global room name, not the client-provided name.
|
|
||||||
pub fn global_room_hash(&self, room: &str) -> [u8; 8] {
|
|
||||||
if let Some(canonical) = self.resolve_global_room(room) {
|
|
||||||
room_hash(canonical)
|
|
||||||
} else {
|
|
||||||
room_hash(room)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start federation — spawns connection loops + event dispatcher.
|
|
||||||
pub async fn run(self: Arc<Self>) {
|
|
||||||
if self.peers.is_empty() && self.global_rooms.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
peers = self.peers.len(),
|
|
||||||
global_rooms = self.global_rooms.len(),
|
|
||||||
"federation starting"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
|
|
||||||
// Per-peer outbound connection loops
|
|
||||||
for peer in &self.peers {
|
|
||||||
let this = self.clone();
|
|
||||||
let peer = peer.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
run_peer_loop(this, peer).await;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Room event dispatcher
|
|
||||||
let room_events = {
|
|
||||||
let mgr = self.room_mgr.lock().await;
|
|
||||||
mgr.subscribe_events()
|
|
||||||
};
|
|
||||||
let this = self.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
run_room_event_dispatcher(this, room_events).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Stale presence sweeper — purges remote participants from dead peers
|
|
||||||
let this = self.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
run_stale_presence_sweeper(this).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
for h in handles {
|
|
||||||
let _ = h.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an inbound federation connection from a recognized peer.
|
|
||||||
pub async fn handle_inbound(
|
|
||||||
self: &Arc<Self>,
|
|
||||||
transport: Arc<QuinnTransport>,
|
|
||||||
peer_config: PeerConfig,
|
|
||||||
) {
|
|
||||||
let peer_fp = normalize_fp(&peer_config.fingerprint);
|
|
||||||
let label = peer_config.label.unwrap_or_else(|| peer_config.url.clone());
|
|
||||||
info!(peer = %label, "inbound federation link active");
|
|
||||||
if let Err(e) = run_federation_link(self.clone(), transport, peer_fp, label.clone()).await {
|
|
||||||
warn!(peer = %label, "inbound federation link ended: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all remote participants for a room from all peer links.
|
|
||||||
/// Deduplicates by fingerprint (same participant may appear via multiple links).
|
|
||||||
pub async fn get_remote_participants(&self, room: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
|
||||||
let canonical = self.resolve_global_room(room);
|
|
||||||
let links = self.peer_links.lock().await;
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for link in links.values() {
|
|
||||||
// Check canonical name
|
|
||||||
if let Some(c) = canonical {
|
|
||||||
if let Some(remote) = link.remote_participants.get(c) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
// Also check raw room name, but only if different from canonical
|
|
||||||
if c != room {
|
|
||||||
if let Some(remote) = link.remote_participants.get(room) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let Some(remote) = link.remote_participants.get(room) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Deduplicate by fingerprint
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forward locally-generated media to all connected peers.
|
|
||||||
/// For locally-originated media, we send to ALL peers (they decide whether to deliver).
|
|
||||||
/// For forwarded media (multi-hop), handle_datagram filters by active_rooms.
|
|
||||||
///
|
|
||||||
/// `_room_name` is kept in the signature for caller-site symmetry with
|
|
||||||
/// the other room-tagged helpers and for future per-room-name logging
|
|
||||||
/// or rate limiting; the body currently forwards on `room_hash` alone
|
|
||||||
/// because that's what the wire format carries.
|
|
||||||
pub async fn forward_to_peers(&self, _room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) {
|
|
||||||
let links = self.peer_links.lock().await;
|
|
||||||
if links.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (_fp, link) in links.iter() {
|
|
||||||
let mut tagged = Vec::with_capacity(8 + media_data.len());
|
|
||||||
tagged.extend_from_slice(room_hash);
|
|
||||||
tagged.extend_from_slice(media_data);
|
|
||||||
match link.transport.send_raw_datagram(&tagged) {
|
|
||||||
Ok(()) => {
|
|
||||||
self.metrics.federation_packets_forwarded
|
|
||||||
.with_label_values(&[&link.label, "out"]).inc();
|
|
||||||
}
|
|
||||||
Err(e) => warn!(peer = %link.label, "federation send error: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Trust verification (kept from previous implementation) ──
|
|
||||||
|
|
||||||
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
|
|
||||||
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> {
|
|
||||||
let addr_ip = addr.ip();
|
|
||||||
self.peers.iter().find(|p| {
|
|
||||||
p.url.parse::<SocketAddr>()
|
|
||||||
.map(|sa| sa.ip() == addr_ip)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> {
|
|
||||||
self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> {
|
|
||||||
if let Some(peer) = self.find_peer_by_addr(addr) {
|
|
||||||
return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone()));
|
|
||||||
}
|
|
||||||
if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) {
|
|
||||||
return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string()));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Outbound media egress task ──
|
|
||||||
|
|
||||||
/// Drains the federation media channel and forwards to active peers.
|
|
||||||
pub async fn run_federation_media_egress(
|
|
||||||
fm: Arc<FederationManager>,
|
|
||||||
mut rx: tokio::sync::mpsc::Receiver<FederationMediaOut>,
|
|
||||||
) {
|
|
||||||
let mut count: u64 = 0;
|
|
||||||
while let Some(out) = rx.recv().await {
|
|
||||||
count += 1;
|
|
||||||
if count == 1 || count % 250 == 0 {
|
|
||||||
info!(room = %out.room_name, count, "federation egress: forwarding media");
|
|
||||||
}
|
|
||||||
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data).await;
|
|
||||||
}
|
|
||||||
info!(total = count, "federation egress task ended");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Room event dispatcher ──
|
|
||||||
|
|
||||||
/// Watches RoomManager events and sends GlobalRoomActive/Inactive to peers.
|
|
||||||
async fn run_room_event_dispatcher(
|
|
||||||
fm: Arc<FederationManager>,
|
|
||||||
mut events: tokio::sync::broadcast::Receiver<RoomEvent>,
|
|
||||||
) {
|
|
||||||
loop {
|
|
||||||
match events.recv().await {
|
|
||||||
Ok(RoomEvent::LocalJoin { room }) => {
|
|
||||||
if fm.is_global_room(&room) {
|
|
||||||
let participants = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
mgr.local_participant_list(&room)
|
|
||||||
};
|
|
||||||
info!(room = %room, count = participants.len(), "global room now active, announcing to peers");
|
|
||||||
let msg = SignalMessage::GlobalRoomActive { room, participants };
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for link in links.values() {
|
|
||||||
let _ = link.transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(RoomEvent::LocalLeave { room }) => {
|
|
||||||
if fm.is_global_room(&room) {
|
|
||||||
info!(room = %room, "global room now inactive, announcing to peers");
|
|
||||||
let msg = SignalMessage::GlobalRoomInactive { room };
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for link in links.values() {
|
|
||||||
let _ = link.transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
warn!(missed = n, "room event receiver lagged");
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stale presence sweeper ──
|
|
||||||
|
|
||||||
/// Periodically checks for stale remote participants and purges them.
|
|
||||||
/// This handles the case where a peer link dies without sending GlobalRoomInactive
|
|
||||||
/// (e.g., QUIC timeout, network partition, crash).
|
|
||||||
async fn run_stale_presence_sweeper(fm: Arc<FederationManager>) {
|
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
let stale_threshold = Duration::from_secs(REMOTE_PARTICIPANT_STALE_SECS);
|
|
||||||
|
|
||||||
// Find peers with stale remote_participants whose link is also gone or idle
|
|
||||||
let stale_rooms: Vec<(String, String)> = {
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
let mut stale = Vec::new();
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if link.last_seen.elapsed() > stale_threshold && !link.remote_participants.is_empty() {
|
|
||||||
for room in link.remote_participants.keys() {
|
|
||||||
stale.push((fp.clone(), room.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stale
|
|
||||||
};
|
|
||||||
|
|
||||||
if stale_rooms.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purge stale entries and collect affected rooms
|
|
||||||
let mut affected_rooms = HashSet::new();
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
for (fp, room) in &stale_rooms {
|
|
||||||
if let Some(link) = links.get_mut(fp.as_str()) {
|
|
||||||
if link.last_seen.elapsed() > stale_threshold {
|
|
||||||
info!(peer = %link.label, room = %room, "purging stale remote participants (no data for {}s)", link.last_seen.elapsed().as_secs());
|
|
||||||
link.remote_participants.remove(room);
|
|
||||||
link.active_rooms.remove(room);
|
|
||||||
affected_rooms.insert(room.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast updated RoomUpdate for affected rooms
|
|
||||||
for room in &affected_rooms {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(room) {
|
|
||||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
|
||||||
let remote = fm.get_remote_participants(&local_room).await;
|
|
||||||
all_participants.extend(remote);
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
let update = SignalMessage::RoomUpdate {
|
|
||||||
count: all_participants.len() as u32,
|
|
||||||
participants: all_participants,
|
|
||||||
};
|
|
||||||
let senders = mgr.local_senders(&local_room);
|
|
||||||
drop(mgr);
|
|
||||||
room::broadcast_signal(&senders, &update).await;
|
|
||||||
info!(room = %room, "swept stale presence — broadcast updated RoomUpdate");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Peer connection management ──
|
|
||||||
|
|
||||||
/// Persistent connection loop for one peer — reconnects with backoff.
|
|
||||||
async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
|
|
||||||
let mut backoff = Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connecting to peer...");
|
|
||||||
match connect_to_peer(&fm, &peer).await {
|
|
||||||
Ok(transport) => {
|
|
||||||
backoff = Duration::from_secs(5);
|
|
||||||
let peer_fp = normalize_fp(&peer.fingerprint);
|
|
||||||
let label = peer.label.clone().unwrap_or_else(|| peer.url.clone());
|
|
||||||
if let Err(e) = run_federation_link(fm.clone(), transport, peer_fp, label).await {
|
|
||||||
warn!(peer_url = %peer.url, "federation link ended: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(peer_url = %peer.url, backoff_s = backoff.as_secs(), "federation connect failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(backoff).await;
|
|
||||||
backoff = (backoff * 2).min(Duration::from_secs(300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to a peer relay and send hello.
|
|
||||||
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> {
|
|
||||||
let addr: SocketAddr = peer.url.parse()?;
|
|
||||||
let client_cfg = wzp_transport::client_config();
|
|
||||||
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
|
|
||||||
let transport = Arc::new(QuinnTransport::new(conn));
|
|
||||||
|
|
||||||
// Send hello with our TLS fingerprint
|
|
||||||
let hello = SignalMessage::FederationHello {
|
|
||||||
tls_fingerprint: fm.local_tls_fp.clone(),
|
|
||||||
};
|
|
||||||
transport.send_signal(&hello).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
|
|
||||||
|
|
||||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)");
|
|
||||||
Ok(transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Federation link (runs on a single QUIC connection) ──
|
|
||||||
|
|
||||||
/// Run the federation link: exchange global room state and forward media.
|
|
||||||
async fn run_federation_link(
|
|
||||||
fm: Arc<FederationManager>,
|
|
||||||
transport: Arc<QuinnTransport>,
|
|
||||||
peer_fp: String,
|
|
||||||
peer_label: String,
|
|
||||||
) -> Result<(), anyhow::Error> {
|
|
||||||
// Register peer link + metrics
|
|
||||||
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(1);
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
links.insert(peer_fp.clone(), PeerLink {
|
|
||||||
transport: transport.clone(),
|
|
||||||
label: peer_label.clone(),
|
|
||||||
active_rooms: HashSet::new(),
|
|
||||||
remote_participants: HashMap::new(),
|
|
||||||
last_seen: Instant::now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Announce our currently active global rooms to this new peer
|
|
||||||
// Collect all announcements first, then send (avoid holding locks across await)
|
|
||||||
let announcements = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
let active = mgr.active_rooms();
|
|
||||||
let mut msgs = Vec::new();
|
|
||||||
|
|
||||||
// Local rooms
|
|
||||||
for room_name in &active {
|
|
||||||
if fm.is_global_room(room_name) {
|
|
||||||
let participants = mgr.local_participant_list(room_name);
|
|
||||||
info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer");
|
|
||||||
msgs.push(SignalMessage::GlobalRoomActive { room: room_name.clone(), participants });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote rooms from OTHER peers (for multi-hop propagation)
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp != &peer_fp {
|
|
||||||
for (room, participants) in &link.remote_participants {
|
|
||||||
if fm.is_global_room(room) {
|
|
||||||
info!(peer = %peer_label, room = %room, via = %link.label, "propagating remote room to new peer");
|
|
||||||
msgs.push(SignalMessage::GlobalRoomActive {
|
|
||||||
room: room.clone(),
|
|
||||||
participants: participants.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msgs
|
|
||||||
};
|
|
||||||
for msg in &announcements {
|
|
||||||
let _ = transport.send_signal(msg).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Three concurrent tasks: signal recv + media recv + RTT monitor
|
|
||||||
let signal_transport = transport.clone();
|
|
||||||
let media_transport = transport.clone();
|
|
||||||
let rtt_transport = transport.clone();
|
|
||||||
let fm_signal = fm.clone();
|
|
||||||
let fm_media = fm.clone();
|
|
||||||
let fm_rtt = fm.clone();
|
|
||||||
let peer_fp_signal = peer_fp.clone();
|
|
||||||
let peer_fp_media = peer_fp.clone();
|
|
||||||
let label_signal = peer_label.clone();
|
|
||||||
let label_rtt = peer_label.clone();
|
|
||||||
|
|
||||||
let signal_task = async move {
|
|
||||||
loop {
|
|
||||||
match signal_transport.recv_signal().await {
|
|
||||||
Ok(Some(msg)) => {
|
|
||||||
handle_signal(&fm_signal, &peer_fp_signal, &label_signal, msg).await;
|
|
||||||
}
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(e) => {
|
|
||||||
error!(peer = %label_signal, "federation signal error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let peer_label_media = peer_label.clone();
|
|
||||||
let media_task = async move {
|
|
||||||
let mut media_count: u64 = 0;
|
|
||||||
loop {
|
|
||||||
match media_transport.connection().read_datagram().await {
|
|
||||||
Ok(data) => {
|
|
||||||
media_count += 1;
|
|
||||||
if media_count == 1 || media_count % 250 == 0 {
|
|
||||||
info!(peer = %peer_label_media, media_count, len = data.len(), "federation: received datagram");
|
|
||||||
}
|
|
||||||
handle_datagram(&fm_media, &peer_fp_media, data).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
info!(peer = %peer_label_media, "federation media task ended: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// RTT monitor: periodically sample QUIC RTT for this peer and push it
|
|
||||||
// into the `wzp_federation_peer_rtt_ms` gauge. The gauge is registered
|
|
||||||
// in metrics.rs but previously never received any samples — the task
|
|
||||||
// computed rtt_ms and dropped it on the floor, leaving the Grafana
|
|
||||||
// panel blank. Fixed as part of the workspace warning sweep.
|
|
||||||
let rtt_task = async move {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
||||||
let rtt_ms = rtt_transport.connection().stats().path.rtt.as_millis() as f64;
|
|
||||||
fm_rtt
|
|
||||||
.metrics
|
|
||||||
.federation_peer_rtt_ms
|
|
||||||
.with_label_values(&[&label_rtt])
|
|
||||||
.set(rtt_ms);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = signal_task => {}
|
|
||||||
_ = media_task => {}
|
|
||||||
_ = rtt_task => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup: remove peer link + metrics
|
|
||||||
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(0);
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
links.remove(&peer_fp);
|
|
||||||
}
|
|
||||||
info!(peer = %peer_label, "federation link ended");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an incoming federation signal.
|
|
||||||
async fn handle_signal(
|
|
||||||
fm: &Arc<FederationManager>,
|
|
||||||
peer_fp: &str,
|
|
||||||
peer_label: &str,
|
|
||||||
msg: SignalMessage,
|
|
||||||
) {
|
|
||||||
// Update last_seen for this peer
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
link.last_seen = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
SignalMessage::GlobalRoomActive { room, participants } => {
|
|
||||||
if fm.is_global_room(&room) {
|
|
||||||
info!(peer = %peer_label, room = %room, remote_participants = participants.len(), "peer has global room active");
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
link.active_rooms.insert(room.clone());
|
|
||||||
}
|
|
||||||
// Update active rooms metric
|
|
||||||
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
|
||||||
fm.metrics.federation_active_rooms.set(total as i64);
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
// Tag remote participants with their relay label
|
|
||||||
let tagged: Vec<_> = participants.iter().map(|p| {
|
|
||||||
let mut tagged = p.clone();
|
|
||||||
if tagged.relay_label.is_none() {
|
|
||||||
tagged.relay_label = Some(link.label.clone());
|
|
||||||
}
|
|
||||||
tagged
|
|
||||||
}).collect();
|
|
||||||
link.remote_participants.insert(room.clone(), tagged);
|
|
||||||
}
|
|
||||||
// Propagate to other peers (with relay labels preserved)
|
|
||||||
let tagged_for_propagation = if let Some(link) = links.get(peer_fp) {
|
|
||||||
let label = link.label.clone();
|
|
||||||
participants.iter().map(|p| {
|
|
||||||
let mut t = p.clone();
|
|
||||||
if t.relay_label.is_none() {
|
|
||||||
t.relay_label = Some(label.clone());
|
|
||||||
}
|
|
||||||
t
|
|
||||||
}).collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
participants.clone()
|
|
||||||
};
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp != peer_fp {
|
|
||||||
let _ = link.transport.send_signal(&SignalMessage::GlobalRoomActive {
|
|
||||||
room: room.clone(),
|
|
||||||
participants: tagged_for_propagation.clone(),
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(links);
|
|
||||||
|
|
||||||
// Broadcast updated RoomUpdate to local clients in this room
|
|
||||||
// Find the local room name (may be hashed or raw)
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
|
||||||
// Build merged participant list: local + all remote (deduped)
|
|
||||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for link in links.values() {
|
|
||||||
if let Some(canonical) = fm.resolve_global_room(&local_room) {
|
|
||||||
if let Some(remote) = link.remote_participants.get(canonical) {
|
|
||||||
all_participants.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
// Also check raw room name, but only if different from canonical
|
|
||||||
if canonical != local_room {
|
|
||||||
if let Some(remote) = link.remote_participants.get(&local_room) {
|
|
||||||
all_participants.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Deduplicate by fingerprint
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
let update = SignalMessage::RoomUpdate {
|
|
||||||
count: all_participants.len() as u32,
|
|
||||||
participants: all_participants,
|
|
||||||
};
|
|
||||||
let senders = mgr.local_senders(&local_room);
|
|
||||||
drop(links);
|
|
||||||
drop(mgr);
|
|
||||||
room::broadcast_signal(&senders, &update).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SignalMessage::GlobalRoomInactive { room } => {
|
|
||||||
info!(peer = %peer_label, room = %room, "peer global room now inactive");
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(peer_fp) {
|
|
||||||
link.active_rooms.remove(&room);
|
|
||||||
// Clear remote participants for this peer+room
|
|
||||||
link.remote_participants.remove(&room);
|
|
||||||
// Also try canonical name
|
|
||||||
if let Some(canonical) = fm.resolve_global_room(&room) {
|
|
||||||
link.remote_participants.remove(canonical);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update active rooms metric
|
|
||||||
let total: usize = links.values().map(|l| l.active_rooms.len()).sum();
|
|
||||||
fm.metrics.federation_active_rooms.set(total as i64);
|
|
||||||
|
|
||||||
// Build remaining remote participants (from all peers except the one going inactive)
|
|
||||||
let remaining_remote: Vec<wzp_proto::packet::RoomParticipant> = {
|
|
||||||
let canonical = fm.resolve_global_room(&room);
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp == peer_fp { continue; }
|
|
||||||
if let Some(c) = canonical {
|
|
||||||
if let Some(remote) = link.remote_participants.get(c) {
|
|
||||||
result.extend(remote.iter().cloned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
result.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
result
|
|
||||||
};
|
|
||||||
|
|
||||||
// Propagate to other peers: send updated GlobalRoomActive with revised list,
|
|
||||||
// or GlobalRoomInactive if no participants remain anywhere
|
|
||||||
let local_active = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
mgr.active_rooms().iter().any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room))
|
|
||||||
};
|
|
||||||
let has_remaining = !remaining_remote.is_empty() || local_active;
|
|
||||||
|
|
||||||
// Collect peer transports to send to (avoid holding lock across await)
|
|
||||||
let peer_sends: Vec<_> = links.iter()
|
|
||||||
.filter(|(fp, _)| *fp != peer_fp)
|
|
||||||
.map(|(_, link)| link.transport.clone())
|
|
||||||
.collect();
|
|
||||||
drop(links);
|
|
||||||
|
|
||||||
if has_remaining {
|
|
||||||
// Send updated participant list to other peers
|
|
||||||
let mut updated_participants = remaining_remote.clone();
|
|
||||||
if local_active {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
|
||||||
updated_participants.extend(mgr.local_participant_list(&local_room));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let msg = SignalMessage::GlobalRoomActive {
|
|
||||||
room: room.clone(),
|
|
||||||
participants: updated_participants,
|
|
||||||
};
|
|
||||||
for transport in &peer_sends {
|
|
||||||
let _ = transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No participants left anywhere — propagate inactive
|
|
||||||
let msg = SignalMessage::GlobalRoomInactive { room: room.clone() };
|
|
||||||
for transport in &peer_sends {
|
|
||||||
let _ = transport.send_signal(&msg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast updated RoomUpdate to local clients (remote participant removed)
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
for local_room in mgr.active_rooms() {
|
|
||||||
if fm.is_global_room(&local_room) && fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
|
|
||||||
let mut all_participants = mgr.local_participant_list(&local_room);
|
|
||||||
all_participants.extend(remaining_remote.iter().cloned());
|
|
||||||
// Deduplicate by fingerprint
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
all_participants.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
let update = SignalMessage::RoomUpdate {
|
|
||||||
count: all_participants.len() as u32,
|
|
||||||
participants: all_participants,
|
|
||||||
};
|
|
||||||
let senders = mgr.local_senders(&local_room);
|
|
||||||
drop(mgr);
|
|
||||||
room::broadcast_signal(&senders, &update).await;
|
|
||||||
info!(room = %room, "broadcast updated presence (remote participant removed)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // ignore other signals
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an incoming federation datagram (room-hash-tagged media).
|
|
||||||
async fn handle_datagram(
|
|
||||||
fm: &Arc<FederationManager>,
|
|
||||||
source_peer_fp: &str,
|
|
||||||
data: Bytes,
|
|
||||||
) {
|
|
||||||
if data.len() < 12 { return; } // 8-byte hash + min packet
|
|
||||||
|
|
||||||
let mut rh = [0u8; 8];
|
|
||||||
rh.copy_from_slice(&data[..8]);
|
|
||||||
let media_bytes = data.slice(8..);
|
|
||||||
|
|
||||||
let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) {
|
|
||||||
Some(pkt) => pkt,
|
|
||||||
None => {
|
|
||||||
fm.event_log.emit(Event::new("federation_ingress_malformed").len(data.len()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event log: federation ingress
|
|
||||||
let peer_label = {
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
links.get(source_peer_fp).map(|l| l.label.clone()).unwrap_or_default()
|
|
||||||
};
|
|
||||||
fm.event_log.emit(Event::new("federation_ingress").packet(&pkt).peer(&peer_label));
|
|
||||||
|
|
||||||
// Count inbound federation packet + update last_seen
|
|
||||||
fm.metrics.federation_packets_forwarded
|
|
||||||
.with_label_values(&[source_peer_fp, "in"]).inc();
|
|
||||||
{
|
|
||||||
let mut links = fm.peer_links.lock().await;
|
|
||||||
if let Some(link) = links.get_mut(source_peer_fp) {
|
|
||||||
link.last_seen = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedup: drop packets we've already seen (multi-path duplicates).
|
|
||||||
// Key uses a hash of the actual payload bytes — unique per Opus frame,
|
|
||||||
// so different senders with the same seq/timestamp never collide.
|
|
||||||
let payload_hash = {
|
|
||||||
let mut h = 0u64;
|
|
||||||
for (i, &b) in media_bytes.iter().take(16).enumerate() {
|
|
||||||
h ^= (b as u64) << ((i % 8) * 8);
|
|
||||||
}
|
|
||||||
h
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let mut dedup = fm.dedup.lock().await;
|
|
||||||
if dedup.is_dup(&rh, pkt.header.seq, payload_hash) {
|
|
||||||
fm.event_log.emit(Event::new("dedup_drop").seq(pkt.header.seq).peer(&peer_label));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find room by hash — check local rooms AND global room config
|
|
||||||
let room_name = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
let active = mgr.active_rooms();
|
|
||||||
// First: check local rooms (has participants)
|
|
||||||
active.iter().find(|r| room_hash(r) == rh).cloned()
|
|
||||||
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned())
|
|
||||||
// Second: check global room config (hub relay may have no local participants)
|
|
||||||
.or_else(|| {
|
|
||||||
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned()
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let room_name = match room_name {
|
|
||||||
Some(r) => r,
|
|
||||||
None => {
|
|
||||||
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rate limit per room
|
|
||||||
if FEDERATION_RATE_LIMIT_PPS > 0 {
|
|
||||||
let mut limiters = fm.rate_limiters.lock().await;
|
|
||||||
let limiter = limiters.entry(room_name.clone())
|
|
||||||
.or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS));
|
|
||||||
if !limiter.allow() {
|
|
||||||
fm.event_log.emit(Event::new("rate_limit_drop").room(&room_name).seq(pkt.header.seq));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliver to all local participants — forward the raw bytes as-is.
|
|
||||||
// The original sender's MediaPacket is preserved exactly (no re-serialization).
|
|
||||||
let locals = {
|
|
||||||
let mgr = fm.room_mgr.lock().await;
|
|
||||||
mgr.local_senders(&room_name)
|
|
||||||
};
|
|
||||||
for sender in &locals {
|
|
||||||
match sender {
|
|
||||||
room::ParticipantSender::Quic(t) => {
|
|
||||||
if let Err(e) = t.send_raw_datagram(&media_bytes) {
|
|
||||||
fm.event_log.emit(Event::new("local_deliver_error").room(&room_name).seq(pkt.header.seq).reason(&e.to_string()));
|
|
||||||
warn!("federation local delivery error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room::ParticipantSender::WebSocket(_) => { let _ = sender.send_raw(&pkt.payload).await; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fm.event_log.emit(Event::new("local_deliver").room(&room_name).seq(pkt.header.seq).to_count(locals.len()));
|
|
||||||
|
|
||||||
// Multi-hop: forward to ALL other connected peers (not the source)
|
|
||||||
// Don't filter by active_rooms — the receiving peer decides whether to deliver
|
|
||||||
let links = fm.peer_links.lock().await;
|
|
||||||
for (fp, link) in links.iter() {
|
|
||||||
if fp != source_peer_fp {
|
|
||||||
let mut tagged = Vec::with_capacity(8 + media_bytes.len());
|
|
||||||
tagged.extend_from_slice(&rh);
|
|
||||||
tagged.extend_from_slice(&media_bytes);
|
|
||||||
let _ = link.transport.send_raw_datagram(&tagged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -78,30 +78,31 @@ pub async fn accept_handshake(
|
|||||||
};
|
};
|
||||||
transport.send_signal(&answer).await?;
|
transport.send_signal(&answer).await?;
|
||||||
|
|
||||||
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:...
|
// Derive caller fingerprint from their identity public key (first 8 bytes as hex)
|
||||||
// Must match the format used in signal registration and presence.
|
let caller_fp = caller_identity_pub[..8]
|
||||||
let caller_fp = {
|
.iter()
|
||||||
use sha2::{Sha256, Digest};
|
.map(|b| format!("{b:02x}"))
|
||||||
let hash = Sha256::digest(&caller_identity_pub);
|
.collect::<String>();
|
||||||
let fp = wzp_crypto::Fingerprint([
|
|
||||||
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
|
|
||||||
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
|
||||||
]);
|
|
||||||
fp.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((session, chosen_profile, caller_fp, caller_alias))
|
Ok((session, chosen_profile, caller_fp, caller_alias))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select the best quality profile from those the caller supports.
|
/// Select the best quality profile from those the caller supports.
|
||||||
///
|
fn choose_profile(supported: &[QualityProfile]) -> QualityProfile {
|
||||||
/// The `_supported` list is currently ignored — we hardcode GOOD (24k) until
|
// Prefer higher-quality profiles. Use GOOD as default if supported list is empty.
|
||||||
/// studio tiers (32k/48k/64k) have been validated across federation (large
|
if supported.is_empty() {
|
||||||
/// packets may exceed path MTU and fragment in unpleasant ways). Once that's
|
return QualityProfile::GOOD;
|
||||||
/// tested, the body should pick the highest supported profile ≤ the relay's
|
}
|
||||||
/// configured ceiling.
|
// Pick the profile with the highest bitrate.
|
||||||
fn choose_profile(_supported: &[QualityProfile]) -> QualityProfile {
|
supported
|
||||||
QualityProfile::GOOD
|
.iter()
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.total_bitrate_kbps()
|
||||||
|
.partial_cmp(&b.total_bitrate_kbps())
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(QualityProfile::GOOD)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -8,11 +8,7 @@
|
|||||||
//! quality transitions.
|
//! quality transitions.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod call_registry;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod event_log;
|
|
||||||
pub mod federation;
|
|
||||||
pub mod signal_hub;
|
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use wzp_proto::{MediaTransport, SignalMessage};
|
use wzp_proto::MediaTransport;
|
||||||
use wzp_relay::config::RelayConfig;
|
use wzp_relay::config::RelayConfig;
|
||||||
use wzp_relay::metrics::RelayMetrics;
|
use wzp_relay::metrics::RelayMetrics;
|
||||||
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
use wzp_relay::pipeline::{PipelineConfig, RelayPipeline};
|
||||||
@@ -23,54 +23,12 @@ use wzp_relay::presence::PresenceRegistry;
|
|||||||
use wzp_relay::room::{self, RoomManager};
|
use wzp_relay::room::{self, RoomManager};
|
||||||
use wzp_relay::session_mgr::SessionManager;
|
use wzp_relay::session_mgr::SessionManager;
|
||||||
|
|
||||||
/// Parsed CLI result — config + identity path.
|
fn parse_args() -> RelayConfig {
|
||||||
struct CliResult {
|
let mut config = RelayConfig::default();
|
||||||
config: RelayConfig,
|
|
||||||
identity_path: Option<String>,
|
|
||||||
config_file: Option<String>,
|
|
||||||
config_needs_create: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_args() -> CliResult {
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
// First pass: extract --config and --identity
|
|
||||||
let mut config_file = None;
|
|
||||||
let mut identity_path = None;
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
match args[i].as_str() {
|
match args[i].as_str() {
|
||||||
"--config" | "-c" => { i += 1; config_file = args.get(i).cloned(); }
|
|
||||||
"--identity" | "-i" => { i += 1; identity_path = args.get(i).cloned(); }
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track if we need to create the config after identity is known
|
|
||||||
let config_needs_create = config_file.as_ref().map(|p| !std::path::Path::new(p).exists()).unwrap_or(false);
|
|
||||||
|
|
||||||
let mut config = if let Some(ref path) = config_file {
|
|
||||||
if config_needs_create {
|
|
||||||
// Will be re-created with personalized info after identity is loaded
|
|
||||||
RelayConfig::default()
|
|
||||||
} else {
|
|
||||||
wzp_relay::config::load_config(path)
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
eprintln!("failed to load config from {path}: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RelayConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// CLI flags override config file values
|
|
||||||
let mut i = 1;
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--config" | "-c" => { i += 1; } // already handled
|
|
||||||
"--identity" | "-i" => { i += 1; } // already handled
|
|
||||||
"--listen" => {
|
"--listen" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
config.listen_addr = args.get(i).expect("--listen requires an address")
|
config.listen_addr = args.get(i).expect("--listen requires an address")
|
||||||
@@ -123,28 +81,6 @@ fn parse_args() -> CliResult {
|
|||||||
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
args.get(i).expect("--static-dir requires a directory path").to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"--global-room" => {
|
|
||||||
i += 1;
|
|
||||||
config.global_rooms.push(wzp_relay::config::GlobalRoomConfig {
|
|
||||||
name: args.get(i).expect("--global-room requires a room name").to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"--debug-tap" => {
|
|
||||||
i += 1;
|
|
||||||
config.debug_tap = Some(
|
|
||||||
args.get(i).expect("--debug-tap requires a room name (or '*' for all)").to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--event-log" => {
|
|
||||||
i += 1;
|
|
||||||
config.event_log = Some(
|
|
||||||
args.get(i).expect("--event-log requires a file path").to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"--version" | "-V" => {
|
|
||||||
println!("wzp-relay {}", env!("WZP_BUILD_HASH"));
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
"--mesh-status" => {
|
"--mesh-status" => {
|
||||||
// Print mesh table from a fresh registry and exit.
|
// Print mesh table from a fresh registry and exit.
|
||||||
// In practice this is useful after the relay has been running;
|
// In practice this is useful after the relay has been running;
|
||||||
@@ -154,11 +90,9 @@ fn parse_args() -> CliResult {
|
|||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-relay [--config <path>] [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>] [--auth-url <url>] [--metrics-port <port>] [--probe <addr>]... [--probe-mesh] [--mesh-status]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Options:");
|
eprintln!("Options:");
|
||||||
eprintln!(" -c, --config <path> Load config from TOML file (creates example if missing)");
|
|
||||||
eprintln!(" -i, --identity <path> Identity file path (creates if missing, uses OsRng)");
|
|
||||||
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
||||||
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
||||||
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
||||||
@@ -168,8 +102,6 @@ fn parse_args() -> CliResult {
|
|||||||
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
eprintln!(" --probe-mesh Enable mesh mode (mark config flag, probes all --probe targets).");
|
||||||
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
eprintln!(" --mesh-status Print mesh health table and exit (diagnostic).");
|
||||||
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
eprintln!(" --trunking Enable trunk batching for outgoing media in room mode.");
|
||||||
eprintln!(" --global-room <name> Declare a room as global (bridged across federation). Repeatable.");
|
|
||||||
eprintln!(" --debug-tap <room> Log packet headers for a room ('*' for all rooms).");
|
|
||||||
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
eprintln!(" --ws-port <port> WebSocket listener port for browser clients (e.g., 8080).");
|
||||||
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
eprintln!(" --static-dir <dir> Directory to serve static files from (HTML/JS/WASM).");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -184,7 +116,7 @@ fn parse_args() -> CliResult {
|
|||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
CliResult { config, identity_path, config_file, config_needs_create }
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RelayStats {
|
struct RelayStats {
|
||||||
@@ -252,29 +184,10 @@ async fn run_downstream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect a non-loopback IP address from local interfaces.
|
|
||||||
/// Prefers public IPs over private (10.x, 172.16-31.x, 192.168.x).
|
|
||||||
fn detect_public_ip() -> Option<String> {
|
|
||||||
use std::net::UdpSocket;
|
|
||||||
// Connect to a public address to find our outbound IP (doesn't actually send anything)
|
|
||||||
if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
|
|
||||||
if socket.connect("8.8.8.8:80").is_ok() {
|
|
||||||
if let Ok(addr) = socket.local_addr() {
|
|
||||||
return Some(addr.ip().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build-time git hash, set by build.rs or env.
|
|
||||||
const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let CliResult { config, identity_path, config_file, config_needs_create } = parse_args();
|
let config = parse_args();
|
||||||
tracing_subscriber::fmt().init();
|
tracing_subscriber::fmt().init();
|
||||||
info!(version = BUILD_GIT_HASH, "wzp-relay build");
|
|
||||||
rustls::crypto::ring::default_provider()
|
rustls::crypto::ring::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
.expect("failed to install rustls crypto provider");
|
.expect("failed to install rustls crypto provider");
|
||||||
@@ -294,115 +207,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load or generate relay identity
|
// Generate ephemeral relay identity for crypto handshake
|
||||||
let relay_seed = {
|
let relay_seed = wzp_crypto::Seed::generate();
|
||||||
let id_path = match identity_path {
|
|
||||||
Some(ref p) => std::path::PathBuf::from(p),
|
|
||||||
None => dirs::home_dir()
|
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
||||||
.join(".wzp")
|
|
||||||
.join("relay-identity"),
|
|
||||||
};
|
|
||||||
if id_path.exists() {
|
|
||||||
if let Ok(hex) = std::fs::read_to_string(&id_path) {
|
|
||||||
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
|
||||||
info!("loaded relay identity from {}", id_path.display());
|
|
||||||
s
|
|
||||||
} else {
|
|
||||||
warn!("corrupt identity file {}, generating new", id_path.display());
|
|
||||||
let s = wzp_crypto::Seed::generate();
|
|
||||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
let _ = std::fs::write(&id_path, &hex);
|
|
||||||
s
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let s = wzp_crypto::Seed::generate();
|
|
||||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
let _ = std::fs::write(&id_path, &hex);
|
|
||||||
s
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let s = wzp_crypto::Seed::generate();
|
|
||||||
if let Some(parent) = id_path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
let _ = std::fs::write(&id_path, &hex);
|
|
||||||
info!("generated relay identity at {}", id_path.display());
|
|
||||||
s
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
|
||||||
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
|
||||||
|
|
||||||
let (server_config, cert_der) = wzp_transport::server_config_from_seed(&relay_seed.0);
|
let (server_config, _cert) = wzp_transport::server_config();
|
||||||
let tls_fp = wzp_transport::tls_fingerprint(&cert_der);
|
|
||||||
info!(tls_fingerprint = %tls_fp, "TLS certificate (deterministic from relay identity)");
|
|
||||||
|
|
||||||
// Create personalized config file if it was missing
|
|
||||||
let public_ip = detect_public_ip();
|
|
||||||
if config_needs_create {
|
|
||||||
if let Some(ref path) = config_file {
|
|
||||||
let info = wzp_relay::config::RelayInfo {
|
|
||||||
listen_addr: config.listen_addr.to_string(),
|
|
||||||
tls_fingerprint: tls_fp.clone(),
|
|
||||||
public_ip: public_ip.clone(),
|
|
||||||
};
|
|
||||||
if let Err(e) = wzp_relay::config::load_or_create_config(path, Some(&info)) {
|
|
||||||
warn!("failed to create config: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print federation hint with our public IP + listen port + TLS fingerprint
|
|
||||||
let listen_port = config.listen_addr.port();
|
|
||||||
if let Some(ip) = &public_ip {
|
|
||||||
info!("federation: to peer with this relay, add to relay.toml:");
|
|
||||||
info!(" [[peers]]");
|
|
||||||
info!(" url = \"{ip}:{listen_port}\"");
|
|
||||||
info!(" fingerprint = \"{tls_fp}\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log configured peers and trusted relays
|
|
||||||
if !config.peers.is_empty() {
|
|
||||||
info!(count = config.peers.len(), "federation peers configured");
|
|
||||||
for p in &config.peers {
|
|
||||||
info!(url = %p.url, label = ?p.label, " peer");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !config.trusted.is_empty() {
|
|
||||||
info!(count = config.trusted.len(), "trusted relays configured");
|
|
||||||
for t in &config.trusted {
|
|
||||||
info!(fingerprint = %t.fingerprint, label = ?t.label, " trusted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?;
|
||||||
|
|
||||||
// Compute the IP address we should advertise in CallSetup for direct
|
|
||||||
// calls. If the relay is bound to a specific IP, use it as-is; if bound
|
|
||||||
// to 0.0.0.0, use the trick of "connect" a UDP socket to an arbitrary
|
|
||||||
// external address and read its local_addr — the OS binds to whichever
|
|
||||||
// local interface IP would route packets to that destination, which is
|
|
||||||
// the primary outbound interface. This is the same IP clients on the
|
|
||||||
// LAN use to reach us.
|
|
||||||
let advertised_ip: std::net::IpAddr = {
|
|
||||||
let listen_ip = config.listen_addr.ip();
|
|
||||||
if !listen_ip.is_unspecified() {
|
|
||||||
listen_ip
|
|
||||||
} else {
|
|
||||||
// Probe via a dummy "connected" UDP socket. Never actually sends.
|
|
||||||
match std::net::UdpSocket::bind("0.0.0.0:0")
|
|
||||||
.and_then(|s| { s.connect("8.8.8.8:80").map(|_| s) })
|
|
||||||
.and_then(|s| s.local_addr())
|
|
||||||
{
|
|
||||||
Ok(a) if !a.ip().is_loopback() => a.ip(),
|
|
||||||
_ => std::net::IpAddr::from([127u8, 0, 0, 1]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let advertised_addr_str = format!("{}:{}", advertised_ip, config.listen_addr.port());
|
|
||||||
info!(%advertised_addr_str, "relay advertised address for CallSetup");
|
|
||||||
|
|
||||||
// Forward mode
|
// Forward mode
|
||||||
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> =
|
||||||
if let Some(remote_addr) = config.remote_relay {
|
if let Some(remote_addr) = config.remote_relay {
|
||||||
@@ -418,41 +230,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Room manager (room mode only)
|
// Room manager (room mode only)
|
||||||
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
||||||
|
|
||||||
// Event log for protocol analysis
|
|
||||||
let event_log = wzp_relay::event_log::start_event_log(
|
|
||||||
config.event_log.as_ref().map(std::path::PathBuf::from)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Federation manager
|
|
||||||
let global_room_set: std::collections::HashSet<String> = config.global_rooms.iter()
|
|
||||||
.map(|g| g.name.clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let federation_mgr = if !config.peers.is_empty() || !config.trusted.is_empty() || !global_room_set.is_empty() {
|
|
||||||
let fm = Arc::new(wzp_relay::federation::FederationManager::new(
|
|
||||||
config.peers.clone(),
|
|
||||||
config.trusted.clone(),
|
|
||||||
global_room_set.clone(),
|
|
||||||
room_mgr.clone(),
|
|
||||||
endpoint.clone(),
|
|
||||||
tls_fp.clone(),
|
|
||||||
metrics.clone(),
|
|
||||||
event_log.clone(),
|
|
||||||
));
|
|
||||||
let fm_run = fm.clone();
|
|
||||||
tokio::spawn(async move { fm_run.run().await });
|
|
||||||
Some(fm)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Session manager — enforces max concurrent sessions
|
// Session manager — enforces max concurrent sessions
|
||||||
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
|
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
|
||||||
|
|
||||||
// Signal hub + call registry for direct 1:1 calls
|
|
||||||
let signal_hub = Arc::new(Mutex::new(wzp_relay::signal_hub::SignalHub::new()));
|
|
||||||
let call_registry = Arc::new(Mutex::new(wzp_relay::call_registry::CallRegistry::new()));
|
|
||||||
|
|
||||||
// Spawn inter-relay health probes via ProbeMesh coordinator
|
// Spawn inter-relay health probes via ProbeMesh coordinator
|
||||||
if !config.probe_targets.is_empty() {
|
if !config.probe_targets.is_empty() {
|
||||||
let mesh = wzp_relay::probe::ProbeMesh::new(
|
let mesh = wzp_relay::probe::ProbeMesh::new(
|
||||||
@@ -487,32 +267,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
info!("auth disabled — any client can connect (use --auth-url to enable)");
|
info!("auth disabled — any client can connect (use --auth-url to enable)");
|
||||||
}
|
}
|
||||||
if !config.global_rooms.is_empty() {
|
|
||||||
info!(count = config.global_rooms.len(), "global rooms configured");
|
|
||||||
for g in &config.global_rooms {
|
|
||||||
info!(name = %g.name, " global room");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref tap) = config.debug_tap {
|
|
||||||
info!(filter = %tap, "debug tap enabled — logging packet headers");
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Listening for connections...");
|
info!("Listening for connections...");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Pull the next Incoming off the queue. Deliberately do NOT await
|
let connection = match wzp_transport::accept(&endpoint).await {
|
||||||
// the QUIC handshake here — move that into the per-connection
|
Ok(conn) => conn,
|
||||||
// spawned task below. Previously we used wzp_transport::accept
|
Err(e) => { error!("accept: {e}"); continue; }
|
||||||
// which did both, which meant a single slow handshake would block
|
|
||||||
// the entire accept loop and prevent ALL subsequent connections
|
|
||||||
// from being processed. Surfaced as direct-call hangs where the
|
|
||||||
// callee's call-* connection never completes its QUIC handshake.
|
|
||||||
let incoming = match endpoint.accept().await {
|
|
||||||
Some(inc) => inc,
|
|
||||||
None => {
|
|
||||||
error!("endpoint.accept() returned None — endpoint closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let remote_transport = remote_transport.clone();
|
let remote_transport = remote_transport.clone();
|
||||||
@@ -522,28 +283,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let relay_seed_bytes = relay_seed.0;
|
let relay_seed_bytes = relay_seed.0;
|
||||||
let metrics = metrics.clone();
|
let metrics = metrics.clone();
|
||||||
let trunking_enabled = config.trunking_enabled;
|
let trunking_enabled = config.trunking_enabled;
|
||||||
let debug_tap = config.debug_tap.as_ref().map(|filter| room::DebugTap { room_filter: filter.clone() });
|
|
||||||
let presence = presence.clone();
|
let presence = presence.clone();
|
||||||
let route_resolver = route_resolver.clone();
|
let route_resolver = route_resolver.clone();
|
||||||
let federation_mgr = federation_mgr.clone();
|
|
||||||
let signal_hub = signal_hub.clone();
|
|
||||||
let call_registry = call_registry.clone();
|
|
||||||
let advertised_addr_str = advertised_addr_str.clone();
|
|
||||||
|
|
||||||
let incoming_addr = incoming.remote_address();
|
|
||||||
info!(%incoming_addr, "accept queue: new Incoming, spawning handshake task");
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Drive the QUIC handshake inside the spawned task so that
|
|
||||||
// slow or hung handshakes never block the outer accept loop.
|
|
||||||
let connection = match incoming.await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
error!(%incoming_addr, "QUIC handshake failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
info!(%incoming_addr, "QUIC handshake complete");
|
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
|
|
||||||
let room_name = connection
|
let room_name = connection
|
||||||
@@ -556,23 +299,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
|
|
||||||
// Ping connections: client just measures QUIC connect RTT.
|
|
||||||
if room_name == "ping" {
|
|
||||||
info!(%addr, "ping connection (RTT probe)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version query: respond with build hash over a uni stream.
|
|
||||||
if room_name == "version" {
|
|
||||||
if let Ok(mut send) = transport.connection().open_uni().await {
|
|
||||||
let _ = send.write_all(BUILD_GIT_HASH.as_bytes()).await;
|
|
||||||
let _ = send.finish();
|
|
||||||
// Wait for client to read before closing
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe connections use SNI "_probe" to identify themselves.
|
// Probe connections use SNI "_probe" to identify themselves.
|
||||||
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
// They skip auth + handshake and just do Ping->Pong + presence gossip.
|
||||||
if room_name == "_probe" {
|
if room_name == "_probe" {
|
||||||
@@ -659,290 +385,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Federation connections use SNI "_federation"
|
|
||||||
if room_name == "_federation" {
|
|
||||||
if let Some(ref fm) = federation_mgr {
|
|
||||||
// Wait for FederationHello to identify the connecting relay
|
|
||||||
let hello_fp = match tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(5),
|
|
||||||
transport.recv_signal(),
|
|
||||||
).await {
|
|
||||||
Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello { tls_fingerprint }))) => tls_fingerprint,
|
|
||||||
_ => {
|
|
||||||
warn!(%addr, "federation: no hello received, closing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(label) = fm.check_inbound_trust(addr, &hello_fp) {
|
|
||||||
let peer_config = wzp_relay::config::PeerConfig {
|
|
||||||
url: addr.to_string(),
|
|
||||||
fingerprint: hello_fp,
|
|
||||||
label: Some(label.clone()),
|
|
||||||
};
|
|
||||||
let fm = fm.clone();
|
|
||||||
info!(%addr, label = %label, "inbound federation accepted (trusted)");
|
|
||||||
fm.handle_inbound(transport, peer_config).await;
|
|
||||||
} else {
|
|
||||||
warn!(%addr, fp = %hello_fp, "unknown relay wants to federate");
|
|
||||||
info!(" to accept, add to relay.toml:");
|
|
||||||
info!(" [[trusted]]");
|
|
||||||
info!(" fingerprint = \"{hello_fp}\"");
|
|
||||||
info!(" label = \"Relay at {addr}\"");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
info!(%addr, "federation connection rejected (no federation configured)");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct calling: persistent signaling connection
|
|
||||||
if room_name == "_signal" {
|
|
||||||
info!(%addr, "signal connection");
|
|
||||||
|
|
||||||
// Optional auth
|
|
||||||
let auth_fp: Option<String> = if let Some(ref url) = auth_url {
|
|
||||||
match transport.recv_signal().await {
|
|
||||||
Ok(Some(SignalMessage::AuthToken { token })) => {
|
|
||||||
match wzp_relay::auth::validate_token(url, &token).await {
|
|
||||||
Ok(client) => Some(client.fingerprint),
|
|
||||||
Err(e) => {
|
|
||||||
error!(%addr, "signal auth failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => { warn!(%addr, "signal: expected AuthToken"); return; }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for RegisterPresence
|
|
||||||
let (client_fp, client_alias) = match tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
transport.recv_signal(),
|
|
||||||
).await {
|
|
||||||
Ok(Ok(Some(SignalMessage::RegisterPresence { identity_pub, signature: _, alias }))) => {
|
|
||||||
// Compute fingerprint: SHA-256(Ed25519 pub key)[:16], same as Fingerprint type
|
|
||||||
let fp = {
|
|
||||||
use sha2::{Sha256, Digest};
|
|
||||||
let hash = Sha256::digest(&identity_pub);
|
|
||||||
let fingerprint = wzp_crypto::Fingerprint([
|
|
||||||
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
|
|
||||||
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
|
||||||
]);
|
|
||||||
fingerprint.to_string()
|
|
||||||
};
|
|
||||||
let fp = auth_fp.unwrap_or(fp);
|
|
||||||
(fp, alias)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
warn!(%addr, "signal: no RegisterPresence received");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register in signal hub + presence
|
|
||||||
{
|
|
||||||
let mut hub = signal_hub.lock().await;
|
|
||||||
hub.register(client_fp.clone(), transport.clone(), client_alias.clone());
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut reg = presence.lock().await;
|
|
||||||
reg.register_local(&client_fp, client_alias.clone(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send ack
|
|
||||||
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck {
|
|
||||||
success: true,
|
|
||||||
error: None,
|
|
||||||
}).await;
|
|
||||||
|
|
||||||
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
|
|
||||||
|
|
||||||
// Signal recv loop
|
|
||||||
loop {
|
|
||||||
match transport.recv_signal().await {
|
|
||||||
Ok(Some(msg)) => {
|
|
||||||
match msg {
|
|
||||||
SignalMessage::DirectCallOffer { ref target_fingerprint, ref call_id, .. } => {
|
|
||||||
let target_fp = target_fingerprint.clone();
|
|
||||||
let call_id = call_id.clone();
|
|
||||||
|
|
||||||
// Check if target is online
|
|
||||||
let online = {
|
|
||||||
let hub = signal_hub.lock().await;
|
|
||||||
hub.is_online(&target_fp)
|
|
||||||
};
|
|
||||||
if !online {
|
|
||||||
info!(%addr, target = %target_fp, "call target not online");
|
|
||||||
let _ = transport.send_signal(&SignalMessage::Hangup {
|
|
||||||
reason: wzp_proto::HangupReason::Normal,
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create call in registry
|
|
||||||
{
|
|
||||||
let mut reg = call_registry.lock().await;
|
|
||||||
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward offer to callee
|
|
||||||
info!(caller = %client_fp, callee = %target_fp, call_id = %call_id, "routing direct call offer");
|
|
||||||
let hub = signal_hub.lock().await;
|
|
||||||
if let Err(e) = hub.send_to(&target_fp, &msg).await {
|
|
||||||
warn!("failed to forward call offer: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send ringing to caller
|
|
||||||
drop(hub);
|
|
||||||
let _ = transport.send_signal(&SignalMessage::CallRinging {
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
SignalMessage::DirectCallAnswer { ref call_id, ref accept_mode, .. } => {
|
|
||||||
let call_id = call_id.clone();
|
|
||||||
let mode = *accept_mode;
|
|
||||||
|
|
||||||
let peer_fp = {
|
|
||||||
let reg = call_registry.lock().await;
|
|
||||||
reg.peer_fingerprint(&call_id, &client_fp).map(|s| s.to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(peer_fp) = peer_fp else {
|
|
||||||
warn!(call_id = %call_id, "answer for unknown call");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if mode == wzp_proto::CallAcceptMode::Reject {
|
|
||||||
info!(call_id = %call_id, "call rejected");
|
|
||||||
let mut reg = call_registry.lock().await;
|
|
||||||
reg.end_call(&call_id);
|
|
||||||
drop(reg);
|
|
||||||
let hub = signal_hub.lock().await;
|
|
||||||
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
|
|
||||||
reason: wzp_proto::HangupReason::Normal,
|
|
||||||
}).await;
|
|
||||||
} else {
|
|
||||||
// Accept — create private room
|
|
||||||
let room = format!("call-{call_id}");
|
|
||||||
{
|
|
||||||
let mut reg = call_registry.lock().await;
|
|
||||||
reg.set_active(&call_id, mode, room.clone());
|
|
||||||
}
|
|
||||||
info!(call_id = %call_id, room = %room, mode = ?mode, "call accepted, creating room");
|
|
||||||
|
|
||||||
// Forward answer to caller
|
|
||||||
{
|
|
||||||
let hub = signal_hub.lock().await;
|
|
||||||
let _ = hub.send_to(&peer_fp, &msg).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send CallSetup to both parties.
|
|
||||||
//
|
|
||||||
// BUG FIX: the previous version of this used `addr.ip()`
|
|
||||||
// which is `connection.remote_address()` — the CLIENT'S
|
|
||||||
// IP, not the relay's. So CallSetup told both parties to
|
|
||||||
// dial the answerer's own IP, which meant the caller was
|
|
||||||
// sending QUIC Initials into the callee's client (no
|
|
||||||
// server listening there) and the callee was sending to
|
|
||||||
// itself. In both cases endpoint.connect() hung forever.
|
|
||||||
//
|
|
||||||
// Use the relay's precomputed advertised address instead.
|
|
||||||
let relay_addr_for_setup = advertised_addr_str.clone();
|
|
||||||
let setup = SignalMessage::CallSetup {
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
room: room.clone(),
|
|
||||||
relay_addr: relay_addr_for_setup,
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let hub = signal_hub.lock().await;
|
|
||||||
let _ = hub.send_to(&peer_fp, &setup).await;
|
|
||||||
let _ = hub.send_to(&client_fp, &setup).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SignalMessage::Hangup { .. } => {
|
|
||||||
// Forward hangup to all active calls for this user
|
|
||||||
let calls = {
|
|
||||||
let reg = call_registry.lock().await;
|
|
||||||
reg.calls_for_fingerprint(&client_fp)
|
|
||||||
.iter()
|
|
||||||
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
|
||||||
c.callee_fingerprint.clone()
|
|
||||||
} else {
|
|
||||||
c.caller_fingerprint.clone()
|
|
||||||
}))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
for (call_id, peer_fp) in &calls {
|
|
||||||
let hub = signal_hub.lock().await;
|
|
||||||
let _ = hub.send_to(peer_fp, &msg).await;
|
|
||||||
drop(hub);
|
|
||||||
let mut reg = call_registry.lock().await;
|
|
||||||
reg.end_call(call_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SignalMessage::Ping { timestamp_ms } => {
|
|
||||||
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
other => {
|
|
||||||
warn!(%addr, "signal: unexpected message: {:?}", std::mem::discriminant(&other));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
info!(%addr, "signal connection closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(%addr, "signal recv error: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup: unregister + end active calls
|
|
||||||
let active_calls = {
|
|
||||||
let reg = call_registry.lock().await;
|
|
||||||
reg.calls_for_fingerprint(&client_fp)
|
|
||||||
.iter()
|
|
||||||
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
|
|
||||||
c.callee_fingerprint.clone()
|
|
||||||
} else {
|
|
||||||
c.caller_fingerprint.clone()
|
|
||||||
}))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
for (call_id, peer_fp) in &active_calls {
|
|
||||||
let hub = signal_hub.lock().await;
|
|
||||||
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup {
|
|
||||||
reason: wzp_proto::HangupReason::Normal,
|
|
||||||
}).await;
|
|
||||||
drop(hub);
|
|
||||||
let mut reg = call_registry.lock().await;
|
|
||||||
reg.end_call(call_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut hub = signal_hub.lock().await;
|
|
||||||
hub.unregister(&client_fp);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut reg = presence.lock().await;
|
|
||||||
reg.unregister_local(&client_fp);
|
|
||||||
}
|
|
||||||
|
|
||||||
transport.close().await.ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth check: if --auth-url is set, expect first signal message to be a token
|
// Auth check: if --auth-url is set, expect first signal message to be a token
|
||||||
// Auth: if --auth-url is set, expect AuthToken as first signal
|
// Auth: if --auth-url is set, expect AuthToken as first signal
|
||||||
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
let authenticated_fp: Option<String> = if let Some(ref url) = auth_url {
|
||||||
@@ -1009,28 +451,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Use the caller's identity fingerprint from the handshake
|
// Use the caller's identity fingerprint from the handshake
|
||||||
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
|
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
|
||||||
|
|
||||||
// ACL: call rooms (call-*) are restricted to the two authorized participants.
|
|
||||||
// Only the relay's call orchestrator creates these rooms — random clients can't join.
|
|
||||||
if room_name.starts_with("call-") {
|
|
||||||
let call_id = &room_name[5..]; // strip "call-" prefix
|
|
||||||
let authorized = {
|
|
||||||
let reg = call_registry.lock().await;
|
|
||||||
match reg.get(call_id) {
|
|
||||||
Some(call) => {
|
|
||||||
call.caller_fingerprint == participant_fp
|
|
||||||
|| call.callee_fingerprint == participant_fp
|
|
||||||
}
|
|
||||||
None => false, // unknown call — reject
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !authorized {
|
|
||||||
warn!(%addr, room = %room_name, fp = %participant_fp, "rejected: not authorized for this call room");
|
|
||||||
transport.close().await.ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
info!(%addr, room = %room_name, fp = %participant_fp, "authorized for call room");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register in presence registry
|
// Register in presence registry
|
||||||
{
|
{
|
||||||
let mut reg = presence.lock().await;
|
let mut reg = presence.lock().await;
|
||||||
@@ -1083,20 +503,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
metrics.active_sessions.inc();
|
metrics.active_sessions.inc();
|
||||||
|
|
||||||
// Call rooms: enforce 2-participant limit
|
|
||||||
if room_name.starts_with("call-") {
|
|
||||||
let mgr = room_mgr.lock().await;
|
|
||||||
if mgr.room_size(&room_name) >= 2 {
|
|
||||||
drop(mgr);
|
|
||||||
warn!(%addr, room = %room_name, "call room full (max 2 participants)");
|
|
||||||
metrics.active_sessions.dec();
|
|
||||||
let mut smgr = session_mgr.lock().await;
|
|
||||||
smgr.remove_session(session_id);
|
|
||||||
transport.close().await.ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let participant_id = {
|
let participant_id = {
|
||||||
let mut mgr = room_mgr.lock().await;
|
let mut mgr = room_mgr.lock().await;
|
||||||
match mgr.join(
|
match mgr.join(
|
||||||
@@ -1109,25 +515,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok((id, update, senders)) => {
|
Ok((id, update, senders)) => {
|
||||||
metrics.active_rooms.set(mgr.list().len() as i64);
|
metrics.active_rooms.set(mgr.list().len() as i64);
|
||||||
drop(mgr); // release lock before async broadcast
|
drop(mgr); // release lock before async broadcast
|
||||||
|
room::broadcast_signal(&senders, &update).await;
|
||||||
// Merge federated participants into RoomUpdate if this is a global room
|
|
||||||
let merged_update = if let Some(ref fm) = federation_mgr {
|
|
||||||
if fm.is_global_room(&room_name) {
|
|
||||||
if let SignalMessage::RoomUpdate { count: _, participants: mut local_parts } = update {
|
|
||||||
let remote = fm.get_remote_participants(&room_name).await;
|
|
||||||
local_parts.extend(remote);
|
|
||||||
// Deduplicate by fingerprint
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
local_parts.retain(|p| seen.insert(p.fingerprint.clone()));
|
|
||||||
SignalMessage::RoomUpdate {
|
|
||||||
count: local_parts.len() as u32,
|
|
||||||
participants: local_parts,
|
|
||||||
}
|
|
||||||
} else { update }
|
|
||||||
} else { update }
|
|
||||||
} else { update };
|
|
||||||
|
|
||||||
room::broadcast_signal(&senders, &merged_update).await;
|
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1145,25 +533,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|b| format!("{b:02x}"))
|
.map(|b| format!("{b:02x}"))
|
||||||
.collect();
|
.collect();
|
||||||
// Set up federation media channel if this is a global room
|
|
||||||
let (federation_tx, federation_room_hash) = if let Some(ref fm) = federation_mgr {
|
|
||||||
let is_global = fm.is_global_room(&room_name);
|
|
||||||
if is_global {
|
|
||||||
let canonical_hash = fm.global_room_hash(&room_name);
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
|
||||||
let fm_clone = fm.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
wzp_relay::federation::run_federation_media_egress(fm_clone, rx).await;
|
|
||||||
});
|
|
||||||
info!(room = %room_name, canonical = ?fm.resolve_global_room(&room_name), "federation egress created (global room)");
|
|
||||||
(Some(tx), Some(canonical_hash))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
room::run_participant(
|
room::run_participant(
|
||||||
room_mgr.clone(),
|
room_mgr.clone(),
|
||||||
room_name,
|
room_name,
|
||||||
@@ -1172,9 +541,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
metrics.clone(),
|
metrics.clone(),
|
||||||
&session_id_str,
|
&session_id_str,
|
||||||
trunking_enabled,
|
trunking_enabled,
|
||||||
debug_tap,
|
|
||||||
federation_tx,
|
|
||||||
federation_room_hash,
|
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
// Participant disconnected — clean up presence + per-session metrics
|
// Participant disconnected — clean up presence + per-session metrics
|
||||||
@@ -1197,5 +563,4 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,22 +16,12 @@ pub struct RelayMetrics {
|
|||||||
pub bytes_forwarded: IntCounter,
|
pub bytes_forwarded: IntCounter,
|
||||||
pub auth_attempts: IntCounterVec,
|
pub auth_attempts: IntCounterVec,
|
||||||
pub handshake_duration: Histogram,
|
pub handshake_duration: Histogram,
|
||||||
// Federation metrics
|
|
||||||
pub federation_peer_status: IntGaugeVec,
|
|
||||||
pub federation_peer_rtt_ms: GaugeVec,
|
|
||||||
pub federation_packets_forwarded: IntCounterVec,
|
|
||||||
pub federation_packets_deduped: IntCounter,
|
|
||||||
pub federation_packets_rate_limited: IntCounter,
|
|
||||||
pub federation_active_rooms: IntGauge,
|
|
||||||
// Per-session metrics
|
// Per-session metrics
|
||||||
pub session_buffer_depth: IntGaugeVec,
|
pub session_buffer_depth: IntGaugeVec,
|
||||||
pub session_loss_pct: GaugeVec,
|
pub session_loss_pct: GaugeVec,
|
||||||
pub session_rtt_ms: GaugeVec,
|
pub session_rtt_ms: GaugeVec,
|
||||||
pub session_underruns: IntCounterVec,
|
pub session_underruns: IntCounterVec,
|
||||||
pub session_overruns: IntCounterVec,
|
pub session_overruns: IntCounterVec,
|
||||||
// Phase 4: loss-recovery breakdown per session.
|
|
||||||
pub session_dred_reconstructions: IntCounterVec,
|
|
||||||
pub session_classical_plc: IntCounterVec,
|
|
||||||
registry: Registry,
|
registry: Registry,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,28 +60,6 @@ impl RelayMetrics {
|
|||||||
)
|
)
|
||||||
.expect("metric");
|
.expect("metric");
|
||||||
|
|
||||||
let federation_peer_status = IntGaugeVec::new(
|
|
||||||
Opts::new("wzp_federation_peer_status", "Peer connection status (0=disconnected, 1=connected)"),
|
|
||||||
&["peer"],
|
|
||||||
).expect("metric");
|
|
||||||
let federation_peer_rtt_ms = GaugeVec::new(
|
|
||||||
Opts::new("wzp_federation_peer_rtt_ms", "QUIC RTT to federated peer in milliseconds"),
|
|
||||||
&["peer"],
|
|
||||||
).expect("metric");
|
|
||||||
let federation_packets_forwarded = IntCounterVec::new(
|
|
||||||
Opts::new("wzp_federation_packets_forwarded_total", "Packets forwarded to/from federated peers"),
|
|
||||||
&["peer", "direction"],
|
|
||||||
).expect("metric");
|
|
||||||
let federation_packets_deduped = IntCounter::with_opts(
|
|
||||||
Opts::new("wzp_federation_packets_deduped_total", "Duplicate federation packets dropped"),
|
|
||||||
).expect("metric");
|
|
||||||
let federation_packets_rate_limited = IntCounter::with_opts(
|
|
||||||
Opts::new("wzp_federation_packets_rate_limited_total", "Federation packets dropped by rate limiter"),
|
|
||||||
).expect("metric");
|
|
||||||
let federation_active_rooms = IntGauge::with_opts(
|
|
||||||
Opts::new("wzp_federation_active_rooms", "Number of federated rooms currently active"),
|
|
||||||
).expect("metric");
|
|
||||||
|
|
||||||
let session_buffer_depth = IntGaugeVec::new(
|
let session_buffer_depth = IntGaugeVec::new(
|
||||||
Opts::new(
|
Opts::new(
|
||||||
"wzp_relay_session_jitter_buffer_depth",
|
"wzp_relay_session_jitter_buffer_depth",
|
||||||
@@ -133,42 +101,17 @@ impl RelayMetrics {
|
|||||||
)
|
)
|
||||||
.expect("metric");
|
.expect("metric");
|
||||||
|
|
||||||
let session_dred_reconstructions = IntCounterVec::new(
|
|
||||||
Opts::new(
|
|
||||||
"wzp_relay_session_dred_reconstructions_total",
|
|
||||||
"Frames reconstructed via DRED (Deep REDundancy) per session",
|
|
||||||
),
|
|
||||||
&["session_id"],
|
|
||||||
)
|
|
||||||
.expect("metric");
|
|
||||||
let session_classical_plc = IntCounterVec::new(
|
|
||||||
Opts::new(
|
|
||||||
"wzp_relay_session_classical_plc_total",
|
|
||||||
"Frames filled via classical Opus/Codec2 PLC per session",
|
|
||||||
),
|
|
||||||
&["session_id"],
|
|
||||||
)
|
|
||||||
.expect("metric");
|
|
||||||
|
|
||||||
registry.register(Box::new(active_sessions.clone())).expect("register");
|
registry.register(Box::new(active_sessions.clone())).expect("register");
|
||||||
registry.register(Box::new(active_rooms.clone())).expect("register");
|
registry.register(Box::new(active_rooms.clone())).expect("register");
|
||||||
registry.register(Box::new(packets_forwarded.clone())).expect("register");
|
registry.register(Box::new(packets_forwarded.clone())).expect("register");
|
||||||
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
registry.register(Box::new(bytes_forwarded.clone())).expect("register");
|
||||||
registry.register(Box::new(auth_attempts.clone())).expect("register");
|
registry.register(Box::new(auth_attempts.clone())).expect("register");
|
||||||
registry.register(Box::new(handshake_duration.clone())).expect("register");
|
registry.register(Box::new(handshake_duration.clone())).expect("register");
|
||||||
registry.register(Box::new(federation_peer_status.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_peer_rtt_ms.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_packets_forwarded.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_packets_deduped.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_packets_rate_limited.clone())).expect("register");
|
|
||||||
registry.register(Box::new(federation_active_rooms.clone())).expect("register");
|
|
||||||
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
|
registry.register(Box::new(session_buffer_depth.clone())).expect("register");
|
||||||
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
registry.register(Box::new(session_loss_pct.clone())).expect("register");
|
||||||
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
registry.register(Box::new(session_rtt_ms.clone())).expect("register");
|
||||||
registry.register(Box::new(session_underruns.clone())).expect("register");
|
registry.register(Box::new(session_underruns.clone())).expect("register");
|
||||||
registry.register(Box::new(session_overruns.clone())).expect("register");
|
registry.register(Box::new(session_overruns.clone())).expect("register");
|
||||||
registry.register(Box::new(session_dred_reconstructions.clone())).expect("register");
|
|
||||||
registry.register(Box::new(session_classical_plc.clone())).expect("register");
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
active_sessions,
|
active_sessions,
|
||||||
@@ -177,19 +120,11 @@ impl RelayMetrics {
|
|||||||
bytes_forwarded,
|
bytes_forwarded,
|
||||||
auth_attempts,
|
auth_attempts,
|
||||||
handshake_duration,
|
handshake_duration,
|
||||||
federation_peer_status,
|
|
||||||
federation_peer_rtt_ms,
|
|
||||||
federation_packets_forwarded,
|
|
||||||
federation_packets_deduped,
|
|
||||||
federation_packets_rate_limited,
|
|
||||||
federation_active_rooms,
|
|
||||||
session_buffer_depth,
|
session_buffer_depth,
|
||||||
session_loss_pct,
|
session_loss_pct,
|
||||||
session_rtt_ms,
|
session_rtt_ms,
|
||||||
session_underruns,
|
session_underruns,
|
||||||
session_overruns,
|
session_overruns,
|
||||||
session_dred_reconstructions,
|
|
||||||
session_classical_plc,
|
|
||||||
registry,
|
registry,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,39 +176,6 @@ impl RelayMetrics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 4: update per-session loss-recovery counters from a client's
|
|
||||||
/// `LossRecoveryUpdate` signal message. The client sends monotonic
|
|
||||||
/// totals (frames reconstructed since call start); we compute the
|
|
||||||
/// delta against the current Prometheus counter and increment by it.
|
|
||||||
/// IntCounterVec only increases, so a client restart that resets the
|
|
||||||
/// counter to 0 simply produces no delta until the new totals exceed
|
|
||||||
/// the Prometheus state.
|
|
||||||
pub fn update_session_loss_recovery(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
dred_reconstructions: u64,
|
|
||||||
classical_plc: u64,
|
|
||||||
) {
|
|
||||||
let cur_dred = self
|
|
||||||
.session_dred_reconstructions
|
|
||||||
.with_label_values(&[session_id])
|
|
||||||
.get();
|
|
||||||
if dred_reconstructions > cur_dred {
|
|
||||||
self.session_dred_reconstructions
|
|
||||||
.with_label_values(&[session_id])
|
|
||||||
.inc_by(dred_reconstructions - cur_dred);
|
|
||||||
}
|
|
||||||
let cur_plc = self
|
|
||||||
.session_classical_plc
|
|
||||||
.with_label_values(&[session_id])
|
|
||||||
.get();
|
|
||||||
if classical_plc > cur_plc {
|
|
||||||
self.session_classical_plc
|
|
||||||
.with_label_values(&[session_id])
|
|
||||||
.inc_by(classical_plc - cur_plc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove all per-session label values for a disconnected session.
|
/// Remove all per-session label values for a disconnected session.
|
||||||
pub fn remove_session_metrics(&self, session_id: &str) {
|
pub fn remove_session_metrics(&self, session_id: &str) {
|
||||||
let _ = self.session_buffer_depth.remove_label_values(&[session_id]);
|
let _ = self.session_buffer_depth.remove_label_values(&[session_id]);
|
||||||
@@ -281,10 +183,6 @@ impl RelayMetrics {
|
|||||||
let _ = self.session_rtt_ms.remove_label_values(&[session_id]);
|
let _ = self.session_rtt_ms.remove_label_values(&[session_id]);
|
||||||
let _ = self.session_underruns.remove_label_values(&[session_id]);
|
let _ = self.session_underruns.remove_label_values(&[session_id]);
|
||||||
let _ = self.session_overruns.remove_label_values(&[session_id]);
|
let _ = self.session_overruns.remove_label_values(&[session_id]);
|
||||||
let _ = self
|
|
||||||
.session_dred_reconstructions
|
|
||||||
.remove_label_values(&[session_id]);
|
|
||||||
let _ = self.session_classical_plc.remove_label_values(&[session_id]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the underlying Prometheus registry.
|
/// Get a reference to the underlying Prometheus registry.
|
||||||
@@ -479,13 +377,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
m.update_session_quality("sess-cleanup", &report);
|
m.update_session_quality("sess-cleanup", &report);
|
||||||
m.update_session_buffer("sess-cleanup", 42, 3, 1);
|
m.update_session_buffer("sess-cleanup", 42, 3, 1);
|
||||||
m.update_session_loss_recovery("sess-cleanup", 17, 4);
|
|
||||||
|
|
||||||
// Verify they appear
|
// Verify they appear
|
||||||
let output = m.metrics_handler();
|
let output = m.metrics_handler();
|
||||||
assert!(output.contains("sess-cleanup"));
|
assert!(output.contains("sess-cleanup"));
|
||||||
assert!(output.contains("wzp_relay_session_dred_reconstructions_total"));
|
|
||||||
assert!(output.contains("wzp_relay_session_classical_plc_total"));
|
|
||||||
|
|
||||||
// Remove and verify they are gone
|
// Remove and verify they are gone
|
||||||
m.remove_session_metrics("sess-cleanup");
|
m.remove_session_metrics("sess-cleanup");
|
||||||
@@ -493,55 +388,6 @@ mod tests {
|
|||||||
assert!(!output.contains("sess-cleanup"));
|
assert!(!output.contains("sess-cleanup"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 4: LossRecoveryUpdate → per-session counters, monotonic delta
|
|
||||||
/// application.
|
|
||||||
#[test]
|
|
||||||
fn session_loss_recovery_monotonic_delta() {
|
|
||||||
let m = RelayMetrics::new();
|
|
||||||
let sess = "sess-dred";
|
|
||||||
|
|
||||||
// First update: 10 DRED, 2 PLC
|
|
||||||
m.update_session_loss_recovery(sess, 10, 2);
|
|
||||||
let dred1 = m
|
|
||||||
.session_dred_reconstructions
|
|
||||||
.with_label_values(&[sess])
|
|
||||||
.get();
|
|
||||||
let plc1 = m.session_classical_plc.with_label_values(&[sess]).get();
|
|
||||||
assert_eq!(dred1, 10);
|
|
||||||
assert_eq!(plc1, 2);
|
|
||||||
|
|
||||||
// Second update: 25 DRED, 5 PLC — counter advances by (15, 3)
|
|
||||||
m.update_session_loss_recovery(sess, 25, 5);
|
|
||||||
let dred2 = m
|
|
||||||
.session_dred_reconstructions
|
|
||||||
.with_label_values(&[sess])
|
|
||||||
.get();
|
|
||||||
let plc2 = m.session_classical_plc.with_label_values(&[sess]).get();
|
|
||||||
assert_eq!(dred2, 25);
|
|
||||||
assert_eq!(plc2, 5);
|
|
||||||
|
|
||||||
// Third update with LOWER values (e.g., client reset) — counters
|
|
||||||
// hold steady, no decrement.
|
|
||||||
m.update_session_loss_recovery(sess, 5, 1);
|
|
||||||
let dred3 = m
|
|
||||||
.session_dred_reconstructions
|
|
||||||
.with_label_values(&[sess])
|
|
||||||
.get();
|
|
||||||
let plc3 = m.session_classical_plc.with_label_values(&[sess]).get();
|
|
||||||
assert_eq!(dred3, 25, "counter must not decrease");
|
|
||||||
assert_eq!(plc3, 5, "counter must not decrease");
|
|
||||||
|
|
||||||
// Fourth update: client caught up and exceeded the old max.
|
|
||||||
m.update_session_loss_recovery(sess, 30, 8);
|
|
||||||
let dred4 = m
|
|
||||||
.session_dred_reconstructions
|
|
||||||
.with_label_values(&[sess])
|
|
||||||
.get();
|
|
||||||
let plc4 = m.session_classical_plc.with_label_values(&[sess]).get();
|
|
||||||
assert_eq!(dred4, 30);
|
|
||||||
assert_eq!(plc4, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn metrics_increment() {
|
fn metrics_increment() {
|
||||||
let m = RelayMetrics::new();
|
let m = RelayMetrics::new();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
||||||
use wzp_proto::packet::TrunkFrame;
|
use wzp_proto::packet::TrunkFrame;
|
||||||
use wzp_proto::MediaTransport;
|
use wzp_proto::MediaTransport;
|
||||||
@@ -18,38 +18,6 @@ use wzp_proto::MediaTransport;
|
|||||||
use crate::metrics::RelayMetrics;
|
use crate::metrics::RelayMetrics;
|
||||||
use crate::trunk::TrunkBatcher;
|
use crate::trunk::TrunkBatcher;
|
||||||
|
|
||||||
/// Debug tap: logs packet metadata for matching rooms.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct DebugTap {
|
|
||||||
/// Room name filter ("*" = all rooms, or specific room name/hash).
|
|
||||||
pub room_filter: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DebugTap {
|
|
||||||
pub fn matches(&self, room_name: &str) -> bool {
|
|
||||||
self.room_filter == "*" || self.room_filter == room_name
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn log_packet(&self, room: &str, dir: &str, addr: &std::net::SocketAddr, pkt: &wzp_proto::MediaPacket, fan_out: usize) {
|
|
||||||
let h = &pkt.header;
|
|
||||||
info!(
|
|
||||||
target: "debug_tap",
|
|
||||||
room = %room,
|
|
||||||
dir = dir,
|
|
||||||
addr = %addr,
|
|
||||||
seq = h.seq,
|
|
||||||
codec = ?h.codec_id,
|
|
||||||
ts = h.timestamp,
|
|
||||||
fec_block = h.fec_block,
|
|
||||||
fec_sym = h.fec_symbol,
|
|
||||||
repair = h.is_repair,
|
|
||||||
len = pkt.payload.len(),
|
|
||||||
fan_out,
|
|
||||||
"TAP"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unique participant ID within a room.
|
/// Unique participant ID within a room.
|
||||||
pub type ParticipantId = u64;
|
pub type ParticipantId = u64;
|
||||||
|
|
||||||
@@ -59,22 +27,6 @@ fn next_id() -> ParticipantId {
|
|||||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events emitted by RoomManager for federation to observe.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum RoomEvent {
|
|
||||||
/// First local participant joined this room.
|
|
||||||
LocalJoin { room: String },
|
|
||||||
/// Last local participant left this room.
|
|
||||||
LocalLeave { room: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outbound federation media from a local participant.
|
|
||||||
pub struct FederationMediaOut {
|
|
||||||
pub room_name: String,
|
|
||||||
pub room_hash: [u8; 8],
|
|
||||||
pub data: Bytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum ParticipantSender {
|
pub enum ParticipantSender {
|
||||||
@@ -180,7 +132,6 @@ impl Room {
|
|||||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||||
alias: p.alias.clone(),
|
alias: p.alias.clone(),
|
||||||
relay_label: None, // local participant
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -206,35 +157,24 @@ pub struct RoomManager {
|
|||||||
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
/// When `None`, rooms are open (no auth mode). When `Some`, only listed
|
||||||
/// fingerprints can join the corresponding room.
|
/// fingerprints can join the corresponding room.
|
||||||
acl: Option<HashMap<String, HashSet<String>>>,
|
acl: Option<HashMap<String, HashSet<String>>>,
|
||||||
/// Channel for room lifecycle events (federation subscribes).
|
|
||||||
event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomManager {
|
impl RoomManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
|
||||||
Self {
|
Self {
|
||||||
rooms: HashMap::new(),
|
rooms: HashMap::new(),
|
||||||
acl: None,
|
acl: None,
|
||||||
event_tx,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a room manager with ACL enforcement enabled.
|
/// Create a room manager with ACL enforcement enabled.
|
||||||
pub fn with_acl() -> Self {
|
pub fn with_acl() -> Self {
|
||||||
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
|
||||||
Self {
|
Self {
|
||||||
rooms: HashMap::new(),
|
rooms: HashMap::new(),
|
||||||
acl: Some(HashMap::new()),
|
acl: Some(HashMap::new()),
|
||||||
event_tx,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to room lifecycle events (for federation).
|
|
||||||
pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<RoomEvent> {
|
|
||||||
self.event_tx.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Grant a fingerprint access to a room.
|
/// Grant a fingerprint access to a room.
|
||||||
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
pub fn allow(&mut self, room_name: &str, fingerprint: &str) {
|
||||||
if let Some(ref mut acl) = self.acl {
|
if let Some(ref mut acl) = self.acl {
|
||||||
@@ -273,13 +213,8 @@ impl RoomManager {
|
|||||||
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
|
||||||
return Err("not authorized for this room".to_string());
|
return Err("not authorized for this room".to_string());
|
||||||
}
|
}
|
||||||
let was_empty = !self.rooms.contains_key(room_name)
|
|
||||||
|| self.rooms.get(room_name).map_or(true, |r| r.is_empty());
|
|
||||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||||
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
||||||
if was_empty {
|
|
||||||
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
|
|
||||||
}
|
|
||||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||||
count: room.len() as u32,
|
count: room.len() as u32,
|
||||||
participants: room.participant_list(),
|
participants: room.participant_list(),
|
||||||
@@ -300,34 +235,12 @@ impl RoomManager {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get list of active room names.
|
|
||||||
pub fn active_rooms(&self) -> Vec<String> {
|
|
||||||
self.rooms.keys().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get participant list for a room (fingerprint + alias).
|
|
||||||
pub fn local_participant_list(&self, room_name: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
|
||||||
self.rooms.get(room_name)
|
|
||||||
.map(|room| room.participant_list())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all senders for participants in a room (for federation inbound media delivery).
|
|
||||||
pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> {
|
|
||||||
self.rooms.get(room_name)
|
|
||||||
.map(|room| room.participants.iter()
|
|
||||||
.map(|p| p.sender.clone())
|
|
||||||
.collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
||||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||||
room.remove(participant_id);
|
room.remove(participant_id);
|
||||||
if room.is_empty() {
|
if room.is_empty() {
|
||||||
self.rooms.remove(room_name);
|
self.rooms.remove(room_name);
|
||||||
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
|
|
||||||
info!(room = room_name, "room closed (empty)");
|
info!(room = room_name, "room closed (empty)");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -437,9 +350,6 @@ pub async fn run_participant(
|
|||||||
metrics: Arc<RelayMetrics>,
|
metrics: Arc<RelayMetrics>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
trunking_enabled: bool,
|
trunking_enabled: bool,
|
||||||
debug_tap: Option<DebugTap>,
|
|
||||||
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
|
||||||
federation_room_hash: Option<[u8; 8]>,
|
|
||||||
) {
|
) {
|
||||||
if trunking_enabled {
|
if trunking_enabled {
|
||||||
run_participant_trunked(
|
run_participant_trunked(
|
||||||
@@ -448,7 +358,7 @@ pub async fn run_participant(
|
|||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
run_participant_plain(
|
run_participant_plain(
|
||||||
room_mgr, room_name, participant_id, transport, metrics, session_id, debug_tap, federation_tx, federation_room_hash,
|
room_mgr, room_name, participant_id, transport, metrics, session_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -462,9 +372,6 @@ async fn run_participant_plain(
|
|||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
transport: Arc<wzp_transport::QuinnTransport>,
|
||||||
metrics: Arc<RelayMetrics>,
|
metrics: Arc<RelayMetrics>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
debug_tap: Option<DebugTap>,
|
|
||||||
federation_tx: Option<tokio::sync::mpsc::Sender<FederationMediaOut>>,
|
|
||||||
federation_room_hash: Option<[u8; 8]>,
|
|
||||||
) {
|
) {
|
||||||
let addr = transport.connection().remote_address();
|
let addr = transport.connection().remote_address();
|
||||||
let mut packets_forwarded = 0u64;
|
let mut packets_forwarded = 0u64;
|
||||||
@@ -483,6 +390,7 @@ async fn run_participant_plain(
|
|||||||
);
|
);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let recv_start = std::time::Instant::now();
|
||||||
let pkt = match transport.recv_media().await {
|
let pkt = match transport.recv_media().await {
|
||||||
Ok(Some(pkt)) => pkt,
|
Ok(Some(pkt)) => pkt,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
@@ -537,13 +445,6 @@ async fn run_participant_plain(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug tap: log packet metadata
|
|
||||||
if let Some(ref tap) = debug_tap {
|
|
||||||
if tap.matches(&room_name) {
|
|
||||||
tap.log_packet(&room_name, "in", &addr, &pkt, others.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward to all others
|
// Forward to all others
|
||||||
let fwd_start = std::time::Instant::now();
|
let fwd_start = std::time::Instant::now();
|
||||||
let pkt_bytes = pkt.payload.len() as u64;
|
let pkt_bytes = pkt.payload.len() as u64;
|
||||||
@@ -568,17 +469,6 @@ async fn run_participant_plain(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Federation: forward to active peer relays via channel
|
|
||||||
if let Some(ref fed_tx) = federation_tx {
|
|
||||||
let data = pkt.to_bytes();
|
|
||||||
let _ = fed_tx.try_send(FederationMediaOut {
|
|
||||||
room_name: room_name.clone(),
|
|
||||||
room_hash: federation_room_hash.unwrap_or_else(|| crate::federation::room_hash(&room_name)),
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||||
if fwd_ms > max_forward_ms {
|
if fwd_ms > max_forward_ms {
|
||||||
max_forward_ms = fwd_ms;
|
max_forward_ms = fwd_ms;
|
||||||
@@ -837,7 +727,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn room_join_leave() {
|
fn room_join_leave() {
|
||||||
let mgr = RoomManager::new();
|
let mut mgr = RoomManager::new();
|
||||||
assert_eq!(mgr.room_size("test"), 0);
|
assert_eq!(mgr.room_size("test"), 0);
|
||||||
assert!(mgr.list().is_empty());
|
assert!(mgr.list().is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
//! Persistent signaling connection manager.
|
|
||||||
//!
|
|
||||||
//! Tracks clients connected via `_signal` SNI. Routes call signals
|
|
||||||
//! (DirectCallOffer, DirectCallAnswer, Hangup) between registered users.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use tracing::info;
|
|
||||||
use wzp_proto::{MediaTransport, SignalMessage};
|
|
||||||
use wzp_transport::QuinnTransport;
|
|
||||||
|
|
||||||
/// A client connected via `_signal` for direct calling.
|
|
||||||
pub struct SignalClient {
|
|
||||||
pub fingerprint: String,
|
|
||||||
pub alias: Option<String>,
|
|
||||||
pub transport: Arc<QuinnTransport>,
|
|
||||||
pub connected_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages persistent signaling connections.
|
|
||||||
pub struct SignalHub {
|
|
||||||
clients: HashMap<String, SignalClient>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SignalHub {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
clients: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a new signaling client.
|
|
||||||
pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) {
|
|
||||||
info!(fingerprint = %fp, alias = ?alias, "signal client registered");
|
|
||||||
self.clients.insert(fp.clone(), SignalClient {
|
|
||||||
fingerprint: fp,
|
|
||||||
alias,
|
|
||||||
transport,
|
|
||||||
connected_at: Instant::now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unregister a signaling client. Returns the client if found.
|
|
||||||
pub fn unregister(&mut self, fp: &str) -> Option<SignalClient> {
|
|
||||||
let client = self.clients.remove(fp);
|
|
||||||
if client.is_some() {
|
|
||||||
info!(fingerprint = %fp, "signal client unregistered");
|
|
||||||
}
|
|
||||||
client
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a client by fingerprint.
|
|
||||||
pub fn get(&self, fp: &str) -> Option<&SignalClient> {
|
|
||||||
self.clients.get(fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a fingerprint is online.
|
|
||||||
pub fn is_online(&self, fp: &str) -> bool {
|
|
||||||
self.clients.contains_key(fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a signal message to a client by fingerprint.
|
|
||||||
pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> {
|
|
||||||
match self.clients.get(fp) {
|
|
||||||
Some(client) => {
|
|
||||||
client.transport.send_signal(msg).await
|
|
||||||
.map_err(|e| format!("send to {fp}: {e}"))
|
|
||||||
}
|
|
||||||
None => Err(format!("{fp} not online")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of connected signaling clients.
|
|
||||||
pub fn online_count(&self) -> usize {
|
|
||||||
self.clients.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all online fingerprints.
|
|
||||||
pub fn online_fingerprints(&self) -> Vec<&str> {
|
|
||||||
self.clients.keys().map(|s| s.as_str()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get alias for a fingerprint.
|
|
||||||
pub fn alias(&self, fp: &str) -> Option<&str> {
|
|
||||||
self.clients.get(fp).and_then(|c| c.alias.as_deref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn register_unregister() {
|
|
||||||
let hub = SignalHub::new();
|
|
||||||
assert_eq!(hub.online_count(), 0);
|
|
||||||
assert!(!hub.is_online("alice"));
|
|
||||||
|
|
||||||
// Can't easily construct QuinnTransport in a unit test,
|
|
||||||
// so we just test the HashMap logic conceptually.
|
|
||||||
// Integration tests cover the full flow.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,9 +16,6 @@ async-trait = { workspace = true }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
ed25519-dalek = { workspace = true }
|
|
||||||
hkdf = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
|||||||
@@ -6,74 +6,20 @@ use std::time::Duration;
|
|||||||
use quinn::crypto::rustls::QuicClientConfig;
|
use quinn::crypto::rustls::QuicClientConfig;
|
||||||
use quinn::crypto::rustls::QuicServerConfig;
|
use quinn::crypto::rustls::QuicServerConfig;
|
||||||
|
|
||||||
/// Create a server configuration with a self-signed certificate (random keypair).
|
/// Create a server configuration with a self-signed certificate (for testing).
|
||||||
///
|
///
|
||||||
/// The certificate changes on every call. Use `server_config_from_seed` for
|
/// Tunes QUIC transport parameters for lossy VoIP:
|
||||||
/// a deterministic certificate that survives relay restarts.
|
/// - 30s idle timeout
|
||||||
|
/// - 5s keep-alive interval
|
||||||
|
/// - DATAGRAM extension enabled
|
||||||
|
/// - Conservative flow control for bandwidth-constrained links
|
||||||
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
|
||||||
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
let cert_key = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||||
.expect("failed to generate self-signed cert");
|
.expect("failed to generate self-signed cert");
|
||||||
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
let cert_der = rustls::pki_types::CertificateDer::from(cert_key.cert);
|
||||||
let key_der =
|
let key_der =
|
||||||
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
rustls::pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()).unwrap();
|
||||||
build_server_config(cert_der, key_der)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a server configuration with a deterministic self-signed certificate
|
|
||||||
/// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint.
|
|
||||||
pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>) {
|
|
||||||
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
|
||||||
use ed25519_dalek::SigningKey;
|
|
||||||
use hkdf::Hkdf;
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
// Derive Ed25519 key bytes from seed via HKDF
|
|
||||||
let hk = Hkdf::<Sha256>::new(None, seed);
|
|
||||||
let mut ed_bytes = [0u8; 32];
|
|
||||||
hk.expand(b"wzp-tls-ed25519", &mut ed_bytes)
|
|
||||||
.expect("HKDF expand failed");
|
|
||||||
|
|
||||||
// Create Ed25519 signing key and export as PKCS8 DER
|
|
||||||
let signing_key = SigningKey::from_bytes(&ed_bytes);
|
|
||||||
let pkcs8_doc = signing_key.to_pkcs8_der()
|
|
||||||
.expect("failed to encode Ed25519 key as PKCS8");
|
|
||||||
let key_der_for_rcgen = rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec())
|
|
||||||
.expect("failed to wrap PKCS8 DER");
|
|
||||||
|
|
||||||
// Create rcgen KeyPair from DER
|
|
||||||
let key_pair = rcgen::KeyPair::from_der_and_sign_algo(
|
|
||||||
&key_der_for_rcgen,
|
|
||||||
&rcgen::PKCS_ED25519,
|
|
||||||
)
|
|
||||||
.expect("failed to create KeyPair from seed-derived Ed25519 key");
|
|
||||||
|
|
||||||
// Build self-signed cert with this deterministic keypair
|
|
||||||
let params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
|
|
||||||
.expect("failed to create CertificateParams");
|
|
||||||
let cert = params.self_signed(&key_pair).expect("failed to self-sign cert");
|
|
||||||
let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
|
|
||||||
let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der())
|
|
||||||
.expect("failed to serialize key DER");
|
|
||||||
|
|
||||||
build_server_config(cert_der, key_der)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute a hex-formatted SHA-256 fingerprint of a DER-encoded certificate.
|
|
||||||
///
|
|
||||||
/// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons).
|
|
||||||
pub fn tls_fingerprint(cert_der: &[u8]) -> String {
|
|
||||||
use sha2::{Sha256, Digest};
|
|
||||||
let hash = Sha256::digest(cert_der);
|
|
||||||
hash.iter()
|
|
||||||
.map(|b| format!("{b:02x}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_server_config(
|
|
||||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
|
||||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
|
||||||
) -> (quinn::ServerConfig, Vec<u8>) {
|
|
||||||
let mut server_crypto = rustls::ServerConfig::builder()
|
let mut server_crypto = rustls::ServerConfig::builder()
|
||||||
.with_no_client_auth()
|
.with_no_client_auth()
|
||||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||||
|
|||||||
@@ -22,13 +22,8 @@ pub mod path_monitor;
|
|||||||
pub mod quic;
|
pub mod quic;
|
||||||
pub mod reliable;
|
pub mod reliable;
|
||||||
|
|
||||||
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
|
pub use config::{client_config, server_config};
|
||||||
pub use connection::{accept, connect, create_endpoint};
|
pub use connection::{accept, connect, create_endpoint};
|
||||||
pub use path_monitor::PathMonitor;
|
pub use path_monitor::PathMonitor;
|
||||||
pub use quic::QuinnTransport;
|
pub use quic::QuinnTransport;
|
||||||
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
|
||||||
|
|
||||||
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can
|
|
||||||
// thread a shared endpoint between signaling and media connections without
|
|
||||||
// needing to depend on quinn directly.
|
|
||||||
pub use quinn::Endpoint;
|
|
||||||
|
|||||||
@@ -33,13 +33,6 @@ impl QuinnTransport {
|
|||||||
&self.connection
|
&self.connection
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send raw bytes as a QUIC datagram (no MediaPacket framing).
|
|
||||||
pub fn send_raw_datagram(&self, data: &[u8]) -> Result<(), TransportError> {
|
|
||||||
self.connection
|
|
||||||
.send_datagram(bytes::Bytes::copy_from_slice(data))
|
|
||||||
.map_err(|e| TransportError::Internal(format!("datagram: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close the QUIC connection immediately (synchronous, no async needed).
|
/// Close the QUIC connection immediately (synchronous, no async needed).
|
||||||
/// The relay will detect the close and remove this participant from the room.
|
/// The relay will detect the close and remove this participant from the room.
|
||||||
pub fn close_now(&self) {
|
pub fn close_now(&self) {
|
||||||
@@ -143,7 +136,7 @@ impl MediaTransport for QuinnTransport {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match datagram::deserialize_media(data.clone()) {
|
match datagram::deserialize_media(data) {
|
||||||
Some(packet) => {
|
Some(packet) => {
|
||||||
// Record receive observation
|
// Record receive observation
|
||||||
{
|
{
|
||||||
@@ -156,10 +149,8 @@ impl MediaTransport for QuinnTransport {
|
|||||||
Ok(Some(packet))
|
Ok(Some(packet))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing");
|
tracing::warn!("received malformed media datagram");
|
||||||
// Don't return Ok(None) — that signals connection closed.
|
Ok(None)
|
||||||
// Recurse to read the next datagram instead.
|
|
||||||
Box::pin(self.recv_media()).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,239 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<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="general" />
|
|
||||||
</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">
|
|
||||||
<div class="direct-registered-header">
|
|
||||||
<p style="color:var(--green);font-size:13px;margin:0">✅ Registered — waiting for calls</p>
|
|
||||||
<button id="deregister-btn" class="secondary-btn small">Deregister</button>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Recent contacts -->
|
|
||||||
<div id="recent-contacts-section" class="hidden">
|
|
||||||
<div class="history-header">Recent contacts</div>
|
|
||||||
<div id="recent-contacts-list" class="history-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Call history -->
|
|
||||||
<div id="call-history-section" class="hidden">
|
|
||||||
<div class="history-header">
|
|
||||||
History
|
|
||||||
<button id="clear-history-btn" class="link-btn">clear</button>
|
|
||||||
</div>
|
|
||||||
<div id="call-history-list" class="history-list"></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>
|
|
||||||
<label class="checkbox">
|
|
||||||
<input id="s-dred-debug" type="checkbox" />
|
|
||||||
DRED debug logs (verbose, dev only)
|
|
||||||
</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,107 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wzp-desktop"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
description = "WarzonePhone Desktop — encrypted VoIP client"
|
|
||||||
default-run = "wzp-desktop"
|
|
||||||
|
|
||||||
# Library target — required for Tauri mobile (Android/iOS link the app as a cdylib)
|
|
||||||
# and also used by the desktop binary below.
|
|
||||||
#
|
|
||||||
# `staticlib` was DROPPED from crate-type because rust-lang/rust#104707
|
|
||||||
# documents that having staticlib alongside cdylib leaks non-exported
|
|
||||||
# symbols from staticlibs into the cdylib. Bionic's private `__init_tcb`
|
|
||||||
# / `pthread_create` symbols end up bound LOCALLY inside our .so instead
|
|
||||||
# of resolved dynamically against libc.so at dlopen time — which crashes
|
|
||||||
# at launch as soon as tao tries to std::thread::spawn() from the JNI
|
|
||||||
# onCreate callback. The legacy wzp-android crate uses ["cdylib", "rlib"]
|
|
||||||
# and runs fine on the same phone with the same NDK + Rust toolchain.
|
|
||||||
#
|
|
||||||
# iOS Tauri builds that actually need staticlib can re-add it behind a
|
|
||||||
# target cfg if we ever ship on iOS.
|
|
||||||
[lib]
|
|
||||||
name = "wzp_desktop_lib"
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "wzp-desktop"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "2", features = [] }
|
|
||||||
# cc is no longer needed — all C++ moved to crates/wzp-native (built with
|
|
||||||
# cargo-ndk and loaded via libloading at runtime). wzp-desktop's .so on
|
|
||||||
# Android is now pure Rust.
|
|
||||||
|
|
||||||
[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 — protocol layer is platform-independent
|
|
||||||
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 pulls in CPAL on every desktop target and, additionally on
|
|
||||||
# macOS, VoiceProcessingIO (coreaudio-rs behind the "vpio" feature). The
|
|
||||||
# vpio feature MUST NOT be enabled on Windows / Linux because coreaudio-rs
|
|
||||||
# is Apple-framework-only and will fail to build. Task #24 will add a
|
|
||||||
# matching Windows Voice Capture DSP path behind its own feature; until
|
|
||||||
# then, Windows desktops use plain CPAL with AEC disabled.
|
|
||||||
|
|
||||||
# macOS: CPAL + VoiceProcessingIO (hardware AEC via Core Audio).
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "vpio"] }
|
|
||||||
|
|
||||||
# Windows: CPAL for playback + direct WASAPI for capture with OS-level
|
|
||||||
# AEC (AudioCategory_Communications). The wzp-client `windows-aec`
|
|
||||||
# feature swaps the default CPAL AudioCapture for a WASAPI one that
|
|
||||||
# opens the mic under AudioCategory_Communications, turning on Windows's
|
|
||||||
# communications audio processing chain (AEC, NS, AGC). The reference
|
|
||||||
# signal for AEC is the system render mix, so echo from our CPAL
|
|
||||||
# playback is cancelled automatically without extra plumbing.
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
|
||||||
wzp-client = { path = "../../crates/wzp-client", features = ["audio", "windows-aec"] }
|
|
||||||
|
|
||||||
# Linux: CPAL playback+capture baseline. AEC is enabled via the top-level
|
|
||||||
# `linux-aec` feature in wzp-desktop, which forwards to wzp-client/linux-aec.
|
|
||||||
# Keeping it opt-in at the wzp-desktop level (rather than forcing it always
|
|
||||||
# on here) lets `cargo tauri build` produce two variants from the same
|
|
||||||
# source tree — a noAEC baseline and an AEC build — by toggling the feature
|
|
||||||
# at build time: `cargo tauri build -- --features wzp-desktop/linux-aec`.
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
|
||||||
wzp-client = { path = "../../crates/wzp-client", features = ["audio"] }
|
|
||||||
|
|
||||||
# Android: no CPAL, no vpio — audio goes through the standalone wzp-native
|
|
||||||
# cdylib that we dlopen via libloading at runtime. See the wzp_native
|
|
||||||
# module in src/.
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
wzp-client = { path = "../../crates/wzp-client", default-features = false }
|
|
||||||
# libloading: runtime dlopen of libwzp_native.so — the standalone cdylib
|
|
||||||
# crate that owns all C++ (Oboe bridge). Keeps wzp-desktop's .so free of
|
|
||||||
# any C/C++ static archives that would otherwise leak bionic's internal
|
|
||||||
# pthread_create into our cdylib and trigger the __init_tcb crash.
|
|
||||||
libloading = "0.8"
|
|
||||||
# jni + ndk-context: called from android_audio.rs to invoke
|
|
||||||
# AudioManager.setSpeakerphoneOn on the JVM side at runtime, so the
|
|
||||||
# Oboe playout stream (opened with Usage::VoiceCommunication) can route
|
|
||||||
# between earpiece and loud speaker without restarting.
|
|
||||||
jni = "0.21"
|
|
||||||
ndk-context = "0.1"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["custom-protocol"]
|
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
|
||||||
# linux-aec: forwards to wzp-client/linux-aec so `cargo tauri build -- --features
|
|
||||||
# wzp-desktop/linux-aec` enables the WebRTC AEC3 backend on Linux. No-op on
|
|
||||||
# other targets because wzp-client/linux-aec is itself cfg(target_os = "linux").
|
|
||||||
linux-aec = ["wzp-client/linux-aec"]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<!--
|
|
||||||
Custom Info.plist keys merged into the bundled WarzonePhone.app by
|
|
||||||
tauri-bundler. The base Info.plist (CFBundleIdentifier, version,
|
|
||||||
etc.) is generated from tauri.conf.json — only put *additional*
|
|
||||||
keys here.
|
|
||||||
|
|
||||||
NSMicrophoneUsageDescription is required by macOS TCC for any
|
|
||||||
app that opens an audio input unit. Without this string the OS
|
|
||||||
silently denies CoreAudio capture (input callbacks return zeros)
|
|
||||||
and the app never appears in System Settings → Privacy &
|
|
||||||
Security → Microphone. This was the root cause of the desktop
|
|
||||||
mic regression where phones could not hear the desktop client.
|
|
||||||
-->
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>WarzonePhone needs microphone access to transmit your voice during calls.</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// Capture short git hash so the running app can prove which build it is.
|
|
||||||
// Falls back to "unknown" if git isn't available (e.g. when building from
|
|
||||||
// a tarball without a .git dir).
|
|
||||||
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");
|
|
||||||
|
|
||||||
// No cc::Build of ANY kind on Android — all C++ lives in the standalone
|
|
||||||
// `wzp-native` crate which is built separately with cargo-ndk and loaded
|
|
||||||
// via libloading at runtime. See docs/incident-tauri-android-init-tcb.md
|
|
||||||
// for why this split exists.
|
|
||||||
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
|
||||||
"identifier": "default",
|
|
||||||
"description": "Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.",
|
|
||||||
"windows": ["main"],
|
|
||||||
"platforms": [
|
|
||||||
"linux",
|
|
||||||
"macOS",
|
|
||||||
"windows",
|
|
||||||
"android",
|
|
||||||
"iOS"
|
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
"core:default",
|
|
||||||
"core:event:default",
|
|
||||||
"core:event:allow-listen",
|
|
||||||
"core:event:allow-unlisten",
|
|
||||||
"core:event:allow-emit",
|
|
||||||
"core:event:allow-emit-to",
|
|
||||||
"core:path:default",
|
|
||||||
"core:window:default",
|
|
||||||
"core:app:default",
|
|
||||||
"core:webview:default",
|
|
||||||
"shell:default"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
||||||
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.wzp_desktop"
|
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
|
||||||
<activity
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:label="@string/main_activity_title"
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
<!-- AndroidTV support -->
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package com.wzp.desktop
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "WzpMainActivity"
|
|
||||||
private const val AUDIO_PERMISSIONS_REQUEST = 4242
|
|
||||||
private val REQUIRED_AUDIO_PERMISSIONS = arrayOf(
|
|
||||||
Manifest.permission.RECORD_AUDIO,
|
|
||||||
Manifest.permission.MODIFY_AUDIO_SETTINGS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
// Request RECORD_AUDIO early so Oboe (inside libwzp_native.so) can open
|
|
||||||
// the AAudio input stream without silently failing. The grant is
|
|
||||||
// persisted, so after the first launch the dialog no longer appears.
|
|
||||||
// MODIFY_AUDIO_SETTINGS is needed to switch AudioManager mode + speaker.
|
|
||||||
val needsRequest = REQUIRED_AUDIO_PERMISSIONS.any {
|
|
||||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
if (needsRequest) {
|
|
||||||
Log.i(TAG, "requesting audio permissions")
|
|
||||||
ActivityCompat.requestPermissions(this, REQUIRED_AUDIO_PERMISSIONS, AUDIO_PERMISSIONS_REQUEST)
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "audio permissions already granted")
|
|
||||||
configureAudioForCall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(
|
|
||||||
requestCode: Int,
|
|
||||||
permissions: Array<String>,
|
|
||||||
grantResults: IntArray
|
|
||||||
) {
|
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
||||||
if (requestCode == AUDIO_PERMISSIONS_REQUEST) {
|
|
||||||
val allGranted = grantResults.isNotEmpty() &&
|
|
||||||
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
|
||||||
Log.i(TAG, "audio permissions result: allGranted=$allGranted grants=${grantResults.toList()}")
|
|
||||||
if (allGranted) {
|
|
||||||
configureAudioForCall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put the phone into VoIP call mode with handset (earpiece) as the
|
|
||||||
* default output. The Oboe playout stream is opened with
|
|
||||||
* Usage::VoiceCommunication which honours this routing, so:
|
|
||||||
*
|
|
||||||
* MODE_IN_COMMUNICATION + speakerphoneOn=false → earpiece (handset)
|
|
||||||
* MODE_IN_COMMUNICATION + speakerphoneOn=true → loudspeaker
|
|
||||||
* MODE_IN_COMMUNICATION + bluetoothScoOn=true → bluetooth headset
|
|
||||||
*
|
|
||||||
* The speaker/handset/BT toggle itself is wired up via the Tauri
|
|
||||||
* command `set_speakerphone(on)` in a follow-up build. For now the
|
|
||||||
* default is handset, matching the user's stated preference.
|
|
||||||
*
|
|
||||||
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
|
|
||||||
* slider is separate from media volume on most devices.
|
|
||||||
*/
|
|
||||||
private fun configureAudioForCall() {
|
|
||||||
try {
|
|
||||||
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
||||||
Log.i(TAG, "audio state before: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
|
||||||
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
|
|
||||||
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
|
|
||||||
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
|
|
||||||
"${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
|
|
||||||
|
|
||||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
||||||
am.isSpeakerphoneOn = false // default: handset / earpiece
|
|
||||||
|
|
||||||
// Crank both voice-call and music volumes so nothing silent slips
|
|
||||||
// through regardless of which stream actually ends up driving.
|
|
||||||
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
|
|
||||||
am.setStreamVolume(AudioManager.STREAM_VOICE_CALL, maxVoice, 0)
|
|
||||||
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
|
||||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
|
|
||||||
|
|
||||||
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
|
|
||||||
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
|
|
||||||
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"default":{"identifier":"default","description":"Default capability — grants core APIs (events, path, window, app, clipboard) to the main window on every platform we ship to.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit","core:event:allow-emit-to","core:path:default","core:window:default","core:app:default","core:webview:default","shell:default"],"platforms":["linux","macOS","windows","android","iOS"]}}
|
|
||||||
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: 2.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 104 B |
@@ -1,98 +0,0 @@
|
|||||||
//! Runtime bridge to Android's `AudioManager` for in-call audio routing.
|
|
||||||
//!
|
|
||||||
//! We own a quinn+Oboe VoIP pipeline entirely from Rust, but routing the
|
|
||||||
//! playout stream between earpiece / loudspeaker / Bluetooth headset has to
|
|
||||||
//! happen at the JVM level because those toggles are AudioManager-only.
|
|
||||||
//! This module uses the global JavaVM handle that `ndk_context` exposes
|
|
||||||
//! (populated by Tauri's mobile runtime) + the `jni` crate to reach into
|
|
||||||
//! the Android framework without needing a Tauri plugin.
|
|
||||||
//!
|
|
||||||
//! All callers must be inside an Android target (`#[cfg(target_os = "android")]`).
|
|
||||||
|
|
||||||
#![cfg(target_os = "android")]
|
|
||||||
|
|
||||||
use jni::objects::{JObject, JString, JValue};
|
|
||||||
use jni::JavaVM;
|
|
||||||
|
|
||||||
/// Grab the JavaVM + current Activity from the ndk_context that Tauri's
|
|
||||||
/// mobile runtime sets up at process startup.
|
|
||||||
fn jvm_and_activity() -> Result<(JavaVM, JObject<'static>), String> {
|
|
||||||
let ctx = ndk_context::android_context();
|
|
||||||
let vm_ptr = ctx.vm() as *mut jni::sys::JavaVM;
|
|
||||||
if vm_ptr.is_null() {
|
|
||||||
return Err("ndk_context: JavaVM pointer is null".into());
|
|
||||||
}
|
|
||||||
let vm = unsafe { JavaVM::from_raw(vm_ptr) }
|
|
||||||
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
|
||||||
let activity_ptr = ctx.context() as jni::sys::jobject;
|
|
||||||
if activity_ptr.is_null() {
|
|
||||||
return Err("ndk_context: activity pointer is null".into());
|
|
||||||
}
|
|
||||||
// SAFETY: ndk_context guarantees the pointer lives for the process
|
|
||||||
// lifetime; we wrap it as a JObject<'static> for convenience.
|
|
||||||
let activity: JObject<'static> = unsafe { JObject::from_raw(activity_ptr) };
|
|
||||||
Ok((vm, activity))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Android's `AudioManager` via `activity.getSystemService("audio")`.
|
|
||||||
fn audio_manager<'local>(
|
|
||||||
env: &mut jni::AttachGuard<'local>,
|
|
||||||
activity: &JObject<'local>,
|
|
||||||
) -> Result<JObject<'local>, String> {
|
|
||||||
let svc_name: JString<'local> = env
|
|
||||||
.new_string("audio")
|
|
||||||
.map_err(|e| format!("new_string(audio): {e}"))?;
|
|
||||||
let am = env
|
|
||||||
.call_method(
|
|
||||||
activity,
|
|
||||||
"getSystemService",
|
|
||||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
|
||||||
&[JValue::Object(&svc_name)],
|
|
||||||
)
|
|
||||||
.and_then(|v| v.l())
|
|
||||||
.map_err(|e| format!("getSystemService(audio): {e}"))?;
|
|
||||||
if am.is_null() {
|
|
||||||
return Err("getSystemService returned null".into());
|
|
||||||
}
|
|
||||||
Ok(am)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Switch between loud speaker (`true`) and earpiece/handset (`false`).
|
|
||||||
///
|
|
||||||
/// Calls `AudioManager.setSpeakerphoneOn(on)` on the JVM. Requires that
|
|
||||||
/// the audio mode is already `MODE_IN_COMMUNICATION` — MainActivity.kt
|
|
||||||
/// sets this at startup, so by the time a call is up this is always true.
|
|
||||||
pub fn set_speakerphone(on: bool) -> Result<(), String> {
|
|
||||||
let (vm, activity) = jvm_and_activity()?;
|
|
||||||
let mut env = vm
|
|
||||||
.attach_current_thread()
|
|
||||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
||||||
let am = audio_manager(&mut env, &activity)?;
|
|
||||||
|
|
||||||
env.call_method(
|
|
||||||
&am,
|
|
||||||
"setSpeakerphoneOn",
|
|
||||||
"(Z)V",
|
|
||||||
&[JValue::Bool(if on { 1 } else { 0 })],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("setSpeakerphoneOn({on}): {e}"))?;
|
|
||||||
|
|
||||||
tracing::info!(on, "AudioManager.setSpeakerphoneOn");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query the current speakerphone state. Returns true if routing is on the
|
|
||||||
/// loud speaker, false if on earpiece / BT headset / wired headset.
|
|
||||||
pub fn is_speakerphone_on() -> Result<bool, String> {
|
|
||||||
let (vm, activity) = jvm_and_activity()?;
|
|
||||||
let mut env = vm
|
|
||||||
.attach_current_thread()
|
|
||||||
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
||||||
let am = audio_manager(&mut env, &activity)?;
|
|
||||||
|
|
||||||
let on = env
|
|
||||||
.call_method(&am, "isSpeakerphoneOn", "()Z", &[])
|
|
||||||
.and_then(|v| v.z())
|
|
||||||
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
|
|
||||||
Ok(on)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,180 +0,0 @@
|
|||||||
//! Call history store.
|
|
||||||
//!
|
|
||||||
//! Keeps a rolling JSON file of the last N direct-call events so the UI can
|
|
||||||
//! show "recent contacts" + "call history with callback buttons" on the
|
|
||||||
//! direct-call screen. Storage lives in `<APP_DATA_DIR>/call_history.json`
|
|
||||||
//! alongside the identity file. The file is read lazily on first access and
|
|
||||||
//! cached in an RwLock behind a OnceLock.
|
|
||||||
//!
|
|
||||||
//! This is a v1 — no duration tracking yet, entries are logged at the
|
|
||||||
//! moment the direction is decided (placed / received / missed).
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{OnceLock, RwLock};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Maximum number of history entries we keep. Older ones are pruned FIFO.
|
|
||||||
const MAX_ENTRIES: usize = 200;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum CallDirection {
|
|
||||||
/// Local user placed the call.
|
|
||||||
Placed,
|
|
||||||
/// Remote user called and local user answered.
|
|
||||||
Received,
|
|
||||||
/// Remote user called but local user did not answer (rejected or
|
|
||||||
/// missed entirely — the UI treats these identically).
|
|
||||||
Missed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CallHistoryEntry {
|
|
||||||
pub call_id: String,
|
|
||||||
pub peer_fp: String,
|
|
||||||
pub peer_alias: Option<String>,
|
|
||||||
pub direction: CallDirection,
|
|
||||||
/// Seconds since UNIX epoch, UTC.
|
|
||||||
pub timestamp_unix: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── In-process store (loaded from disk once) ─────────────────────────────
|
|
||||||
|
|
||||||
static STORE: OnceLock<RwLock<Vec<CallHistoryEntry>>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn store() -> &'static RwLock<Vec<CallHistoryEntry>> {
|
|
||||||
STORE.get_or_init(|| RwLock::new(load_from_disk()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn history_path() -> PathBuf {
|
|
||||||
crate::APP_DATA_DIR
|
|
||||||
.get()
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
|
||||||
PathBuf::from(home).join(".wzp")
|
|
||||||
})
|
|
||||||
.join("call_history.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_from_disk() -> Vec<CallHistoryEntry> {
|
|
||||||
let path = history_path();
|
|
||||||
let Ok(bytes) = std::fs::read(&path) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
serde_json::from_slice::<Vec<CallHistoryEntry>>(&bytes)
|
|
||||||
.inspect_err(|e| tracing::warn!(path = %path.display(), error = %e, "call_history.json parse failed"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_to_disk(entries: &[CallHistoryEntry]) {
|
|
||||||
let path = history_path();
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
let Ok(json) = serde_json::to_vec_pretty(entries) else { return };
|
|
||||||
// Atomic write via temp file + rename so a crash mid-write doesn't
|
|
||||||
// leave us with a half-file on disk.
|
|
||||||
let tmp = path.with_extension("json.tmp");
|
|
||||||
if std::fs::write(&tmp, &json).is_ok() {
|
|
||||||
let _ = std::fs::rename(&tmp, &path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_unix() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Append a new entry to the store and persist to disk. Trims the store to
|
|
||||||
/// `MAX_ENTRIES` after insertion.
|
|
||||||
pub fn log(
|
|
||||||
call_id: String,
|
|
||||||
peer_fp: String,
|
|
||||||
peer_alias: Option<String>,
|
|
||||||
direction: CallDirection,
|
|
||||||
) {
|
|
||||||
tracing::info!(
|
|
||||||
%call_id, %peer_fp, ?direction,
|
|
||||||
alias = ?peer_alias,
|
|
||||||
"history::log"
|
|
||||||
);
|
|
||||||
let entry = CallHistoryEntry {
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
peer_fp,
|
|
||||||
peer_alias,
|
|
||||||
direction,
|
|
||||||
timestamp_unix: now_unix(),
|
|
||||||
};
|
|
||||||
let mut guard = store().write().unwrap();
|
|
||||||
// If an entry for this call_id already exists, update it in-place
|
|
||||||
// rather than appending a duplicate. Protects against the caller
|
|
||||||
// side adding a second Missed row when the callee's DirectCallOffer
|
|
||||||
// bounces back through federation / loopback, or when some future
|
|
||||||
// relay routing edge case double-emits a signal. The dedup keeps
|
|
||||||
// history tidy and matches what the user intuitively expects (one
|
|
||||||
// history row per call, not one per signal event).
|
|
||||||
if let Some(existing) = guard.iter_mut().rev().find(|e| e.call_id == call_id) {
|
|
||||||
tracing::info!(%call_id, from = ?existing.direction, to = ?direction, "history::log replacing existing entry");
|
|
||||||
existing.direction = direction;
|
|
||||||
existing.timestamp_unix = entry.timestamp_unix;
|
|
||||||
save_to_disk(&guard);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
guard.push(entry);
|
|
||||||
if guard.len() > MAX_ENTRIES {
|
|
||||||
let drop_n = guard.len() - MAX_ENTRIES;
|
|
||||||
guard.drain(0..drop_n);
|
|
||||||
}
|
|
||||||
save_to_disk(&guard);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a copy of all entries in reverse-chronological order
|
|
||||||
/// (most recent first).
|
|
||||||
pub fn all() -> Vec<CallHistoryEntry> {
|
|
||||||
let guard = store().read().unwrap();
|
|
||||||
guard.iter().rev().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unique peer contacts sorted by most recent interaction. Each contact
|
|
||||||
/// is represented by the newest history entry for that fingerprint.
|
|
||||||
pub fn contacts() -> Vec<CallHistoryEntry> {
|
|
||||||
let guard = store().read().unwrap();
|
|
||||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
// iterate newest → oldest
|
|
||||||
for entry in guard.iter().rev() {
|
|
||||||
if seen.insert(entry.peer_fp.clone()) {
|
|
||||||
out.push(entry.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the entire history and persist the empty file.
|
|
||||||
pub fn clear() {
|
|
||||||
let mut guard = store().write().unwrap();
|
|
||||||
guard.clear();
|
|
||||||
save_to_disk(&guard);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find a Missed-candidate entry that matches `call_id` and hasn't been
|
|
||||||
/// answered yet. Used by the signal loop to turn "pending incoming" into
|
|
||||||
/// "Received" when the user accepts.
|
|
||||||
pub fn mark_received_if_pending(call_id: &str) -> bool {
|
|
||||||
let mut guard = store().write().unwrap();
|
|
||||||
for entry in guard.iter_mut().rev() {
|
|
||||||
if entry.call_id == call_id && entry.direction == CallDirection::Missed {
|
|
||||||
entry.direction = CallDirection::Received;
|
|
||||||
save_to_disk(&guard);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
@@ -1,717 +0,0 @@
|
|||||||
// WarzonePhone Tauri backend — shared between desktop (macOS/Windows/Linux)
|
|
||||||
// and Tauri mobile (Android/iOS). Platform-specific audio is cfg-gated.
|
|
||||||
|
|
||||||
#![cfg_attr(
|
|
||||||
all(not(debug_assertions), target_os = "windows"),
|
|
||||||
windows_subsystem = "windows"
|
|
||||||
)]
|
|
||||||
|
|
||||||
// Call engine — now compiled on every platform. On desktop it runs the real
|
|
||||||
// CPAL/VPIO audio pipeline; on Android the engine calls into the standalone
|
|
||||||
// wzp-native cdylib (via the wzp_native module) for Oboe-backed audio.
|
|
||||||
mod engine;
|
|
||||||
|
|
||||||
// Android runtime binding to libwzp_native.so (Oboe audio backend, built as
|
|
||||||
// a standalone cdylib with cargo-ndk to avoid the Tauri staticlib symbol
|
|
||||||
// leak — see docs/incident-tauri-android-init-tcb.md).
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
mod wzp_native;
|
|
||||||
|
|
||||||
// Android AudioManager bridge (routing earpiece / speaker / BT).
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
mod android_audio;
|
|
||||||
|
|
||||||
// Direct-call history store (persisted JSON in app data dir).
|
|
||||||
mod history;
|
|
||||||
|
|
||||||
// CallEngine has a unified impl on both targets now — the Android branch of
|
|
||||||
// CallEngine::start() routes audio through the standalone wzp-native cdylib
|
|
||||||
// (loaded via the wzp_native module below), the desktop branch uses CPAL.
|
|
||||||
use engine::CallEngine;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, OnceLock};
|
|
||||||
use tauri::{Emitter, Manager};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use wzp_proto::MediaTransport;
|
|
||||||
|
|
||||||
/// Short git hash captured at compile time by build.rs.
|
|
||||||
const GIT_HASH: &str = env!("WZP_GIT_HASH");
|
|
||||||
|
|
||||||
/// Resolved by `setup()` once we have a Tauri AppHandle. Holds the
|
|
||||||
/// platform-correct app data dir (e.g. `/data/data/com.wzp.desktop/files` on
|
|
||||||
/// Android, `~/Library/Application Support/com.wzp.desktop` on macOS).
|
|
||||||
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Adjective list — keep in sync with the noun list below. Both are powers of
|
|
||||||
/// 2 friendly so the modulo bias is negligible.
|
|
||||||
const ALIAS_ADJECTIVES: &[&str] = &[
|
|
||||||
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
|
|
||||||
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
|
|
||||||
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
|
|
||||||
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
|
|
||||||
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc",
|
|
||||||
];
|
|
||||||
const ALIAS_NOUNS: &[&str] = &[
|
|
||||||
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
|
|
||||||
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
|
|
||||||
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
|
|
||||||
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
|
|
||||||
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Derive a stable human-readable alias from the seed bytes. Same seed →
|
|
||||||
/// same alias forever, different seeds → effectively random aliases.
|
|
||||||
fn derive_alias(seed: &wzp_crypto::Seed) -> String {
|
|
||||||
let adj_idx = (u16::from_le_bytes([seed.0[0], seed.0[1]]) as usize) % ALIAS_ADJECTIVES.len();
|
|
||||||
let noun_idx = (u16::from_le_bytes([seed.0[2], seed.0[3]]) as usize) % ALIAS_NOUNS.len();
|
|
||||||
format!("{} {}", ALIAS_ADJECTIVES[adj_idx], ALIAS_NOUNS[noun_idx])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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>>,
|
|
||||||
signal: Arc<Mutex<SignalState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle DRED verbose logging at runtime (gates the chatty per-frame
|
|
||||||
/// reconstruction + parse logs in opus_enc and engine.rs). Wired to the
|
|
||||||
/// "DRED debug logs" checkbox in the GUI settings panel.
|
|
||||||
#[tauri::command]
|
|
||||||
fn set_dred_verbose_logs(enabled: bool) {
|
|
||||||
wzp_codec::set_dred_verbose_logs(enabled);
|
|
||||||
tracing::info!(enabled, "DRED verbose logs toggled");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the current DRED verbose logging flag (so the GUI can hydrate
|
|
||||||
/// its checkbox on startup without trusting localStorage alone).
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_dred_verbose_logs() -> bool {
|
|
||||||
wzp_codec::dred_verbose_logs()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the directory where identity/config should live.
|
|
||||||
///
|
|
||||||
/// Resolved at startup from Tauri's `path().app_data_dir()` API which gives
|
|
||||||
/// us the platform-correct app-private location:
|
|
||||||
/// - Android: `/data/data/<package_id>/files/com.wzp.desktop`
|
|
||||||
/// - macOS: `~/Library/Application Support/com.wzp.desktop`
|
|
||||||
/// - Linux: `~/.local/share/com.wzp.desktop`
|
|
||||||
///
|
|
||||||
/// Falls back to `$HOME/.wzp` on the desktop side if the OnceLock hasn't been
|
|
||||||
/// initialised yet (shouldn't happen in normal startup, but keeps the fn
|
|
||||||
/// total).
|
|
||||||
fn identity_dir() -> PathBuf {
|
|
||||||
if let Some(dir) = APP_DATA_DIR.get() {
|
|
||||||
return dir.clone();
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
// Last-resort default. The real path is set in setup() below.
|
|
||||||
std::path::PathBuf::from("/data/data/com.wzp.desktop/files")
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
|
||||||
std::path::PathBuf::from(home).join(".wzp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identity_path() -> std::path::PathBuf {
|
|
||||||
identity_dir().join("identity")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the persisted seed, or generate-and-persist a new one if missing.
|
|
||||||
fn load_or_create_seed() -> Result<wzp_crypto::Seed, String> {
|
|
||||||
let path = identity_path();
|
|
||||||
if path.exists() {
|
|
||||||
let hex = std::fs::read_to_string(&path).map_err(|e| format!("read identity: {e}"))?;
|
|
||||||
return wzp_crypto::Seed::from_hex(hex.trim()).map_err(|e| format!("{e}"));
|
|
||||||
}
|
|
||||||
let seed = wzp_crypto::Seed::generate();
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("create identity dir: {e}"))?;
|
|
||||||
}
|
|
||||||
let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect();
|
|
||||||
std::fs::write(&path, hex).map_err(|e| format!("write identity: {e}"))?;
|
|
||||||
Ok(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read fingerprint, generating a fresh identity if none exists yet.
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_identity() -> Result<String, String> {
|
|
||||||
let seed = load_or_create_seed()?;
|
|
||||||
Ok(seed.derive_identity().public_identity().fingerprint.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build/identity info shown on the home screen so the user can prove which
|
|
||||||
/// build is installed and what their stable alias is.
|
|
||||||
#[derive(Clone, Serialize)]
|
|
||||||
struct AppInfo {
|
|
||||||
/// Short git commit hash captured at build time.
|
|
||||||
git_hash: &'static str,
|
|
||||||
/// Stable adjective+noun derived from the seed.
|
|
||||||
alias: String,
|
|
||||||
/// Full fingerprint, e.g. "abcd:ef01:..."
|
|
||||||
fingerprint: String,
|
|
||||||
/// App data dir actually in use — useful for debugging EACCES issues.
|
|
||||||
data_dir: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_app_info() -> Result<AppInfo, String> {
|
|
||||||
let seed = load_or_create_seed()?;
|
|
||||||
let pub_id = seed.derive_identity().public_identity();
|
|
||||||
Ok(AppInfo {
|
|
||||||
git_hash: GIT_HASH,
|
|
||||||
alias: derive_alias(&seed),
|
|
||||||
fingerprint: pub_id.fingerprint.to_string(),
|
|
||||||
data_dir: identity_dir().to_string_lossy().into_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we previously opened a quinn::Endpoint for the signaling connection
|
|
||||||
// (direct-call path), reuse it so the media connection shares the same
|
|
||||||
// UDP socket. This side-steps the Android issue where a second
|
|
||||||
// quinn::Endpoint silently hangs in the QUIC handshake.
|
|
||||||
let reuse_endpoint = state.signal.lock().await.endpoint.clone();
|
|
||||||
if reuse_endpoint.is_some() {
|
|
||||||
tracing::info!("connect: reusing existing signal endpoint for media connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
match CallEngine::start(relay, room, alias, os_aec, quality, reuse_endpoint, 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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Audio routing (Android-specific, no-op on desktop) ─────────────────────
|
|
||||||
|
|
||||||
/// Switch the call audio between earpiece (`on=false`) and loudspeaker
|
|
||||||
/// (`on=true`). On Android this calls AudioManager.setSpeakerphoneOn via
|
|
||||||
/// JNI AND then stops and restarts the Oboe streams so AAudio reconfigures
|
|
||||||
/// with the new routing — without the restart, changing the speakerphone
|
|
||||||
/// state mid-call silently tears down the running AAudio streams on some
|
|
||||||
/// OEMs and both capture + playout stop producing data.
|
|
||||||
///
|
|
||||||
/// The Rust send/recv tokio tasks keep running during the ~60ms restart
|
|
||||||
/// window; they just observe empty reads / writes against the
|
|
||||||
/// process-global ring buffers, which is fine because the ring state
|
|
||||||
/// is preserved across stop+start.
|
|
||||||
#[tauri::command]
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
async fn set_speakerphone(on: bool) -> Result<(), String> {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
android_audio::set_speakerphone(on)?;
|
|
||||||
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
|
|
||||||
tracing::info!(on, "set_speakerphone: restarting Oboe for route change");
|
|
||||||
// Oboe's stop/start are sync C-FFI calls that block for ~400ms
|
|
||||||
// on Nothing-class devices (Pixel is faster). Calling them
|
|
||||||
// directly from an async Tauri command stalls the tokio
|
|
||||||
// executor — the send/recv engine tasks were observed to
|
|
||||||
// freeze for ~20 seconds across a few rapid speaker toggles,
|
|
||||||
// piling up buffered QUIC datagrams and then flooding them
|
|
||||||
// all at once when the runtime finally caught up.
|
|
||||||
//
|
|
||||||
// Fix: run the audio teardown + reopen on a dedicated
|
|
||||||
// blocking thread so the runtime keeps scheduling everything
|
|
||||||
// else. AAudio's requestStop returns only after the stream
|
|
||||||
// is actually in Stopped state, so no explicit inter-call
|
|
||||||
// sleep is needed.
|
|
||||||
tokio::task::spawn_blocking(|| {
|
|
||||||
wzp_native::audio_stop();
|
|
||||||
wzp_native::audio_start()
|
|
||||||
.map_err(|code| format!("audio_start after speakerphone toggle: code {code}"))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("spawn_blocking join: {e}"))??;
|
|
||||||
tracing::info!("set_speakerphone: Oboe restarted");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query whether the call is currently routed to the loudspeaker.
|
|
||||||
#[tauri::command]
|
|
||||||
async fn is_speakerphone_on() -> Result<bool, String> {
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
android_audio::is_speakerphone_on()
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
{
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Call history commands ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_call_history() -> Vec<history::CallHistoryEntry> {
|
|
||||||
history::all()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_recent_contacts() -> Vec<history::CallHistoryEntry> {
|
|
||||||
history::contacts()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn clear_call_history() -> Result<(), String> {
|
|
||||||
history::clear();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Signaling commands — platform independent ───────────────────────────────
|
|
||||||
|
|
||||||
struct SignalState {
|
|
||||||
transport: Option<Arc<wzp_transport::QuinnTransport>>,
|
|
||||||
/// The quinn::Endpoint backing the signal connection. Reused for the
|
|
||||||
/// media connection when a direct call is accepted — Android phones
|
|
||||||
/// silently drop packets from a second quinn::Endpoint to the same
|
|
||||||
/// relay, so every call after register_signal MUST share this socket.
|
|
||||||
endpoint: Option<wzp_transport::Endpoint>,
|
|
||||||
fingerprint: String,
|
|
||||||
signal_status: String,
|
|
||||||
incoming_call_id: Option<String>,
|
|
||||||
incoming_caller_fp: Option<String>,
|
|
||||||
incoming_caller_alias: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn register_signal(
|
|
||||||
state: tauri::State<'_, Arc<AppState>>,
|
|
||||||
app: tauri::AppHandle,
|
|
||||||
relay: String,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
use wzp_proto::SignalMessage;
|
|
||||||
|
|
||||||
let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?;
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
// Load or create seed automatically — no need to "connect to a room first"
|
|
||||||
let seed = load_or_create_seed()?;
|
|
||||||
let pub_id = seed.derive_identity().public_identity();
|
|
||||||
let fp = pub_id.fingerprint.to_string();
|
|
||||||
let identity_pub = *pub_id.signing.as_bytes();
|
|
||||||
|
|
||||||
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 conn = wzp_transport::connect(&endpoint, addr, "_signal", wzp_transport::client_config())
|
|
||||||
.await.map_err(|e| format!("{e}"))?;
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
|
||||||
|
|
||||||
transport.send_signal(&SignalMessage::RegisterPresence {
|
|
||||||
identity_pub, signature: vec![], alias: None,
|
|
||||||
}).await.map_err(|e| format!("{e}"))?;
|
|
||||||
|
|
||||||
match transport.recv_signal().await.map_err(|e| format!("{e}"))? {
|
|
||||||
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {}
|
|
||||||
_ => return Err("registration failed".into()),
|
|
||||||
}
|
|
||||||
|
|
||||||
{ let mut sig = state.signal.lock().await; sig.transport = Some(transport.clone()); sig.endpoint = Some(endpoint.clone()); sig.fingerprint = fp.clone(); sig.signal_status = "registered".into(); }
|
|
||||||
|
|
||||||
tracing::info!(%fp, "signal registered, spawning recv loop");
|
|
||||||
let signal_state = Arc::clone(&state.signal);
|
|
||||||
let app_clone = app.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match transport.recv_signal().await {
|
|
||||||
Ok(Some(SignalMessage::CallRinging { call_id })) => {
|
|
||||||
tracing::info!(%call_id, "signal: CallRinging");
|
|
||||||
let mut sig = signal_state.lock().await; sig.signal_status = "ringing".into();
|
|
||||||
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"ringing","call_id":call_id}));
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => {
|
|
||||||
tracing::info!(%call_id, caller = %caller_fingerprint, "signal: DirectCallOffer");
|
|
||||||
let mut sig = signal_state.lock().await; sig.signal_status = "incoming".into();
|
|
||||||
sig.incoming_call_id = Some(call_id.clone()); sig.incoming_caller_fp = Some(caller_fingerprint.clone()); sig.incoming_caller_alias = caller_alias.clone();
|
|
||||||
// Log as a Missed entry up-front. If the user accepts
|
|
||||||
// the call, answer_call upgrades it to Received via
|
|
||||||
// history::mark_received_if_pending(call_id). If they
|
|
||||||
// reject or ignore, it stays Missed.
|
|
||||||
history::log(
|
|
||||||
call_id.clone(),
|
|
||||||
caller_fingerprint.clone(),
|
|
||||||
caller_alias.clone(),
|
|
||||||
history::CallDirection::Missed,
|
|
||||||
);
|
|
||||||
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias}));
|
|
||||||
let _ = app_clone.emit("history-changed", ());
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
|
|
||||||
tracing::info!(%call_id, ?accept_mode, "signal: DirectCallAnswer (forwarded by relay)");
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => {
|
|
||||||
tracing::info!(%call_id, %room, %relay_addr, "signal: CallSetup — emitting setup event to JS");
|
|
||||||
let mut sig = signal_state.lock().await; sig.signal_status = "setup".into();
|
|
||||||
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"setup","call_id":call_id,"room":room,"relay_addr":relay_addr}));
|
|
||||||
}
|
|
||||||
Ok(Some(SignalMessage::Hangup { reason })) => {
|
|
||||||
tracing::info!(?reason, "signal: Hangup");
|
|
||||||
let mut sig = signal_state.lock().await; sig.signal_status = "registered".into(); sig.incoming_call_id = None;
|
|
||||||
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"hangup"}));
|
|
||||||
}
|
|
||||||
Ok(Some(other)) => {
|
|
||||||
tracing::debug!(?other, "signal: unhandled message");
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::warn!("signal recv returned None — peer closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "signal recv error — breaking loop");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::warn!("signal recv loop exited — signal_status=idle, transport dropped");
|
|
||||||
let mut sig = signal_state.lock().await; sig.signal_status = "idle".into(); sig.transport = None;
|
|
||||||
});
|
|
||||||
Ok(fp)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn place_call(
|
|
||||||
state: tauri::State<'_, Arc<AppState>>,
|
|
||||||
app: tauri::AppHandle,
|
|
||||||
target_fp: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use wzp_proto::SignalMessage;
|
|
||||||
let sig = state.signal.lock().await;
|
|
||||||
let transport = sig.transport.as_ref().ok_or("not registered")?;
|
|
||||||
let call_id = format!("{:016x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
|
|
||||||
tracing::info!(%call_id, %target_fp, "place_call: sending DirectCallOffer");
|
|
||||||
transport.send_signal(&SignalMessage::DirectCallOffer {
|
|
||||||
caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp.clone(),
|
|
||||||
call_id: call_id.clone(), identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![],
|
|
||||||
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
|
|
||||||
}).await.map_err(|e| format!("{e}"))?;
|
|
||||||
history::log(call_id, target_fp, None, history::CallDirection::Placed);
|
|
||||||
let _ = app.emit("history-changed", ());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn answer_call(
|
|
||||||
state: tauri::State<'_, Arc<AppState>>,
|
|
||||||
app: tauri::AppHandle,
|
|
||||||
call_id: String,
|
|
||||||
mode: i32,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use wzp_proto::SignalMessage;
|
|
||||||
let sig = state.signal.lock().await;
|
|
||||||
let transport = sig.transport.as_ref().ok_or_else(|| {
|
|
||||||
tracing::warn!("answer_call: not registered (no transport)");
|
|
||||||
"not registered".to_string()
|
|
||||||
})?;
|
|
||||||
let accept_mode = match mode { 0 => wzp_proto::CallAcceptMode::Reject, 1 => wzp_proto::CallAcceptMode::AcceptTrusted, _ => wzp_proto::CallAcceptMode::AcceptGeneric };
|
|
||||||
tracing::info!(%call_id, ?accept_mode, "answer_call: sending DirectCallAnswer");
|
|
||||||
transport.send_signal(&SignalMessage::DirectCallAnswer {
|
|
||||||
call_id: call_id.clone(), accept_mode, identity_pub: None, ephemeral_pub: None, signature: None,
|
|
||||||
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
|
|
||||||
}).await.map_err(|e| {
|
|
||||||
tracing::error!(%call_id, error = %e, "answer_call: send_signal failed");
|
|
||||||
format!("{e}")
|
|
||||||
})?;
|
|
||||||
tracing::info!(%call_id, "answer_call: DirectCallAnswer sent successfully");
|
|
||||||
// Upgrade the pending "Missed" entry to "Received" if the user
|
|
||||||
// accepted (mode != Reject). Mode 0 = Reject → leave as Missed.
|
|
||||||
if mode != 0 {
|
|
||||||
if history::mark_received_if_pending(&call_id) {
|
|
||||||
let _ = app.emit("history-changed", ());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
|
|
||||||
let sig = state.signal.lock().await;
|
|
||||||
Ok(serde_json::json!({"status":sig.signal_status,"fingerprint":sig.fingerprint,"incoming_call_id":sig.incoming_call_id,"incoming_caller_fp":sig.incoming_caller_fp}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tear down the signal connection so the user goes back to idle. Called
|
|
||||||
/// when the user clicks "Deregister" on the direct-call screen. The
|
|
||||||
/// spawned recv loop will break out naturally when the transport closes.
|
|
||||||
#[tauri::command]
|
|
||||||
async fn deregister(state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
|
|
||||||
let mut sig = state.signal.lock().await;
|
|
||||||
if let Some(transport) = sig.transport.take() {
|
|
||||||
tracing::info!("deregister: closing signal transport");
|
|
||||||
transport.close().await.ok();
|
|
||||||
}
|
|
||||||
sig.endpoint = None;
|
|
||||||
sig.signal_status = "idle".into();
|
|
||||||
sig.incoming_call_id = None;
|
|
||||||
sig.incoming_caller_fp = None;
|
|
||||||
sig.incoming_caller_alias = None;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── App entry point ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile
|
|
||||||
/// entry point below.
|
|
||||||
pub fn run() {
|
|
||||||
tracing_subscriber::fmt().init();
|
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
|
||||||
engine: Mutex::new(None),
|
|
||||||
signal: Arc::new(Mutex::new(SignalState {
|
|
||||||
transport: None, endpoint: None, fingerprint: String::new(), signal_status: "idle".into(),
|
|
||||||
incoming_call_id: None, incoming_caller_fp: None, incoming_caller_alias: None,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
|
||||||
.plugin(tauri_plugin_shell::init())
|
|
||||||
.manage(state)
|
|
||||||
.setup(|app| {
|
|
||||||
// Resolve the platform-correct app data dir once at startup so
|
|
||||||
// every command can read/write the seed without juggling AppHandle.
|
|
||||||
let data_dir = app
|
|
||||||
.path()
|
|
||||||
.app_data_dir()
|
|
||||||
.map(|p| p.join(".wzp"))
|
|
||||||
.unwrap_or_else(|_| identity_dir());
|
|
||||||
// create_dir_all is a no-op if it already exists.
|
|
||||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
|
||||||
tracing::warn!("failed to create app data dir {data_dir:?}: {e}");
|
|
||||||
}
|
|
||||||
tracing::info!("app data dir: {data_dir:?}");
|
|
||||||
let _ = APP_DATA_DIR.set(data_dir);
|
|
||||||
|
|
||||||
// Load the standalone wzp-native cdylib (Oboe audio bridge) and
|
|
||||||
// cache its exported function pointers. The library handle is
|
|
||||||
// kept alive in a 'static OnceLock for the lifetime of the
|
|
||||||
// process, so CallEngine::start() can invoke its audio FFI
|
|
||||||
// from anywhere. See src/wzp_native.rs and the incident report
|
|
||||||
// in docs/incident-tauri-android-init-tcb.md.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
match wzp_native::init() {
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::info!(
|
|
||||||
"wzp-native loaded: version={} msg=\"{}\"",
|
|
||||||
wzp_native::version(),
|
|
||||||
wzp_native::hello()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("wzp-native init failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
ping_relay, get_identity, get_app_info,
|
|
||||||
connect, disconnect, toggle_mic, toggle_speaker, get_status,
|
|
||||||
register_signal, place_call, answer_call, get_signal_status,
|
|
||||||
deregister,
|
|
||||||
set_speakerphone, is_speakerphone_on,
|
|
||||||
get_call_history, get_recent_contacts, clear_call_history,
|
|
||||||
set_dred_verbose_logs, get_dred_verbose_logs,
|
|
||||||
])
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("error while running WarzonePhone");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tauri mobile entry point (Android/iOS). On desktop this is a no-op —
|
|
||||||
/// `main.rs` calls `run()` directly.
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
||||||
pub fn mobile_entry() {
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Desktop binary entry point. All logic lives in `lib.rs` so the same
|
|
||||||
// code can be built as a cdylib for Android/iOS via `cargo tauri android build`.
|
|
||||||
#![cfg_attr(
|
|
||||||
all(not(debug_assertions), target_os = "windows"),
|
|
||||||
windows_subsystem = "windows"
|
|
||||||
)]
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
wzp_desktop_lib::run();
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
//! Runtime binding to the standalone `wzp-native` cdylib.
|
|
||||||
//!
|
|
||||||
//! See `docs/incident-tauri-android-init-tcb.md` and the top of
|
|
||||||
//! `crates/wzp-native/src/lib.rs` for the full story on why this split
|
|
||||||
//! exists. Short version: Tauri's desktop cdylib cannot have any C++
|
|
||||||
//! compiled into it (via cc::Build) without landing in rust-lang/rust#104707's
|
|
||||||
//! staticlib symbol leak, which makes bionic's private `pthread_create`
|
|
||||||
//! symbols bind locally and SIGSEGV in `__init_tcb+4` at launch. So all
|
|
||||||
//! the Oboe + audio code lives in a standalone `wzp-native` .so built
|
|
||||||
//! with `cargo-ndk`, and we dlopen it here at runtime.
|
|
||||||
//!
|
|
||||||
//! The Library handle lives in a `'static` `OnceLock` for the lifetime of
|
|
||||||
//! the process; all function pointers cached below borrow from it safely.
|
|
||||||
|
|
||||||
#![cfg(target_os = "android")]
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
// ─── Library handle (kept alive forever) ─────────────────────────────────
|
|
||||||
|
|
||||||
static LIB: OnceLock<libloading::Library> = OnceLock::new();
|
|
||||||
|
|
||||||
// Cached function pointers, resolved once at init(). Each is a raw
|
|
||||||
// `extern "C"` fn pointer with effectively `'static` lifetime because
|
|
||||||
// LIB is a OnceLock that never drops.
|
|
||||||
static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
|
||||||
static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new();
|
|
||||||
static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
|
||||||
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
|
|
||||||
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
|
|
||||||
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
|
|
||||||
static AUDIO_IS_RUNNING: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
|
|
||||||
static AUDIO_CAPTURE_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
|
|
||||||
static AUDIO_PLAYOUT_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Load `libwzp_native.so` and resolve every exported function we use.
|
|
||||||
/// Call this once at app startup (from the Tauri `setup()` callback).
|
|
||||||
/// Subsequent calls are no-ops.
|
|
||||||
pub fn init() -> Result<(), String> {
|
|
||||||
if LIB.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the sibling cdylib. The Android dynamic linker searches
|
|
||||||
// /data/app/<pkg>/lib/arm64/ which gradle populates from jniLibs.
|
|
||||||
let lib = unsafe { libloading::Library::new("libwzp_native.so") }
|
|
||||||
.map_err(|e| format!("dlopen libwzp_native.so: {e}"))?;
|
|
||||||
|
|
||||||
// Stash the Library into the OnceLock first so all Symbol lookups
|
|
||||||
// below borrow from the 'static reference rather than a local.
|
|
||||||
LIB.set(lib).map_err(|_| "wzp_native::LIB already set")?;
|
|
||||||
let lib_ref: &'static libloading::Library = LIB.get().unwrap();
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
macro_rules! resolve {
|
|
||||||
($cell:expr, $ty:ty, $name:expr) => {{
|
|
||||||
let sym: libloading::Symbol<$ty> = lib_ref.get($name)
|
|
||||||
.map_err(|e| format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?")))?;
|
|
||||||
// Dereference the Symbol to extract the raw fn pointer;
|
|
||||||
// it stays valid because lib_ref is 'static.
|
|
||||||
$cell.set(*sym).map_err(|_| format!("{} already set", core::str::from_utf8($name).unwrap_or("?")))?;
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
|
|
||||||
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
|
|
||||||
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
|
|
||||||
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
|
|
||||||
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
|
|
||||||
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
|
|
||||||
resolve!(AUDIO_IS_RUNNING, unsafe extern "C" fn() -> i32, b"wzp_native_audio_is_running");
|
|
||||||
resolve!(AUDIO_CAPTURE_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_capture_latency_ms");
|
|
||||||
resolve!(AUDIO_PLAYOUT_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_playout_latency_ms");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is `init()` done and all symbols cached?
|
|
||||||
pub fn is_loaded() -> bool {
|
|
||||||
AUDIO_START.get().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Smoke-test accessors ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn version() -> i32 {
|
|
||||||
VERSION.get().map(|f| unsafe { f() }).unwrap_or(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn hello() -> String {
|
|
||||||
let Some(f) = HELLO.get() else { return String::new(); };
|
|
||||||
let mut buf = [0u8; 64];
|
|
||||||
let n = unsafe { f(buf.as_mut_ptr(), buf.len()) };
|
|
||||||
String::from_utf8_lossy(&buf[..n]).into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Audio accessors ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Start the Oboe capture + playout streams. Returns `Err(code)` on
|
|
||||||
/// failure. Idempotent on the wzp-native side.
|
|
||||||
pub fn audio_start() -> Result<(), i32> {
|
|
||||||
let f = AUDIO_START.get().ok_or(-100_i32)?;
|
|
||||||
let ret = unsafe { f() };
|
|
||||||
if ret == 0 { Ok(()) } else { Err(ret) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop both streams. Safe to call even if not running.
|
|
||||||
pub fn audio_stop() {
|
|
||||||
if let Some(f) = AUDIO_STOP.get() {
|
|
||||||
unsafe { f() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read captured i16 PCM into `out`. Returns bytes actually copied.
|
|
||||||
pub fn audio_read_capture(out: &mut [i16]) -> usize {
|
|
||||||
let Some(f) = AUDIO_READ_CAPTURE.get() else { return 0; };
|
|
||||||
unsafe { f(out.as_mut_ptr(), out.len()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write i16 PCM into the playout ring. Returns samples enqueued.
|
|
||||||
pub fn audio_write_playout(input: &[i16]) -> usize {
|
|
||||||
let Some(f) = AUDIO_WRITE_PLAYOUT.get() else { return 0; };
|
|
||||||
unsafe { f(input.as_ptr(), input.len()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn audio_is_running() -> bool {
|
|
||||||
AUDIO_IS_RUNNING.get().map(|f| unsafe { f() } != 0).unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn audio_capture_latency_ms() -> f32 {
|
|
||||||
AUDIO_CAPTURE_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn audio_playout_latency_ms() -> f32 {
|
|
||||||
AUDIO_PLAYOUT_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
@@ -1,36 +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"
|
|
||||||
],
|
|
||||||
"android": {
|
|
||||||
"minSdkVersion": 26
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
1053
desktop/src/main.ts
1053
desktop/src/main.ts
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user