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.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
@@ -14,15 +17,20 @@ 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 {
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()
@@ -45,82 +53,184 @@ 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 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 {
val SERVERS = listOf( private const val TAG = "WzpCall"
"172.16.81.175:4433" to "LAN (172.16.81.175)", val DEFAULT_SERVERS = listOf(
"pangolin.manko.yoga:4433" to "Pangolin (remote)", ServerEntry("172.16.81.175:4433", "LAN (172.16.81.175)"),
ServerEntry("193.180.213.68:4433", "Pangolin (IP)"),
) )
const val DEFAULT_ROOM = "android" const val DEFAULT_ROOM = "android"
} }
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) { fun selectServer(index: Int) {
acquireWakeLocks = acquire if (index in _servers.value.indices) {
releaseWakeLocks = release _selectedServer.value = index
}
} }
fun selectServer(index: Int) { fun setPreferIPv6(prefer: Boolean) { _preferIPv6.value = prefer }
if (index in SERVERS.indices) {
_selectedServer.value = index 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 }
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() { fun startCall() {
val relay = SERVERS[_selectedServer.value].first 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
acquireWakeLocks?.invoke() _errorMessage.value = null
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)
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() {
@@ -132,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 }
@@ -145,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
} }
@@ -162,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) {
@@ -184,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

@@ -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,19 @@ 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.Slider
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 +50,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 +64,12 @@ 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()
val playoutGainDb by viewModel.playoutGainDb.collectAsState()
val captureGainDb by viewModel.captureGainDb.collectAsState()
var showAddServerDialog by remember { mutableStateOf(false) }
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -59,12 +78,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 +97,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 +106,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,14 +129,57 @@ 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(
value = roomName, value = roomName,
@@ -149,7 +210,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 +232,22 @@ fun InCallScreen(
AudioLevelBar(stats.audioLevel) 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( ControlRow(
isMuted = isMuted, isMuted = isMuted,
@@ -181,7 +256,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 +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 @Composable
@@ -261,8 +400,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 {
@@ -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 @Composable
private fun ControlRow( private fun ControlRow(
isMuted: Boolean, isMuted: Boolean,
@@ -371,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
) )
@@ -389,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

@@ -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};
@@ -161,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
} }
@@ -275,10 +275,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 +314,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 +447,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]);
} }
} }
@@ -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 { 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.