2 Commits

Author SHA1 Message Date
Claude
a23d9f5e41 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>
2026-04-05 17:45:00 +00:00
Claude
b3e56ecbd8 feat: add AGC to capture + playout paths, add server UI, DNS resolve
- Wire AutoGainControl on both capture (mic → encode) and playout
  (decode → speaker) paths to normalize volume levels
- Add server list with add/remove custom server dialog
- Add IPv4/IPv6 preference toggle for DNS resolution
- Resolve DNS hostnames to IP in Kotlin before passing to Rust engine
- Revert to IP addresses for default servers (DNS still broken on QUIC)

AGC confirmed working — voice levels noticeably improved in testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:02:33 +00:00
12 changed files with 414 additions and 101 deletions

View File

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

View File

@@ -11,6 +11,7 @@ import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import com.wzp.engine.WzpEngine
import kotlin.math.pow
/**
* Audio pipeline that captures mic audio and plays received audio using
@@ -36,6 +37,12 @@ class AudioPipeline(private val context: Context) {
@Volatile
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 playoutThread: Thread? = null
@@ -45,14 +52,20 @@ class AudioPipeline(private val context: Context) {
captureThread = Thread({
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 {
isDaemon = true
priority = Thread.MAX_PRIORITY
start()
}
playoutThread = Thread({
runPlayout(engine)
parkThread()
}, "wzp-playout").apply {
isDaemon = true
priority = Thread.MAX_PRIORITY
start()
}
@@ -62,13 +75,28 @@ class AudioPipeline(private val context: Context) {
fun stop() {
running = false
captureThread?.join(1000)
playoutThread?.join(1000)
// Don't join — threads are parked as daemons to avoid native TLS crash
captureThread = null
playoutThread = null
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) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
@@ -107,6 +135,7 @@ class AudioPipeline(private val context: Context) {
while (running) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) {
applyGain(pcm, read, captureGainDb)
engine.writeAudio(pcm)
} else if (read < 0) {
Log.e(TAG, "AudioRecord.read error: $read")
@@ -157,6 +186,7 @@ class AudioPipeline(private val context: Context) {
while (running) {
val read = engine.readAudio(pcm)
if (read >= FRAME_SAMPLES) {
applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read)
} else {
// Not enough decoded audio — write silence to keep stream alive

View File

@@ -31,7 +31,7 @@ data class CallStats(
/** Frames recovered by FEC. */
val fecRecovered: Long = 0,
/** Current mic audio level (RMS, 0-32767). */
val audioLevel: Int = 0
val audioLevel: Int = 0,
) {
/** Human-readable quality label. */
val qualityLabel: String

View File

@@ -66,6 +66,7 @@ class WzpEngine(private val callback: WzpCallback) {
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
}
/**
* 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 {
when (intent?.action) {
ACTION_STOP -> {
onStopFromNotification?.invoke()
stopSelf()
return START_NOT_STICKY
}
@@ -151,6 +152,9 @@ class CallService : Service() {
private const val ACTION_STOP = "com.wzp.service.STOP"
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. */
fun start(context: Context) {
val intent = Intent(context, CallService::class.java)

View File

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

View File

@@ -1,10 +1,13 @@
package com.wzp.ui.call
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRouteManager
import com.wzp.engine.CallStats
import com.wzp.service.CallService
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Job
@@ -14,15 +17,20 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
data class ServerEntry(val address: String, val label: String)
class CallViewModel : ViewModel(), WzpCallback {
private var engine: WzpEngine? = null
private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null
private var audioRouteManager: AudioRouteManager? = null
private var audioStarted = false
private var acquireWakeLocks: (() -> Unit)? = null
private var releaseWakeLocks: (() -> Unit)? = null
private var appContext: Context? = null
private val _callState = MutableStateFlow(0)
val callState: StateFlow<Int> get() = _callState.asStateFlow()
@@ -45,82 +53,184 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
val roomName: StateFlow<String> = _roomName.asStateFlow()
private val _selectedServer = MutableStateFlow(0) // index into SERVERS
private val _selectedServer = MutableStateFlow(0)
val selectedServer: StateFlow<Int> = _selectedServer.asStateFlow()
private val _servers = MutableStateFlow(DEFAULT_SERVERS.toList())
val servers: StateFlow<List<ServerEntry>> = _servers.asStateFlow()
private val _preferIPv6 = MutableStateFlow(false)
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
companion object {
val SERVERS = listOf(
"172.16.81.175:4433" to "LAN (172.16.81.175)",
"pangolin.manko.yoga:4433" to "Pangolin (remote)",
private const val TAG = "WzpCall"
val DEFAULT_SERVERS = listOf(
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
)
const val DEFAULT_ROOM = "android"
}
fun setContext(context: Context) {
val appCtx = context.applicationContext
appContext = appCtx
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) {
if (index in _servers.value.indices) {
_selectedServer.value = index
}
}
fun selectServer(index: Int) {
if (index in SERVERS.indices) {
_selectedServer.value = index
fun setPreferIPv6(prefer: Boolean) { _preferIPv6.value = prefer }
fun addServer(hostPort: String, label: String) {
val current = _servers.value.toMutableList()
current.add(ServerEntry(hostPort, label))
_servers.value = current
}
fun removeServer(index: Int) {
if (index < DEFAULT_SERVERS.size) return // don't remove built-in servers
val current = _servers.value.toMutableList()
if (index in current.indices) {
current.removeAt(index)
_servers.value = current
if (_selectedServer.value >= current.size) {
_selectedServer.value = 0
}
}
}
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,
* since Rust's DNS resolution may not work on Android.
* Returns "ip:port" string.
*/
private fun resolveToIp(hostPort: String): String {
val parts = hostPort.split(":")
if (parts.size != 2) return hostPort
val host = parts[0]
val port = parts[1]
// Already an IP address — return as-is
if (host.matches(Regex("""\d+\.\d+\.\d+\.\d+"""))) return hostPort
if (host.contains(":")) return hostPort // IPv6 literal
return try {
val addresses = InetAddress.getAllByName(host)
val preferV6 = _preferIPv6.value
val picked = if (preferV6) {
addresses.firstOrNull { it is Inet6Address } ?: addresses.firstOrNull { it is Inet4Address }
} else {
addresses.firstOrNull { it is Inet4Address } ?: addresses.firstOrNull { it is Inet6Address }
}
if (picked != null) {
val ip = picked.hostAddress ?: host
val formatted = if (picked is Inet6Address) "[$ip]:$port" else "$ip:$port"
formatted
} else {
hostPort
}
} catch (_: Exception) {
hostPort // resolution failed — pass through and let Rust try
}
}
/** 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() {
val relay = SERVERS[_selectedServer.value].first
val serverEntry = _servers.value[_selectedServer.value]
val room = _roomName.value
Log.i(TAG, "startCall: server=${serverEntry.address} room=$room")
try {
if (engine == null) {
engine = WzpEngine(this)
}
if (!engineInitialized) {
engine?.init()
engineInitialized = true
}
// Teardown previous call but don't stop the service (we're about to restart it)
teardown(stopService = false)
Log.i(TAG, "startCall: creating engine")
engine = WzpEngine(this)
engine!!.init()
engineInitialized = true
_callState.value = 1
acquireWakeLocks?.invoke()
_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 relay = resolveToIp(serverEntry.address)
Log.i(TAG, "startCall: resolved=$relay, calling engine.startCall")
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) {
_callState.value = 0
_errorMessage.value = "Failed to start call (code $result)"
releaseWakeLocks?.invoke()
appContext?.let { CallService.stop(it) }
}
} catch (e: Exception) {
Log.e(TAG, "startCall IO error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
releaseWakeLocks?.invoke()
appContext?.let { CallService.stop(it) }
}
}
} catch (e: Exception) {
Log.e(TAG, "startCall error", e)
_callState.value = 0
_errorMessage.value = "Engine error: ${e.message}"
releaseWakeLocks?.invoke()
appContext?.let { CallService.stop(it) }
}
}
fun stopCall() {
stopAudio()
stopStatsPolling()
try {
engine?.stopCall()
} catch (_: Exception) {}
_callState.value = 0
releaseWakeLocks?.invoke()
Log.i(TAG, "stopCall")
teardown()
}
fun toggleMute() {
@@ -132,7 +242,7 @@ class CallViewModel : ViewModel(), WzpCallback {
fun toggleSpeaker() {
val newSpeaker = !_isSpeaker.value
_isSpeaker.value = newSpeaker
try { engine?.setSpeaker(newSpeaker) } catch (_: Exception) {}
audioRouteManager?.setSpeaker(newSpeaker)
}
fun clearError() { _errorMessage.value = null }
@@ -145,13 +255,24 @@ class CallViewModel : ViewModel(), WzpCallback {
private fun startAudio() {
if (audioStarted) 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
}
private fun stopAudio() {
if (!audioStarted) return
audioPipeline?.stop()
audioPipeline = null
audioRouteManager?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false
audioStarted = false
}
@@ -162,6 +283,7 @@ class CallViewModel : ViewModel(), WzpCallback {
try {
val json = engine?.getStats() ?: "{}"
if (json.isNotEmpty()) {
Log.d(TAG, "raw: $json")
val s = CallStats.fromJson(json)
_stats.value = s
if (s.state != 0) {
@@ -184,14 +306,7 @@ class CallViewModel : ViewModel(), WzpCallback {
override fun onCleared() {
super.onCleared()
stopAudio()
stopStatsPolling()
releaseWakeLocks?.invoke()
try {
engine?.stopCall()
engine?.destroy()
} catch (_: Exception) {}
engine = null
engineInitialized = false
Log.i(TAG, "onCleared")
teardown()
}
}

View File

@@ -4,6 +4,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,8 +14,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledIconButton
@@ -21,12 +26,19 @@ import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -38,6 +50,7 @@ import androidx.compose.ui.unit.sp
import com.wzp.engine.CallStats
import kotlin.math.roundToInt
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun InCallScreen(
viewModel: CallViewModel,
@@ -51,6 +64,12 @@ fun InCallScreen(
val errorMessage by viewModel.errorMessage.collectAsState()
val roomName by viewModel.roomName.collectAsState()
val selectedServer by viewModel.selectedServer.collectAsState()
val servers by viewModel.servers.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) }
Surface(
modifier = Modifier.fillMaxSize(),
@@ -59,12 +78,12 @@ fun InCallScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
// App title
Text(
text = "WZ Phone",
style = MaterialTheme.typography.headlineMedium.copy(
@@ -78,8 +97,7 @@ fun InCallScreen(
CallStateLabel(callState)
if (callState == 0) {
// Idle — show connect button
Spacer(modifier = Modifier.height(48.dp))
Spacer(modifier = Modifier.height(32.dp))
// Server selector
Text(
@@ -88,16 +106,16 @@ fun InCallScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Row(
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CallViewModel.SERVERS.forEachIndexed { idx, (_, label) ->
servers.forEachIndexed { idx, entry ->
val isSelected = selectedServer == idx
FilledTonalIconButton(
onClick = { viewModel.selectServer(idx) },
modifier = Modifier
.padding(horizontal = 4.dp)
.padding(2.dp)
.height(36.dp)
.width(140.dp),
shape = RoundedCornerShape(8.dp),
@@ -111,14 +129,57 @@ fun InCallScreen(
}
) {
Text(
text = label,
text = entry.label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
}
// + Add button
OutlinedButton(
onClick = { showAddServerDialog = true },
modifier = Modifier
.padding(2.dp)
.height(36.dp),
shape = RoundedCornerShape(8.dp)
) {
Text("+", style = MaterialTheme.typography.labelMedium)
}
}
// IPv4/IPv6 preference
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "IPv4",
style = MaterialTheme.typography.labelSmall,
color = if (!preferIPv6) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Switch(
checked = preferIPv6,
onCheckedChange = { viewModel.setPreferIPv6(it) },
modifier = Modifier.padding(horizontal = 8.dp)
)
Text(
text = "IPv6",
style = MaterialTheme.typography.labelSmall,
color = if (preferIPv6) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Selected server address
Spacer(modifier = Modifier.height(4.dp))
Text(
text = servers.getOrNull(selectedServer)?.address ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = roomName,
@@ -149,7 +210,6 @@ fun InCallScreen(
)
}
// Show error if any
errorMessage?.let { err ->
Spacer(modifier = Modifier.height(16.dp))
Text(
@@ -172,7 +232,22 @@ fun InCallScreen(
AudioLevelBar(stats.audioLevel)
Spacer(modifier = Modifier.weight(1f))
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(
isMuted = isMuted,
@@ -181,7 +256,6 @@ fun InCallScreen(
onToggleSpeaker = viewModel::toggleSpeaker,
onHangUp = {
viewModel.stopCall()
// Don't finish activity — go back to idle
}
)
@@ -193,6 +267,71 @@ fun InCallScreen(
}
}
}
if (showAddServerDialog) {
AddServerDialog(
onDismiss = { showAddServerDialog = false },
onAdd = { host, port, label ->
viewModel.addServer("$host:$port", label)
showAddServerDialog = false
}
)
}
}
@Composable
private fun AddServerDialog(
onDismiss: () -> Unit,
onAdd: (host: String, port: String, label: String) -> Unit
) {
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("4433") }
var label by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Server") },
text = {
Column {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host (IP or domain)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
if (host.isNotBlank()) {
val displayLabel = label.ifBlank { host }
onAdd(host.trim(), port.trim(), displayLabel)
}
}
) { Text("Add") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
@@ -261,8 +400,6 @@ private fun QualityIndicator(tier: Int, label: String) {
@Composable
private fun AudioLevelBar(audioLevel: Int) {
// audioLevel is RMS of i16 samples (0-32767).
// Map to 0.0-1.0 with a log-ish curve for better visual feel.
val level = if (audioLevel > 0) {
(audioLevel.toFloat() / 8000f).coerceIn(0.02f, 1f)
} else {
@@ -287,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
private fun ControlRow(
isMuted: Boolean,
@@ -371,7 +531,7 @@ private fun StatsOverlay(stats: CallStats) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Network Stats",
text = "Stats",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -389,10 +549,9 @@ private fun StatsOverlay(stats: CallStats) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem("Enc", "${stats.framesEncoded}")
StatItem("Dec", "${stats.framesDecoded}")
StatItem("Sent", "${stats.framesEncoded}")
StatItem("Recv", "${stats.framesDecoded}")
StatItem("FEC", "${stats.fecRecovered}")
StatItem("Under", "${stats.underruns}")
}
}
}

View File

@@ -15,6 +15,7 @@ use std::time::Instant;
use bytes::Bytes;
use tracing::{error, info, warn};
use wzp_codec::agc::AutoGainControl;
use wzp_codec::opus_dec::OpusDecoder;
use wzp_codec::opus_enc::OpusEncoder;
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
@@ -161,7 +162,6 @@ impl WzpEngine {
if let Some(start) = self.call_start {
stats.duration_secs = start.elapsed().as_secs_f64();
}
// Include current audio level
stats.audio_level = self.state.audio_level_rms.load(Ordering::Relaxed);
stats
}
@@ -275,10 +275,14 @@ async fn run_call(
let mut fec_enc = wzp_fec::create_encoder(&profile);
let mut fec_dec = wzp_fec::create_decoder(&profile);
// AGC: normalize volume on both capture and playout paths
let mut capture_agc = AutoGainControl::new();
let mut playout_agc = AutoGainControl::new();
info!(
fec_ratio = profile.fec_ratio,
frames_per_block = profile.frames_per_block,
"codec + FEC initialized (48kHz mono, 20ms frames, RaptorQ)"
"codec + FEC + AGC initialized (48kHz mono, 20ms frames)"
);
let seq = AtomicU16::new(0);
@@ -310,6 +314,9 @@ async fn run_call(
continue;
}
// AGC: normalize capture volume before encoding
capture_agc.process_frame(&mut capture_buf);
// Opus encode
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
Ok(n) => n,
@@ -440,12 +447,15 @@ async fn run_call(
if !is_repair {
match decoder.decode(&pkt.payload, &mut decode_buf) {
Ok(samples) => {
// AGC on playout — normalizes received audio volume
playout_agc.process_frame(&mut decode_buf[..samples]);
state.playout_ring.write(&decode_buf[..samples]);
frames_decoded += 1;
}
Err(e) => {
warn!("opus decode error: {e}");
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]);
}
}
@@ -495,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 {
loop {
if !state.running.load(Ordering::Relaxed) {
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();
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;
}

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.
fn estimate_bandwidth_kbps(&self) -> u32 {
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
}
/// 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.
pub fn max_datagram_size(&self) -> Option<usize> {
datagram::max_datagram_payload(&self.connection)

Binary file not shown.