feat: foreground service, dB gain sliders, speaker routing, live network stats

- Wire CallService foreground service for background calls (microphone type)
- Add Voice Volume + Mic Gain sliders (-20 to +20 dB) applied in Kotlin
- Connect AudioRouteManager for real speaker toggle via AudioManager
- Feed quinn QUIC RTT into PathMonitor, display Loss/RTT/Jitter from live data
- Nuclear teardown between calls — recreate engine + audio pipeline each call
- Fix re-entrant teardown loop from CallService notification callback
- Park audio threads as daemons to avoid libcrypto TLS destructor crash on exit
- Remove duplicate wakelocks from Activity (service owns them now)
- Strip AEC + denoise from capture path, keep AGC only (incremental approach)
- Fix .so copy target: libwzp_android.so not libwzp.so

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-05 17:45:00 +00:00
parent b3e56ecbd8
commit a23d9f5e41
12 changed files with 197 additions and 82 deletions

View File

@@ -3,7 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@@ -27,7 +27,7 @@
<service <service
android:name="com.wzp.service.CallService" android:name="com.wzp.service.CallService"
android:foregroundServiceType="phoneCall" android:foregroundServiceType="microphone"
android:exported="false" /> android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@@ -11,6 +11,7 @@ import android.media.MediaRecorder
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.wzp.engine.WzpEngine import com.wzp.engine.WzpEngine
import kotlin.math.pow
/** /**
* Audio pipeline that captures mic audio and plays received audio using * Audio pipeline that captures mic audio and plays received audio using
@@ -36,6 +37,12 @@ class AudioPipeline(private val context: Context) {
@Volatile @Volatile
private var running = false private var running = false
/** Playout (incoming voice) gain in dB. 0 = unity. */
@Volatile
var playoutGainDb: Float = 0f
/** Capture (mic) gain in dB. 0 = unity. */
@Volatile
var captureGainDb: Float = 0f
private var captureThread: Thread? = null private var captureThread: Thread? = null
private var playoutThread: Thread? = null private var playoutThread: Thread? = null
@@ -45,14 +52,20 @@ class AudioPipeline(private val context: Context) {
captureThread = Thread({ captureThread = Thread({
runCapture(engine) runCapture(engine)
// Park thread forever — exiting triggers a libcrypto TLS destructor
// crash (SIGSEGV in OPENSSL_free) on Android when a JNI-calling thread exits.
parkThread()
}, "wzp-capture").apply { }, "wzp-capture").apply {
isDaemon = true
priority = Thread.MAX_PRIORITY priority = Thread.MAX_PRIORITY
start() start()
} }
playoutThread = Thread({ playoutThread = Thread({
runPlayout(engine) runPlayout(engine)
parkThread()
}, "wzp-playout").apply { }, "wzp-playout").apply {
isDaemon = true
priority = Thread.MAX_PRIORITY priority = Thread.MAX_PRIORITY
start() start()
} }
@@ -62,13 +75,28 @@ class AudioPipeline(private val context: Context) {
fun stop() { fun stop() {
running = false running = false
captureThread?.join(1000) // Don't join — threads are parked as daemons to avoid native TLS crash
playoutThread?.join(1000)
captureThread = null captureThread = null
playoutThread = null playoutThread = null
Log.i(TAG, "audio pipeline stopped") Log.i(TAG, "audio pipeline stopped")
} }
private fun applyGain(pcm: ShortArray, count: Int, db: Float) {
if (db == 0f) return
val linear = 10f.pow(db / 20f)
for (i in 0 until count) {
pcm[i] = (pcm[i] * linear).toInt().coerceIn(-32000, 32000).toShort()
}
}
private fun parkThread() {
try {
Thread.sleep(Long.MAX_VALUE)
} catch (_: InterruptedException) {
// process exiting
}
}
private fun runCapture(engine: WzpEngine) { private fun runCapture(engine: WzpEngine) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
@@ -107,6 +135,7 @@ class AudioPipeline(private val context: Context) {
while (running) { while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES) val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) { if (read > 0) {
applyGain(pcm, read, captureGainDb)
engine.writeAudio(pcm) engine.writeAudio(pcm)
} else if (read < 0) { } else if (read < 0) {
Log.e(TAG, "AudioRecord.read error: $read") Log.e(TAG, "AudioRecord.read error: $read")
@@ -157,6 +186,7 @@ class AudioPipeline(private val context: Context) {
while (running) { while (running) {
val read = engine.readAudio(pcm) val read = engine.readAudio(pcm)
if (read >= FRAME_SAMPLES) { if (read >= FRAME_SAMPLES) {
applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read) track.write(pcm, 0, read)
} else { } else {
// Not enough decoded audio — write silence to keep stream alive // Not enough decoded audio — write silence to keep stream alive

View File

@@ -31,7 +31,7 @@ data class CallStats(
/** Frames recovered by FEC. */ /** Frames recovered by FEC. */
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,
) { ) {
/** Human-readable quality label. */ /** Human-readable quality label. */
val qualityLabel: String val qualityLabel: String

View File

@@ -66,6 +66,7 @@ class WzpEngine(private val callback: WzpCallback) {
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker) if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
} }
/** /**
* Get current call statistics as a JSON string. * Get current call statistics as a JSON string.
* *

View File

@@ -41,6 +41,7 @@ class CallService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
onStopFromNotification?.invoke()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -151,6 +152,9 @@ class CallService : Service() {
private const val ACTION_STOP = "com.wzp.service.STOP" private const val ACTION_STOP = "com.wzp.service.STOP"
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
/** Called when the user taps "End Call" in the notification. */
var onStopFromNotification: (() -> Unit)? = null
/** Start the foreground call service. */ /** Start the foreground call service. */
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, CallService::class.java) val intent = Intent(context, CallService::class.java)

View File

@@ -2,9 +2,7 @@ package com.wzp.ui.call
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -23,16 +21,13 @@ import androidx.core.content.ContextCompat
/** /**
* Main activity hosting the in-call Compose UI. * Main activity hosting the in-call Compose UI.
* *
* Acquires a partial wake lock and WiFi lock during calls to prevent * Call lifecycle (wake lock, Wi-Fi lock, audio mode, notification)
* audio from stopping when the screen turns off. * is managed by [com.wzp.service.CallService] foreground service.
*/ */
class CallActivity : ComponentActivity() { class CallActivity : ComponentActivity() {
private val viewModel: CallViewModel by viewModels() private val viewModel: CallViewModel by viewModels()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private val audioPermissionLauncher = registerForActivityResult( private val audioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
@@ -45,7 +40,6 @@ class CallActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel.setContext(this) viewModel.setContext(this)
viewModel.setWakeLockCallbacks(::acquireWakeLocks, ::releaseWakeLocks)
setContent { setContent {
WzpTheme { WzpTheme {
@@ -65,33 +59,8 @@ class CallActivity : ComponentActivity() {
} }
} }
private fun acquireWakeLocks() {
if (wakeLock == null) {
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"wzp:call"
).apply { acquire() }
}
if (wifiLock == null) {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
wifiLock = wm.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"wzp:call"
).apply { acquire() }
}
}
private fun releaseWakeLocks() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
wifiLock?.let { if (it.isHeld) it.release() }
wifiLock = null
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
releaseWakeLocks()
if (isFinishing) { if (isFinishing) {
viewModel.stopCall() viewModel.stopCall()
} }

View File

@@ -1,10 +1,13 @@
package com.wzp.ui.call package com.wzp.ui.call
import android.content.Context import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRouteManager
import com.wzp.engine.CallStats import com.wzp.engine.CallStats
import com.wzp.service.CallService
import com.wzp.engine.WzpCallback import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -25,9 +28,9 @@ class CallViewModel : ViewModel(), WzpCallback {
private var engine: WzpEngine? = null private var engine: WzpEngine? = null
private var engineInitialized = false private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null private var audioPipeline: AudioPipeline? = null
private var audioRouteManager: AudioRouteManager? = null
private var audioStarted = false private var audioStarted = false
private var acquireWakeLocks: (() -> Unit)? = null private var appContext: Context? = null
private var releaseWakeLocks: (() -> Unit)? = null
private val _callState = MutableStateFlow(0) private val _callState = MutableStateFlow(0)
val callState: StateFlow<Int> get() = _callState.asStateFlow() val callState: StateFlow<Int> get() = _callState.asStateFlow()
@@ -59,9 +62,16 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _preferIPv6 = MutableStateFlow(false) private val _preferIPv6 = MutableStateFlow(false)
val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow() val preferIPv6: StateFlow<Boolean> = _preferIPv6.asStateFlow()
private val _playoutGainDb = MutableStateFlow(0f)
val playoutGainDb: StateFlow<Float> = _playoutGainDb.asStateFlow()
private val _captureGainDb = MutableStateFlow(0f)
val captureGainDb: StateFlow<Float> = _captureGainDb.asStateFlow()
private var statsJob: Job? = null private var statsJob: Job? = null
companion object { companion object {
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)"),
@@ -70,14 +80,14 @@ class CallViewModel : ViewModel(), WzpCallback {
} }
fun setContext(context: Context) { fun setContext(context: Context) {
val appCtx = context.applicationContext
appContext = appCtx
if (audioPipeline == null) { if (audioPipeline == null) {
audioPipeline = AudioPipeline(context.applicationContext) audioPipeline = AudioPipeline(appCtx)
}
if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx)
} }
}
fun setWakeLockCallbacks(acquire: () -> Unit, release: () -> Unit) {
acquireWakeLocks = acquire
releaseWakeLocks = release
} }
fun selectServer(index: Int) { fun selectServer(index: Int) {
@@ -108,6 +118,16 @@ class CallViewModel : ViewModel(), WzpCallback {
fun setRoomName(name: String) { _roomName.value = name } fun setRoomName(name: String) { _roomName.value = name }
fun setPlayoutGainDb(db: Float) {
_playoutGainDb.value = db
audioPipeline?.playoutGainDb = db
}
fun setCaptureGainDb(db: Float) {
_captureGainDb.value = db
audioPipeline?.captureGainDb = db
}
/** /**
* 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.
@@ -143,52 +163,74 @@ class CallViewModel : ViewModel(), WzpCallback {
} }
} }
/** Tear down engine and audio. Pass stopService=true to also stop the foreground service. */
private fun teardown(stopService: Boolean = true) {
Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
CallService.onStopFromNotification = null
stopAudio()
stopStatsPolling()
Log.i(TAG, "teardown: stopping engine")
try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
engine = null
engineInitialized = false
_callState.value = 0
if (stopService) {
try { appContext?.let { CallService.stop(it) } } catch (_: Exception) {}
}
Log.i(TAG, "teardown: done")
}
fun startCall() { fun startCall() {
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")
try { try {
if (engine == null) { // Teardown previous call but don't stop the service (we're about to restart it)
engine = WzpEngine(this) teardown(stopService = false)
}
if (!engineInitialized) { Log.i(TAG, "startCall: creating engine")
engine?.init() engine = WzpEngine(this)
engineInitialized = true engine!!.init()
} engineInitialized = true
_callState.value = 1 _callState.value = 1
_errorMessage.value = null _errorMessage.value = null
acquireWakeLocks?.invoke() try { appContext?.let { CallService.start(it) } } catch (e: Exception) {
Log.w(TAG, "service start err: $e")
}
startStatsPolling() startStatsPolling()
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try { try {
val relay = resolveToIp(serverEntry.address) val relay = resolveToIp(serverEntry.address)
Log.i(TAG, "startCall: resolved=$relay, calling engine.startCall")
val result = engine?.startCall(relay, room) ?: -1 val result = engine?.startCall(relay, room) ?: -1
Log.i(TAG, "startCall: engine returned $result")
// Only wire up notification callback after engine is running
CallService.onStopFromNotification = { stopCall() }
if (result != 0) { if (result != 0) {
_callState.value = 0 _callState.value = 0
_errorMessage.value = "Failed to start call (code $result)" _errorMessage.value = "Failed to start call (code $result)"
releaseWakeLocks?.invoke() appContext?.let { CallService.stop(it) }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "startCall IO error", e)
_callState.value = 0 _callState.value = 0
_errorMessage.value = "Engine error: ${e.message}" _errorMessage.value = "Engine error: ${e.message}"
releaseWakeLocks?.invoke() appContext?.let { CallService.stop(it) }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "startCall error", e)
_callState.value = 0 _callState.value = 0
_errorMessage.value = "Engine error: ${e.message}" _errorMessage.value = "Engine error: ${e.message}"
releaseWakeLocks?.invoke() appContext?.let { CallService.stop(it) }
} }
} }
fun stopCall() { fun stopCall() {
stopAudio() Log.i(TAG, "stopCall")
stopStatsPolling() teardown()
try {
engine?.stopCall()
} catch (_: Exception) {}
_callState.value = 0
releaseWakeLocks?.invoke()
} }
fun toggleMute() { fun toggleMute() {
@@ -200,7 +242,7 @@ class CallViewModel : ViewModel(), WzpCallback {
fun toggleSpeaker() { fun toggleSpeaker() {
val newSpeaker = !_isSpeaker.value val newSpeaker = !_isSpeaker.value
_isSpeaker.value = newSpeaker _isSpeaker.value = newSpeaker
try { engine?.setSpeaker(newSpeaker) } catch (_: Exception) {} audioRouteManager?.setSpeaker(newSpeaker)
} }
fun clearError() { _errorMessage.value = null } fun clearError() { _errorMessage.value = null }
@@ -213,13 +255,24 @@ class CallViewModel : ViewModel(), WzpCallback {
private fun startAudio() { private fun startAudio() {
if (audioStarted) return if (audioStarted) return
val e = engine ?: return val e = engine ?: return
audioPipeline?.start(e) val ctx = appContext ?: return
// Create a fresh pipeline each call to avoid stale threads
audioPipeline = AudioPipeline(ctx).also {
it.playoutGainDb = _playoutGainDb.value
it.captureGainDb = _captureGainDb.value
it.start(e)
}
audioRouteManager?.register()
audioStarted = true audioStarted = true
} }
private fun stopAudio() { private fun stopAudio() {
if (!audioStarted) return if (!audioStarted) return
audioPipeline?.stop() audioPipeline?.stop()
audioPipeline = null
audioRouteManager?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false
audioStarted = false audioStarted = false
} }
@@ -230,6 +283,7 @@ class CallViewModel : ViewModel(), WzpCallback {
try { try {
val json = engine?.getStats() ?: "{}" val json = engine?.getStats() ?: "{}"
if (json.isNotEmpty()) { if (json.isNotEmpty()) {
Log.d(TAG, "raw: $json")
val s = CallStats.fromJson(json) val s = CallStats.fromJson(json)
_stats.value = s _stats.value = s
if (s.state != 0) { if (s.state != 0) {
@@ -252,14 +306,7 @@ class CallViewModel : ViewModel(), WzpCallback {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
stopAudio() Log.i(TAG, "onCleared")
stopStatsPolling() teardown()
releaseWakeLocks?.invoke()
try {
engine?.stopCall()
engine?.destroy()
} catch (_: Exception) {}
engine = null
engineInitialized = false
} }
} }

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -65,6 +66,8 @@ fun InCallScreen(
val selectedServer by viewModel.selectedServer.collectAsState() val selectedServer by viewModel.selectedServer.collectAsState()
val servers by viewModel.servers.collectAsState() val servers by viewModel.servers.collectAsState()
val preferIPv6 by viewModel.preferIPv6.collectAsState() val preferIPv6 by viewModel.preferIPv6.collectAsState()
val playoutGainDb by viewModel.playoutGainDb.collectAsState()
val captureGainDb by viewModel.captureGainDb.collectAsState()
var showAddServerDialog by remember { mutableStateOf(false) } var showAddServerDialog by remember { mutableStateOf(false) }
@@ -229,7 +232,22 @@ fun InCallScreen(
AudioLevelBar(stats.audioLevel) AudioLevelBar(stats.audioLevel)
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(16.dp))
// Gain sliders
GainSlider(
label = "Voice Volume",
gainDb = playoutGainDb,
onGainChange = { viewModel.setPlayoutGainDb(it) }
)
Spacer(modifier = Modifier.height(4.dp))
GainSlider(
label = "Mic Gain",
gainDb = captureGainDb,
onGainChange = { viewModel.setCaptureGainDb(it) }
)
Spacer(modifier = Modifier.height(32.dp))
ControlRow( ControlRow(
isMuted = isMuted, isMuted = isMuted,
@@ -406,6 +424,29 @@ private fun AudioLevelBar(audioLevel: Int) {
} }
} }
@Composable
private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(0.8f),
horizontalAlignment = Alignment.CenterHorizontally
) {
val sign = if (gainDb >= 0) "+" else ""
Text(
text = "$label: ${sign}${"%.0f".format(gainDb)} dB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Slider(
value = gainDb,
onValueChange = { onGainChange(Math.round(it).toFloat()) },
valueRange = -20f..20f,
steps = 0,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable @Composable
private fun ControlRow( private fun ControlRow(
isMuted: Boolean, isMuted: Boolean,
@@ -490,7 +531,7 @@ private fun StatsOverlay(stats: CallStats) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = "Network Stats", text = "Stats",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -508,10 +549,9 @@ private fun StatsOverlay(stats: CallStats) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
StatItem("Enc", "${stats.framesEncoded}") StatItem("Sent", "${stats.framesEncoded}")
StatItem("Dec", "${stats.framesDecoded}") StatItem("Recv", "${stats.framesDecoded}")
StatItem("FEC", "${stats.fecRecovered}") StatItem("FEC", "${stats.fecRecovered}")
StatItem("Under", "${stats.underruns}")
} }
} }
} }

View File

@@ -162,7 +162,6 @@ impl WzpEngine {
if let Some(start) = self.call_start { if let Some(start) = self.call_start {
stats.duration_secs = start.elapsed().as_secs_f64(); stats.duration_secs = start.elapsed().as_secs_f64();
} }
// Include current audio level
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed); stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
stats stats
} }
@@ -506,15 +505,25 @@ async fn run_call(
} }
}; };
// Stats task // Stats task — polls path quality + quinn RTT every 500ms
let transport_stats = transport.clone();
let stats_task = async { let stats_task = async {
loop { loop {
if !state.running.load(Ordering::Relaxed) { if !state.running.load(Ordering::Relaxed) {
break; break;
} }
// Feed quinn's QUIC-level RTT into our path monitor
let quic_rtt_ms = transport_stats.connection().stats().path.rtt.as_millis() as u32;
if quic_rtt_ms > 0 {
transport_stats.feed_rtt(quic_rtt_ms);
}
let pq = transport_stats.path_quality();
{ {
let mut stats = state.stats.lock().unwrap(); let mut stats = state.stats.lock().unwrap();
stats.frames_encoded = seq.load(Ordering::Relaxed) as u64; stats.frames_encoded = seq.load(Ordering::Relaxed) as u64;
stats.loss_pct = pq.loss_pct;
stats.rtt_ms = quic_rtt_ms;
stats.jitter_ms = pq.jitter_ms;
} }
tokio::time::sleep(std::time::Duration::from_millis(500)).await; tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} }

View File

@@ -136,6 +136,11 @@ impl PathMonitor {
} }
} }
/// Get raw packet counts for debugging.
pub fn counts(&self) -> (u64, u64) {
(self.total_sent, self.total_received)
}
/// Estimate bandwidth in kbps from bytes received over time. /// Estimate bandwidth in kbps from bytes received over time.
fn estimate_bandwidth_kbps(&self) -> u32 { fn estimate_bandwidth_kbps(&self) -> u32 {
if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) { if let (Some(first), Some(last)) = (self.first_recv_time_ms, self.last_recv_time_ms) {

View File

@@ -33,6 +33,16 @@ impl QuinnTransport {
&self.connection &self.connection
} }
/// Feed an external RTT observation (e.g. from QUIC path stats) into the path monitor.
pub fn feed_rtt(&self, rtt_ms: u32) {
self.path_monitor.lock().unwrap().observe_rtt(rtt_ms);
}
/// Get raw packet counts from path monitor (sent, received).
pub fn monitor_counts(&self) -> (u64, u64) {
self.path_monitor.lock().unwrap().counts()
}
/// Get the maximum datagram payload size, if datagrams are supported. /// Get the maximum datagram payload size, if datagrams are supported.
pub fn max_datagram_size(&self) -> Option<usize> { pub fn max_datagram_size(&self) -> Option<usize> {
datagram::max_datagram_payload(&self.connection) datagram::max_datagram_payload(&self.connection)

Binary file not shown.