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>
This commit is contained in:
@@ -14,6 +14,11 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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 {
|
class CallViewModel : ViewModel(), WzpCallback {
|
||||||
|
|
||||||
@@ -45,15 +50,21 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
|
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
|
||||||
val roomName: StateFlow<String> = _roomName.asStateFlow()
|
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()
|
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 var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val SERVERS = listOf(
|
val DEFAULT_SERVERS = listOf(
|
||||||
"172.16.81.175:4433" to "LAN (172.16.81.175)",
|
ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
|
||||||
"pangolin.manko.yoga:4433" to "Pangolin (remote)",
|
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
|
||||||
)
|
)
|
||||||
const val DEFAULT_ROOM = "android"
|
const val DEFAULT_ROOM = "android"
|
||||||
}
|
}
|
||||||
@@ -70,15 +81,70 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectServer(index: Int) {
|
fun selectServer(index: Int) {
|
||||||
if (index in SERVERS.indices) {
|
if (index in _servers.value.indices) {
|
||||||
_selectedServer.value = index
|
_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 setRoomName(name: String) { _roomName.value = name }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun startCall() {
|
fun startCall() {
|
||||||
val relay = SERVERS[_selectedServer.value].first
|
val serverEntry = _servers.value[_selectedServer.value]
|
||||||
val room = _roomName.value
|
val room = _roomName.value
|
||||||
try {
|
try {
|
||||||
if (engine == null) {
|
if (engine == null) {
|
||||||
@@ -89,11 +155,13 @@ class CallViewModel : ViewModel(), WzpCallback {
|
|||||||
engineInitialized = true
|
engineInitialized = true
|
||||||
}
|
}
|
||||||
_callState.value = 1
|
_callState.value = 1
|
||||||
|
_errorMessage.value = null
|
||||||
acquireWakeLocks?.invoke()
|
acquireWakeLocks?.invoke()
|
||||||
startStatsPolling()
|
startStatsPolling()
|
||||||
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val relay = resolveToIp(serverEntry.address)
|
||||||
val result = engine?.startCall(relay, room) ?: -1
|
val result = engine?.startCall(relay, room) ?: -1
|
||||||
if (result != 0) {
|
if (result != 0) {
|
||||||
_callState.value = 0
|
_callState.value = 0
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
@@ -21,12 +26,18 @@ import androidx.compose.material3.FilledTonalIconButton
|
|||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -38,6 +49,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.wzp.engine.CallStats
|
import com.wzp.engine.CallStats
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun InCallScreen(
|
fun InCallScreen(
|
||||||
viewModel: CallViewModel,
|
viewModel: CallViewModel,
|
||||||
@@ -51,6 +63,10 @@ fun InCallScreen(
|
|||||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||||
val roomName by viewModel.roomName.collectAsState()
|
val roomName by viewModel.roomName.collectAsState()
|
||||||
val selectedServer by viewModel.selectedServer.collectAsState()
|
val selectedServer by viewModel.selectedServer.collectAsState()
|
||||||
|
val servers by viewModel.servers.collectAsState()
|
||||||
|
val preferIPv6 by viewModel.preferIPv6.collectAsState()
|
||||||
|
|
||||||
|
var showAddServerDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -59,12 +75,12 @@ fun InCallScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp),
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
// App title
|
|
||||||
Text(
|
Text(
|
||||||
text = "WZ Phone",
|
text = "WZ Phone",
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(
|
style = MaterialTheme.typography.headlineMedium.copy(
|
||||||
@@ -78,8 +94,7 @@ fun InCallScreen(
|
|||||||
CallStateLabel(callState)
|
CallStateLabel(callState)
|
||||||
|
|
||||||
if (callState == 0) {
|
if (callState == 0) {
|
||||||
// Idle — show connect button
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
|
||||||
|
|
||||||
// Server selector
|
// Server selector
|
||||||
Text(
|
Text(
|
||||||
@@ -88,16 +103,16 @@ fun InCallScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Row(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
CallViewModel.SERVERS.forEachIndexed { idx, (_, label) ->
|
servers.forEachIndexed { idx, entry ->
|
||||||
val isSelected = selectedServer == idx
|
val isSelected = selectedServer == idx
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = { viewModel.selectServer(idx) },
|
onClick = { viewModel.selectServer(idx) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 4.dp)
|
.padding(2.dp)
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.width(140.dp),
|
.width(140.dp),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
@@ -111,13 +126,56 @@ fun InCallScreen(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = entry.label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -149,7 +207,6 @@ fun InCallScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error if any
|
|
||||||
errorMessage?.let { err ->
|
errorMessage?.let { err ->
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -172,7 +229,7 @@ fun InCallScreen(
|
|||||||
|
|
||||||
AudioLevelBar(stats.audioLevel)
|
AudioLevelBar(stats.audioLevel)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
ControlRow(
|
ControlRow(
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
@@ -181,7 +238,6 @@ fun InCallScreen(
|
|||||||
onToggleSpeaker = viewModel::toggleSpeaker,
|
onToggleSpeaker = viewModel::toggleSpeaker,
|
||||||
onHangUp = {
|
onHangUp = {
|
||||||
viewModel.stopCall()
|
viewModel.stopCall()
|
||||||
// Don't finish activity — go back to idle
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -193,6 +249,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
|
@Composable
|
||||||
@@ -261,8 +382,6 @@ private fun QualityIndicator(tier: Int, label: String) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AudioLevelBar(audioLevel: Int) {
|
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) {
|
val level = if (audioLevel > 0) {
|
||||||
(audioLevel.toFloat() / 8000f).coerceIn(0.02f, 1f)
|
(audioLevel.toFloat() / 8000f).coerceIn(0.02f, 1f)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
use wzp_codec::agc::AutoGainControl;
|
||||||
use wzp_codec::opus_dec::OpusDecoder;
|
use wzp_codec::opus_dec::OpusDecoder;
|
||||||
use wzp_codec::opus_enc::OpusEncoder;
|
use wzp_codec::opus_enc::OpusEncoder;
|
||||||
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
|
||||||
@@ -275,10 +276,14 @@ async fn run_call(
|
|||||||
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
let mut fec_enc = wzp_fec::create_encoder(&profile);
|
||||||
let mut fec_dec = wzp_fec::create_decoder(&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!(
|
info!(
|
||||||
fec_ratio = profile.fec_ratio,
|
fec_ratio = profile.fec_ratio,
|
||||||
frames_per_block = profile.frames_per_block,
|
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);
|
let seq = AtomicU16::new(0);
|
||||||
@@ -310,6 +315,9 @@ async fn run_call(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AGC: normalize capture volume before encoding
|
||||||
|
capture_agc.process_frame(&mut capture_buf);
|
||||||
|
|
||||||
// Opus encode
|
// Opus encode
|
||||||
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
|
let encoded_len = match encoder.encode(&capture_buf, &mut encode_buf) {
|
||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
@@ -440,12 +448,15 @@ async fn run_call(
|
|||||||
if !is_repair {
|
if !is_repair {
|
||||||
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
match decoder.decode(&pkt.payload, &mut decode_buf) {
|
||||||
Ok(samples) => {
|
Ok(samples) => {
|
||||||
|
// AGC on playout — normalizes received audio volume
|
||||||
|
playout_agc.process_frame(&mut decode_buf[..samples]);
|
||||||
state.playout_ring.write(&decode_buf[..samples]);
|
state.playout_ring.write(&decode_buf[..samples]);
|
||||||
frames_decoded += 1;
|
frames_decoded += 1;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("opus decode error: {e}");
|
warn!("opus decode error: {e}");
|
||||||
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]);
|
||||||
state.playout_ring.write(&decode_buf[..samples]);
|
state.playout_ring.write(&decode_buf[..samples]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
wzp-release.apk
BIN
wzp-release.apk
Binary file not shown.
Reference in New Issue
Block a user