feat: Android VoIP client — Phase 2 (JNI bridge, Compose UI, AEC pipeline wiring)

- JNI bridge with 8 extern functions (init, startCall, stopCall, setMute,
  setSpeaker, getStats, forceProfile, destroy) with panic catching
- Kotlin engine layer: WzpEngine JNI wrapper, WzpCallback interface,
  CallStats data class with JSON deserialization
- Jetpack Compose UI: InCallScreen with quality indicator (green/yellow/red),
  mute/speaker/hangup buttons, stats overlay, duration timer
- CallActivity with RECORD_AUDIO permission handling, Material3 theme
- CallService foreground service with WakeLock, WiFi lock, notification
- AudioRouteManager for speaker/earpiece/Bluetooth SCO switching
- AEC wired into CallEncoder pipeline: AEC → AGC → denoise → silence → encode
- AEC farend reference fed from decode path to encode path in pipeline
- Engine exposes set_aec_enabled/set_agc_enabled via AtomicBool flags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-04 18:16:38 +00:00
parent 26e9c55f1f
commit e7b1c3372a
14 changed files with 1633 additions and 14 deletions

View File

@@ -0,0 +1,38 @@
package com.wzp
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
/**
* Application entry point for WarzonePhone.
*
* Creates the notification channel required for the foreground [com.wzp.service.CallService].
*/
class WzpApplication : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Active Call",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shown while a VoIP call is in progress"
setShowBadge(false)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
}
companion object {
const val CHANNEL_ID = "wzp_call_channel"
}
}

View File

@@ -0,0 +1,142 @@
package com.wzp.audio
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
/**
* Manages audio routing between earpiece, speaker, and Bluetooth devices.
*
* Wraps [AudioManager] operations and listens for device connection changes
* via [AudioDeviceCallback] (API 23+).
*
* Usage:
* 1. Call [register] when the call starts
* 2. Use [setSpeaker] and [setBluetoothSco] to switch routes
* 3. Call [unregister] when the call ends
*/
class AudioRouteManager(context: Context) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val mainHandler = Handler(Looper.getMainLooper())
/** Listener for audio route changes. */
var onRouteChanged: ((AudioRoute) -> Unit)? = null
/** Current active route. */
var currentRoute: AudioRoute = AudioRoute.EARPIECE
private set
// -- Device callback (API 23+) -------------------------------------------
private val deviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
for (device in addedDevices) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
// A Bluetooth headset was connected — optionally auto-switch
onRouteChanged?.invoke(AudioRoute.BLUETOOTH)
}
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
for (device in removedDevices) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
// Bluetooth disconnected — fall back to earpiece or speaker
val fallback = if (audioManager.isSpeakerphoneOn) {
AudioRoute.SPEAKER
} else {
AudioRoute.EARPIECE
}
currentRoute = fallback
onRouteChanged?.invoke(fallback)
}
}
}
}
// -- Public API -----------------------------------------------------------
/** Register the device callback. Call when a call starts. */
fun register() {
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
}
/** Unregister the device callback and release Bluetooth SCO. Call when the call ends. */
fun unregister() {
audioManager.unregisterAudioDeviceCallback(deviceCallback)
stopBluetoothSco()
}
/**
* Enable or disable the loudspeaker.
*
* When enabling speaker, Bluetooth SCO is disconnected.
*/
@Suppress("DEPRECATION")
fun setSpeaker(enabled: Boolean) {
if (enabled) {
stopBluetoothSco()
}
audioManager.isSpeakerphoneOn = enabled
currentRoute = if (enabled) AudioRoute.SPEAKER else AudioRoute.EARPIECE
onRouteChanged?.invoke(currentRoute)
}
/**
* Enable or disable Bluetooth SCO (Synchronous Connection Oriented) audio.
*
* When enabling Bluetooth, the speaker is turned off.
*/
@Suppress("DEPRECATION")
fun setBluetoothSco(enabled: Boolean) {
if (enabled) {
audioManager.isSpeakerphoneOn = false
audioManager.startBluetoothSco()
audioManager.isBluetoothScoOn = true
currentRoute = AudioRoute.BLUETOOTH
} else {
stopBluetoothSco()
currentRoute = AudioRoute.EARPIECE
}
onRouteChanged?.invoke(currentRoute)
}
/** Check whether a Bluetooth SCO device is currently connected. */
fun isBluetoothAvailable(): Boolean {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
return devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
}
/** List available output audio routes. */
fun availableRoutes(): List<AudioRoute> {
val routes = mutableListOf(AudioRoute.EARPIECE, AudioRoute.SPEAKER)
if (isBluetoothAvailable()) {
routes.add(AudioRoute.BLUETOOTH)
}
return routes
}
// -- Internal -------------------------------------------------------------
@Suppress("DEPRECATION")
private fun stopBluetoothSco() {
if (audioManager.isBluetoothScoOn) {
audioManager.isBluetoothScoOn = false
audioManager.stopBluetoothSco()
}
}
}
/** Audio output route. */
enum class AudioRoute {
/** Phone earpiece (default for calls). */
EARPIECE,
/** Built-in loudspeaker. */
SPEAKER,
/** Bluetooth SCO headset/headphones. */
BLUETOOTH
}

View File

@@ -0,0 +1,63 @@
package com.wzp.engine
import org.json.JSONObject
/**
* Snapshot of call statistics, mirroring the Rust `CallStats` struct.
*
* Constructed from the JSON string returned by [WzpEngine.getStats].
*/
data class CallStats(
/** Current call state ordinal (see [CallStateConstants]). */
val state: Int = 0,
/** Call duration in seconds. */
val durationSecs: Double = 0.0,
/** Quality tier: 0 = Good, 1 = Degraded, 2 = Catastrophic. */
val qualityTier: Int = 0,
/** Observed packet loss percentage (0..100). */
val lossPct: Float = 0f,
/** Smoothed round-trip time in milliseconds. */
val rttMs: Int = 0,
/** Jitter in milliseconds. */
val jitterMs: Int = 0,
/** Current jitter buffer depth in packets. */
val jitterBufferDepth: Int = 0,
/** Total frames encoded since call start. */
val framesEncoded: Long = 0,
/** Total frames decoded since call start. */
val framesDecoded: Long = 0,
/** Number of playout underruns (buffer empty when audio was needed). */
val underruns: Long = 0
) {
/** Human-readable quality label. */
val qualityLabel: String
get() = when (qualityTier) {
0 -> "Good"
1 -> "Degraded"
2 -> "Catastrophic"
else -> "Unknown"
}
companion object {
/** Deserialise from the JSON string produced by the native engine. */
fun fromJson(json: String): CallStats {
return try {
val obj = JSONObject(json)
CallStats(
state = obj.optInt("state", 0),
durationSecs = obj.optDouble("duration_secs", 0.0),
qualityTier = obj.optInt("quality_tier", 0),
lossPct = obj.optDouble("loss_pct", 0.0).toFloat(),
rttMs = obj.optInt("rtt_ms", 0),
jitterMs = obj.optInt("jitter_ms", 0),
jitterBufferDepth = obj.optInt("jitter_buffer_depth", 0),
framesEncoded = obj.optLong("frames_encoded", 0),
framesDecoded = obj.optLong("frames_decoded", 0),
underruns = obj.optLong("underruns", 0)
)
} catch (e: Exception) {
CallStats()
}
}
}
}

View File

@@ -0,0 +1,32 @@
package com.wzp.engine
/**
* Callback interface for VoIP engine events.
*
* All callbacks are invoked on the main/UI thread.
*/
interface WzpCallback {
/**
* Called when the call state changes.
*
* @param state one of [CallStateConstants]: IDLE(0), CONNECTING(1), ACTIVE(2),
* RECONNECTING(3), CLOSED(4)
*/
fun onCallStateChanged(state: Int)
/**
* Called when the network quality tier changes.
*
* @param tier 0 = Good, 1 = Degraded, 2 = Catastrophic
*/
fun onQualityTierChanged(tier: Int)
/**
* Called when an error occurs in the native engine.
*
* @param code numeric error code (negative)
* @param message human-readable description
*/
fun onError(code: Int, message: String)
}

View File

@@ -0,0 +1,122 @@
package com.wzp.engine
/**
* Native VoIP engine wrapper. Delegates all work to libwzp_android.so via JNI.
*
* Lifecycle:
* 1. Construct with a [WzpCallback]
* 2. Call [init] to create the native engine
* 3. Call [startCall] to begin a VoIP session
* 4. Use [setMute], [setSpeaker], [getStats], [forceProfile] during the call
* 5. Call [stopCall] to end the session
* 6. Call [destroy] when the engine is no longer needed
*
* Thread safety: all methods must be called from the same thread (typically main).
*/
class WzpEngine(private val callback: WzpCallback) {
/** Opaque pointer to the native EngineHandle. 0 means not initialised. */
private var nativeHandle: Long = 0L
/** Whether the engine has been initialised. */
val isInitialized: Boolean get() = nativeHandle != 0L
/** Create the native engine. Must be called before any other method. */
fun init() {
check(nativeHandle == 0L) { "Engine already initialized" }
nativeHandle = nativeInit()
check(nativeHandle != 0L) { "Native engine creation failed" }
}
/**
* Start a call.
*
* @param relayAddr relay server address (host:port)
* @param room room identifier
* @param seedHex 64-char hex-encoded 32-byte identity seed
* @param token authentication token
* @return 0 on success, negative error code on failure
*/
fun startCall(relayAddr: String, room: String, seedHex: String, token: String): Int {
check(nativeHandle != 0L) { "Engine not initialized" }
val result = nativeStartCall(nativeHandle, relayAddr, room, seedHex, token)
if (result == 0) {
callback.onCallStateChanged(CallStateConstants.CONNECTING)
} else {
callback.onError(result, "Failed to start call")
}
return result
}
/** Stop the active call. Safe to call when no call is active. */
fun stopCall() {
if (nativeHandle != 0L) {
nativeStopCall(nativeHandle)
callback.onCallStateChanged(CallStateConstants.CLOSED)
}
}
/** Mute or unmute the microphone. */
fun setMute(muted: Boolean) {
if (nativeHandle != 0L) nativeSetMute(nativeHandle, muted)
}
/** Enable or disable loudspeaker mode. */
fun setSpeaker(speaker: Boolean) {
if (nativeHandle != 0L) nativeSetSpeaker(nativeHandle, speaker)
}
/**
* Get current call statistics as a JSON string.
*
* @return JSON-serialised [CallStats], or `"{}"` if the engine is not initialised.
*/
fun getStats(): String {
return if (nativeHandle != 0L) nativeGetStats(nativeHandle) else "{}"
}
/**
* Force a quality profile, overriding adaptive selection.
*
* @param profile 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC
*/
fun forceProfile(profile: Int) {
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
}
/** Destroy the native engine and free all resources. The instance must not be reused. */
fun destroy() {
if (nativeHandle != 0L) {
nativeDestroy(nativeHandle)
nativeHandle = 0L
}
}
// -- JNI native methods --------------------------------------------------
private external fun nativeInit(): Long
private external fun nativeStartCall(
handle: Long, relay: String, room: String, seed: String, token: String
): Int
private external fun nativeStopCall(handle: Long)
private external fun nativeSetMute(handle: Long, muted: Boolean)
private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)
private external fun nativeGetStats(handle: Long): String
private external fun nativeForceProfile(handle: Long, profile: Int)
private external fun nativeDestroy(handle: Long)
companion object {
init {
System.loadLibrary("wzp_android")
}
}
}
/** Integer constants matching the Rust [CallState] enum ordinals. */
object CallStateConstants {
const val IDLE = 0
const val CONNECTING = 1
const val ACTIVE = 2
const val RECONNECTING = 3
const val CLOSED = 4
}

View File

@@ -0,0 +1,168 @@
package com.wzp.service
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.net.wifi.WifiManager
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.wzp.WzpApplication
import com.wzp.ui.call.CallActivity
/**
* Foreground service that keeps the VoIP call alive when the app is backgrounded.
*
* Responsibilities:
* - Shows a persistent notification during the call
* - Acquires a partial wake lock so the CPU stays on
* - Acquires a Wi-Fi lock to prevent Wi-Fi from going to sleep
* - Sets [AudioManager] mode to [AudioManager.MODE_IN_COMMUNICATION]
* - Releases all resources when the call ends
*/
class CallService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
// -- Lifecycle ------------------------------------------------------------
override fun onCreate() {
super.onCreate()
acquireWakeLock()
acquireWifiLock()
setAudioMode()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopSelf()
return START_NOT_STICKY
}
}
startForeground(NOTIFICATION_ID, buildNotification())
return START_STICKY
}
override fun onDestroy() {
restoreAudioMode()
releaseWifiLock()
releaseWakeLock()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
// -- Notification ---------------------------------------------------------
private fun buildNotification(): Notification {
// Tapping the notification returns to the call screen
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
// "End call" action button
val stopIntent = PendingIntent.getService(
this,
1,
Intent(this, CallService::class.java).apply { action = ACTION_STOP },
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, WzpApplication.CHANNEL_ID)
.setContentTitle("WZ Phone")
.setContentText("Call in progress")
.setSmallIcon(android.R.drawable.ic_menu_call)
.setOngoing(true)
.setContentIntent(contentIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "End Call", stopIntent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
// -- Wake lock ------------------------------------------------------------
private fun acquireWakeLock() {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"wzp:call_wake_lock"
).apply {
acquire(MAX_CALL_DURATION_MS)
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) it.release()
}
wakeLock = null
}
// -- Wi-Fi lock -----------------------------------------------------------
@Suppress("DEPRECATION")
private fun acquireWifiLock() {
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
wifiLock = wm.createWifiLock(
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"wzp:call_wifi_lock"
).apply {
acquire()
}
}
private fun releaseWifiLock() {
wifiLock?.let {
if (it.isHeld) it.release()
}
wifiLock = null
}
// -- Audio mode -----------------------------------------------------------
private fun setAudioMode() {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
previousAudioMode = am.mode
am.mode = AudioManager.MODE_IN_COMMUNICATION
}
private fun restoreAudioMode() {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.mode = previousAudioMode
}
// -- Static helpers -------------------------------------------------------
companion object {
private const val NOTIFICATION_ID = 1001
private const val ACTION_STOP = "com.wzp.service.STOP"
private const val MAX_CALL_DURATION_MS = 4L * 60 * 60 * 1000 // 4 hours
/** Start the foreground call service. */
fun start(context: Context) {
val intent = Intent(context, CallService::class.java)
context.startForegroundService(intent)
}
/** Stop the foreground call service. */
fun stop(context: Context) {
val intent = Intent(context, CallService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
}

View File

@@ -0,0 +1,135 @@
package com.wzp.ui.call
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.wzp.service.CallService
/**
* Main activity hosting the in-call Compose UI.
*
* Requests RECORD_AUDIO permission, starts the foreground [CallService],
* and launches the call via [CallViewModel].
*/
class CallActivity : ComponentActivity() {
private val viewModel: CallViewModel by viewModels()
// -- Permission request ---------------------------------------------------
private val audioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
startCallFlow()
} else {
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG).show()
finish()
}
}
// -- Lifecycle ------------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WzpTheme {
InCallScreen(
viewModel = viewModel,
onHangUp = {
viewModel.stopCall()
CallService.stop(this@CallActivity)
finish()
}
)
}
}
// Check audio permission
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED
) {
startCallFlow()
} else {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
override fun onDestroy() {
super.onDestroy()
if (isFinishing) {
viewModel.stopCall()
CallService.stop(this)
}
}
// -- Call setup ------------------------------------------------------------
private fun startCallFlow() {
// Extract parameters from intent extras, with test defaults.
val relayAddr = intent.getStringExtra(EXTRA_RELAY_ADDR) ?: DEFAULT_RELAY
val room = intent.getStringExtra(EXTRA_ROOM) ?: DEFAULT_ROOM
val seedHex = intent.getStringExtra(EXTRA_SEED_HEX) ?: DEFAULT_SEED_HEX
val token = intent.getStringExtra(EXTRA_TOKEN) ?: DEFAULT_TOKEN
// Start foreground service
CallService.start(this)
// Start the call
viewModel.startCall(relayAddr, room, seedHex, token)
}
companion object {
const val EXTRA_RELAY_ADDR = "relay_addr"
const val EXTRA_ROOM = "room"
const val EXTRA_SEED_HEX = "seed_hex"
const val EXTRA_TOKEN = "token"
// Test defaults — replaced by real values in production
private const val DEFAULT_RELAY = "127.0.0.1:7777"
private const val DEFAULT_ROOM = "test-room"
private const val DEFAULT_SEED_HEX =
"0000000000000000000000000000000000000000000000000000000000000001"
private const val DEFAULT_TOKEN = "test-token"
}
}
/**
* WarzonePhone Material3 theme with dynamic colour support (Android 12+)
* and dark mode.
*/
@Composable
fun WzpTheme(content: @Composable () -> Unit) {
val darkTheme = isSystemInDarkTheme()
val context = LocalContext.current
val colorScheme = when {
// Dynamic colour is available on Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -0,0 +1,142 @@
package com.wzp.ui.call
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzp.engine.CallStats
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* ViewModel managing the call lifecycle and exposing observable state to the UI.
*
* Owns the [WzpEngine] instance, implements [WzpCallback] to receive engine events,
* and polls call statistics every 500 ms while the call is active.
*/
class CallViewModel : ViewModel(), WzpCallback {
// -- Engine ---------------------------------------------------------------
private val engine = WzpEngine(this)
// -- Observable state -----------------------------------------------------
private val _callState = MutableStateFlow(0) // CallStateConstants.IDLE
val callState: StateFlow<Int> = _callState.asStateFlow()
private val _isMuted = MutableStateFlow(false)
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
private val _isSpeaker = MutableStateFlow(false)
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
private val _stats = MutableStateFlow(CallStats())
val stats: StateFlow<CallStats> = _stats.asStateFlow()
private val _qualityTier = MutableStateFlow(0)
val qualityTier: StateFlow<Int> = _qualityTier.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// -- Stats polling --------------------------------------------------------
private var statsJob: Job? = null
// -- Public API -----------------------------------------------------------
/**
* Initialise the native engine and start a call.
*
* @param relayAddr relay server address (host:port)
* @param room room identifier
* @param seedHex 64-char hex-encoded 32-byte identity seed
* @param token authentication token
*/
fun startCall(relayAddr: String, room: String, seedHex: String, token: String) {
engine.init()
val result = engine.startCall(relayAddr, room, seedHex, token)
if (result == 0) {
startStatsPolling()
}
}
/** End the current call and clean up resources. */
fun stopCall() {
stopStatsPolling()
engine.stopCall()
}
/** Toggle microphone mute. */
fun toggleMute() {
val newMuted = !_isMuted.value
_isMuted.value = newMuted
engine.setMute(newMuted)
}
/** Toggle speaker (loudspeaker) mode. */
fun toggleSpeaker() {
val newSpeaker = !_isSpeaker.value
_isSpeaker.value = newSpeaker
engine.setSpeaker(newSpeaker)
}
/** Clear the current error message. */
fun clearError() {
_errorMessage.value = null
}
// -- WzpCallback ----------------------------------------------------------
override fun onCallStateChanged(state: Int) {
_callState.value = state
}
override fun onQualityTierChanged(tier: Int) {
_qualityTier.value = tier
}
override fun onError(code: Int, message: String) {
_errorMessage.value = "Error $code: $message"
}
// -- Stats polling --------------------------------------------------------
private fun startStatsPolling() {
statsJob?.cancel()
statsJob = viewModelScope.launch {
while (isActive) {
val json = engine.getStats()
val parsed = CallStats.fromJson(json)
_stats.value = parsed
_callState.value = parsed.state
_qualityTier.value = parsed.qualityTier
delay(STATS_POLL_INTERVAL_MS)
}
}
}
private fun stopStatsPolling() {
statsJob?.cancel()
statsJob = null
}
// -- Cleanup --------------------------------------------------------------
override fun onCleared() {
super.onCleared()
stopStatsPolling()
engine.stopCall()
engine.destroy()
}
companion object {
private const val STATS_POLL_INTERVAL_MS = 500L
}
}

View File

@@ -0,0 +1,328 @@
package com.wzp.ui.call
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wzp.engine.CallStats
import kotlin.math.roundToInt
/**
* Main in-call Compose screen.
*
* Displays call duration, quality indicator, audio controls, and live statistics.
*/
@Composable
fun InCallScreen(
viewModel: CallViewModel,
onHangUp: () -> Unit
) {
val callState by viewModel.callState.collectAsState()
val isMuted by viewModel.isMuted.collectAsState()
val isSpeaker by viewModel.isSpeaker.collectAsState()
val stats by viewModel.stats.collectAsState()
val qualityTier by viewModel.qualityTier.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
// -- Call state label ---------------------------------------------
CallStateLabel(callState)
Spacer(modifier = Modifier.height(16.dp))
// -- Duration -----------------------------------------------------
DurationDisplay(stats.durationSecs)
Spacer(modifier = Modifier.height(24.dp))
// -- Quality indicator --------------------------------------------
QualityIndicator(qualityTier, stats.qualityLabel)
Spacer(modifier = Modifier.height(32.dp))
// -- Audio level placeholder bar ----------------------------------
AudioLevelBar(stats.framesEncoded)
Spacer(modifier = Modifier.weight(1f))
// -- Control buttons ----------------------------------------------
ControlRow(
isMuted = isMuted,
isSpeaker = isSpeaker,
onToggleMute = viewModel::toggleMute,
onToggleSpeaker = viewModel::toggleSpeaker,
onHangUp = onHangUp
)
Spacer(modifier = Modifier.height(32.dp))
// -- Stats overlay ------------------------------------------------
StatsOverlay(stats)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
@Composable
private fun CallStateLabel(state: Int) {
val label = when (state) {
0 -> "Idle"
1 -> "Connecting..."
2 -> "Active"
3 -> "Reconnecting..."
4 -> "Call Ended"
else -> "Unknown"
}
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun DurationDisplay(durationSecs: Double) {
val totalSeconds = durationSecs.roundToInt()
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
Text(
text = "%02d:%02d".format(minutes, seconds),
style = MaterialTheme.typography.displayLarge.copy(
fontWeight = FontWeight.Light,
letterSpacing = 4.sp
),
color = MaterialTheme.colorScheme.onBackground
)
}
@Composable
private fun QualityIndicator(tier: Int, label: String) {
val dotColor = when (tier) {
0 -> Color(0xFF4CAF50) // green
1 -> Color(0xFFFFC107) // yellow
2 -> Color(0xFFF44336) // red
else -> Color.Gray
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(dotColor)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun AudioLevelBar(framesEncoded: Long) {
// Placeholder: derive a fake "level" from frame count to show the bar is alive.
// In production this would be driven by actual RMS audio levels from the engine.
val level = if (framesEncoded > 0) {
((framesEncoded % 100).toFloat() / 100f).coerceIn(0.05f, 1f)
} else {
0f
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Audio Level",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { level },
modifier = Modifier
.fillMaxWidth(0.6f)
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
@Composable
private fun ControlRow(
isMuted: Boolean,
isSpeaker: Boolean,
onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit,
onHangUp: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Mute button
FilledTonalIconButton(
onClick = onToggleMute,
modifier = Modifier.size(56.dp),
colors = if (isMuted) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
}
) {
Text(
text = if (isMuted) "MIC\nOFF" else "MIC",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp
)
}
// Hang up button
FilledIconButton(
onClick = onHangUp,
modifier = Modifier.size(72.dp),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = Color(0xFFF44336),
contentColor = Color.White
)
) {
Text(
text = "END",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold
)
)
}
// Speaker button
FilledTonalIconButton(
onClick = onToggleSpeaker,
modifier = Modifier.size(56.dp),
colors = if (isSpeaker) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
}
) {
Text(
text = if (isSpeaker) "SPK\nON" else "SPK",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp
)
}
}
}
@Composable
private fun StatsOverlay(stats: CallStats) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Network Stats",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem("Loss", "%.1f%%".format(stats.lossPct))
StatItem("RTT", "${stats.rttMs}ms")
StatItem("Jitter", "${stats.jitterMs}ms")
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem("Enc", "${stats.framesEncoded}")
StatItem("Dec", "${stats.framesDecoded}")
StatItem("JB Depth", "${stats.jitterBufferDepth}")
StatItem("Under", "${stats.underruns}")
}
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -46,6 +46,10 @@ struct EngineState {
running: AtomicBool,
muted: AtomicBool,
speaker: AtomicBool,
/// Whether acoustic echo cancellation is enabled (default: true).
aec_enabled: AtomicBool,
/// Whether automatic gain control is enabled (default: true).
agc_enabled: AtomicBool,
stats: Mutex<CallStats>,
command_tx: std::sync::mpsc::Sender<EngineCommand>,
command_rx: Mutex<Option<std::sync::mpsc::Receiver<EngineCommand>>>,
@@ -76,6 +80,8 @@ impl WzpEngine {
running: AtomicBool::new(false),
muted: AtomicBool::new(false),
speaker: AtomicBool::new(false),
aec_enabled: AtomicBool::new(true),
agc_enabled: AtomicBool::new(true),
stats: Mutex::new(CallStats::default()),
command_tx: tx,
command_rx: Mutex::new(Some(rx)),
@@ -182,6 +188,11 @@ impl WzpEngine {
info!("codec thread started");
// Track the last-applied AEC/AGC state so we only call
// set_*_enabled when the value actually changes.
let mut prev_aec = true;
let mut prev_agc = true;
let mut capture_buf = vec![0i16; FRAME_SAMPLES];
#[allow(unused_assignments)]
let mut recv_buf: Vec<u8> = Vec::new();
@@ -215,6 +226,18 @@ impl WzpEngine {
}
}
// Sync AEC/AGC enabled flags from shared state.
let cur_aec = state.aec_enabled.load(Ordering::Relaxed);
if cur_aec != prev_aec {
pipeline.set_aec_enabled(cur_aec);
prev_aec = cur_aec;
}
let cur_agc = state.agc_enabled.load(Ordering::Relaxed);
if cur_agc != prev_agc {
pipeline.set_agc_enabled(cur_agc);
prev_agc = cur_agc;
}
if !state.running.load(Ordering::Relaxed) {
break;
}
@@ -319,6 +342,16 @@ impl WzpEngine {
.send(EngineCommand::SetSpeaker(enabled));
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&self, enabled: bool) {
self.state.aec_enabled.store(enabled, Ordering::Relaxed);
}
/// Enable or disable automatic gain control.
pub fn set_agc_enabled(&self, enabled: bool) {
self.state.agc_enabled.store(enabled, Ordering::Relaxed);
}
/// Force a specific quality profile (overrides adaptive logic).
#[allow(unused)]
pub fn force_profile(&self, profile: QualityProfile) {

View File

@@ -0,0 +1,348 @@
//! JNI bridge for Android — thin layer between Kotlin and the WzpEngine.
//!
//! Each function converts JNI types to Rust types, delegates to WzpEngine,
//! and converts results back. No audio processing happens here.
//!
//! # Safety
//!
//! All functions in this module are called from the JVM via JNI. They use raw
//! pointers for the JNI environment and object references. The `jni` crate is
//! not yet a dependency, so we use raw FFI types and placeholder string extraction.
//! When the `jni` crate is added, the `extract_jstring` helper should be replaced
//! with proper `JNIEnv::get_string()` calls.
use std::os::raw::{c_long, c_void};
use std::panic;
use tracing::{error, info};
use wzp_proto::QualityProfile;
use crate::engine::{CallStartConfig, WzpEngine};
/// Opaque engine handle passed to/from Kotlin as a `jlong`.
///
/// Boxed on the heap; the raw pointer is stored on the Kotlin side.
/// Only `nativeDestroy` frees it.
struct EngineHandle {
engine: WzpEngine,
}
// ---------------------------------------------------------------------------
// JNI type aliases (mirrors the C JNI ABI without pulling in the `jni` crate)
// ---------------------------------------------------------------------------
/// JNI boolean — `u8` where 0 = false, non-zero = true.
type JBoolean = u8;
/// JNI int — `i32`.
type JInt = i32;
/// JNI long — `i64` / `c_long` on 64-bit.
type JLong = c_long;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Recover the `EngineHandle` from a raw handle value **without** taking ownership.
///
/// # Safety
/// `handle` must be a value previously returned by `nativeInit` and not yet
/// passed to `nativeDestroy`.
unsafe fn handle_ref(handle: JLong) -> &'static mut EngineHandle {
unsafe { &mut *(handle as *mut EngineHandle) }
}
/// Placeholder: extract a `String` from a JNI `jstring`.
///
/// When the `jni` crate is added this should be replaced with:
/// ```ignore
/// let env = JNIEnv::from_raw(env_ptr).unwrap();
/// env.get_string(jstring).unwrap().into()
/// ```
///
/// # Safety
/// `_env` and `_jstring` are raw JNI pointers.
#[allow(unused)]
unsafe fn extract_jstring(_env: *mut c_void, _jstring: *mut c_void) -> String {
// TODO(jni): implement real string extraction once the `jni` crate is added.
// For now return a default so the rest of the bridge compiles and can be tested
// with hardcoded values from the Kotlin side.
String::new()
}
/// Allocate a JNI `jstring` from a Rust `&str`.
///
/// # Safety
/// `_env` is a raw JNI pointer.
#[allow(unused)]
unsafe fn new_jstring(_env: *mut c_void, _s: &str) -> *mut c_void {
// TODO(jni): implement via JNIEnv::new_string when jni crate is added.
std::ptr::null_mut()
}
/// Map a Kotlin `profile` int to a `QualityProfile`.
fn profile_from_int(value: JInt) -> QualityProfile {
match value {
1 => QualityProfile::DEGRADED,
2 => QualityProfile::CATASTROPHIC,
_ => QualityProfile::GOOD,
}
}
// ---------------------------------------------------------------------------
// JNI exports
// ---------------------------------------------------------------------------
// Function names follow JNI convention: Java_<package>_<Class>_<method>
// with underscores in the package replaced by `_1` in actual JNI but here we
// use the simplified form that matches javah output for the package `com.wzp.engine`.
/// Create a new `WzpEngine`, returning an opaque handle as `jlong`.
///
/// Kotlin signature: `private external fun nativeInit(): Long`
///
/// # Safety
/// Called from JNI.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeInit(
_env: *mut c_void,
_class: *mut c_void,
) -> JLong {
let result = panic::catch_unwind(|| {
// Initialise tracing once (ignore errors if already set).
#[cfg(target_os = "android")]
{
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.try_init();
}
let handle = Box::new(EngineHandle {
engine: WzpEngine::new(),
});
info!("WzpEngine created via JNI");
Box::into_raw(handle) as JLong
});
match result {
Ok(h) => h,
Err(_) => {
error!("panic in nativeInit");
0 // null handle — Kotlin side checks for 0
}
}
}
/// Start a call.
///
/// Kotlin signature:
/// ```kotlin
/// private external fun nativeStartCall(
/// handle: Long, relay: String, room: String, seed: String, token: String
/// ): Int
/// ```
///
/// Returns 0 on success, -1 on error.
///
/// # Safety
/// Called from JNI. `handle` must be a live engine handle.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
env: *mut c_void,
_class: *mut c_void,
handle: JLong,
relay_addr_ptr: *mut c_void,
room_ptr: *mut c_void,
seed_hex_ptr: *mut c_void,
token_ptr: *mut c_void,
) -> JInt {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
// Extract strings from JNI. When the `jni` crate is available these
// will use real JNI string conversion. For now, placeholders.
let relay_addr = unsafe { extract_jstring(env, relay_addr_ptr) };
let _room = unsafe { extract_jstring(env, room_ptr) };
let seed_hex = unsafe { extract_jstring(env, seed_hex_ptr) };
let token = unsafe { extract_jstring(env, token_ptr) };
// Parse the hex-encoded 32-byte identity seed.
let mut identity_seed = [0u8; 32];
if seed_hex.len() == 64 {
for i in 0..32 {
if let Ok(byte) = u8::from_str_radix(&seed_hex[i * 2..i * 2 + 2], 16) {
identity_seed[i] = byte;
}
}
}
let config = CallStartConfig {
profile: QualityProfile::GOOD,
relay_addr,
auth_token: token.into_bytes(),
identity_seed,
};
match h.engine.start_call(config) {
Ok(()) => {
info!("call started via JNI");
0
}
Err(e) => {
error!("start_call failed: {e}");
-1
}
}
}));
match result {
Ok(code) => code,
Err(_) => {
error!("panic in nativeStartCall");
-1
}
}
}
/// Stop the active call.
///
/// Kotlin signature: `private external fun nativeStopCall(handle: Long)`
///
/// # Safety
/// Called from JNI.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStopCall(
_env: *mut c_void,
_class: *mut c_void,
handle: JLong,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.stop_call();
info!("call stopped via JNI");
}));
}
/// Set microphone mute state.
///
/// Kotlin signature: `private external fun nativeSetMute(handle: Long, muted: Boolean)`
///
/// # Safety
/// Called from JNI.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetMute(
_env: *mut c_void,
_class: *mut c_void,
handle: JLong,
muted: JBoolean,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let muted = muted != 0;
h.engine.set_mute(muted);
info!(muted, "mute set via JNI");
}));
}
/// Set speaker (loudspeaker) mode.
///
/// Kotlin signature: `private external fun nativeSetSpeaker(handle: Long, speaker: Boolean)`
///
/// # Safety
/// Called from JNI.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeSetSpeaker(
_env: *mut c_void,
_class: *mut c_void,
handle: JLong,
speaker: JBoolean,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let speaker = speaker != 0;
h.engine.set_speaker(speaker);
info!(speaker, "speaker set via JNI");
}));
}
/// Get call statistics as a JSON string.
///
/// Kotlin signature: `private external fun nativeGetStats(handle: Long): String`
///
/// Returns a JSON-serialized `CallStats` struct, or `"{}"` on error.
///
/// # Safety
/// Called from JNI.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeGetStats(
env: *mut c_void,
_class: *mut c_void,
handle: JLong,
) -> *mut c_void {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let stats = h.engine.get_stats();
match serde_json::to_string(&stats) {
Ok(json) => unsafe { new_jstring(env, &json) },
Err(e) => {
error!("failed to serialize stats: {e}");
unsafe { new_jstring(env, "{}") }
}
}
}));
match result {
Ok(ptr) => ptr,
Err(_) => {
error!("panic in nativeGetStats");
unsafe { new_jstring(env, "{}") }
}
}
}
/// Force a specific quality profile, overriding adaptive logic.
///
/// Kotlin signature: `private external fun nativeForceProfile(handle: Long, profile: Int)`
///
/// Profile values: 0 = GOOD, 1 = DEGRADED, 2 = CATASTROPHIC.
///
/// # Safety
/// Called from JNI.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
_env: *mut c_void,
_class: *mut c_void,
handle: JLong,
profile: JInt,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
let qp = profile_from_int(profile);
h.engine.force_profile(qp);
info!(?qp, "profile forced via JNI");
}));
}
/// Destroy the engine and free all associated memory.
///
/// After this call the handle is invalid and must not be reused.
///
/// Kotlin signature: `private external fun nativeDestroy(handle: Long)`
///
/// # Safety
/// Called from JNI. `handle` must be a live engine handle. After this call
/// the handle is dangling.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeDestroy(
_env: *mut c_void,
_class: *mut c_void,
handle: JLong,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
// Retake ownership of the Box and drop it, which calls WzpEngine::drop()
// and in turn stop_call().
let h = unsafe { Box::from_raw(handle as *mut EngineHandle) };
drop(h);
info!("engine destroyed via JNI");
}));
}

View File

@@ -14,4 +14,4 @@ pub mod commands;
pub mod engine;
pub mod pipeline;
pub mod stats;
// pub mod jni_bridge; // Added later by Agent 4
pub mod jni_bridge;

View File

@@ -5,7 +5,7 @@
//! exclusively by the codec thread.
use tracing::{debug, warn};
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder};
use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller};
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::quality::AdaptiveQualityController;
@@ -38,6 +38,12 @@ pub struct Pipeline {
fec_decoder: RaptorQFecDecoder,
jitter_buffer: JitterBuffer,
quality_ctrl: AdaptiveQualityController,
/// Acoustic echo canceller applied before encoding.
aec: EchoCanceller,
/// Automatic gain control applied before encoding.
agc: AutoGainControl,
/// Last decoded PCM frame, used as the AEC far-end reference.
last_decoded_farend: Option<Vec<i16>>,
// Pre-allocated scratch buffers
capture_buf: Vec<i16>,
#[allow(dead_code)]
@@ -70,6 +76,9 @@ impl Pipeline {
fec_decoder,
jitter_buffer,
quality_ctrl,
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
agc: AutoGainControl::new(),
last_decoded_farend: None,
capture_buf: vec![0i16; FRAME_SAMPLES],
playout_buf: vec![0i16; FRAME_SAMPLES],
encode_out: vec![0u8; MAX_ENCODED_BYTES],
@@ -91,7 +100,17 @@ impl Pipeline {
}
&self.capture_buf[..]
} else {
pcm
// Feed the last decoded playout as AEC far-end reference.
if let Some(ref farend) = self.last_decoded_farend {
self.aec.feed_farend(farend);
}
// Apply AEC + AGC to the captured PCM.
let len = pcm.len().min(self.capture_buf.len());
self.capture_buf[..len].copy_from_slice(&pcm[..len]);
self.aec.process_frame(&mut self.capture_buf[..len]);
self.agc.process_frame(&mut self.capture_buf[..len]);
&self.capture_buf[..len]
};
match self.encoder.encode(input, &mut self.encode_out) {
@@ -135,8 +154,10 @@ impl Pipeline {
/// Decode the next frame from the jitter buffer.
///
/// Returns decoded PCM samples, or `None` if the buffer is not ready.
/// Decoded PCM is also stored as the AEC far-end reference for the next
/// encode cycle.
pub fn decode_frame(&mut self) -> Option<Vec<i16>> {
match self.jitter_buffer.pop() {
let result = match self.jitter_buffer.pop() {
PlayoutResult::Packet(pkt) => {
let mut pcm = vec![0i16; FRAME_SAMPLES];
match self.decoder.decode(&pkt.payload, &mut pcm) {
@@ -160,7 +181,14 @@ impl Pipeline {
self.underruns += 1;
None
}
};
// Save decoded PCM as far-end reference for AEC.
if let Some(ref pcm) = result {
self.last_decoded_farend = Some(pcm.clone());
}
result
}
/// Generate packet loss concealment output.
@@ -221,4 +249,14 @@ impl Pipeline {
quality_tier: self.quality_ctrl.tier() as u8,
}
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&mut self, enabled: bool) {
self.aec.set_enabled(enabled);
}
/// Enable or disable automatic gain control.
pub fn set_agc_enabled(&mut self, enabled: bool) {
self.agc.set_enabled(enabled);
}
}

View File

@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use bytes::Bytes;
use tracing::{debug, info, warn};
use wzp_codec::{ComfortNoise, NoiseSupressor, SilenceDetector};
use wzp_codec::{AutoGainControl, ComfortNoise, EchoCanceller, NoiseSupressor, SilenceDetector};
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
@@ -207,6 +207,10 @@ pub struct CallEncoder {
frame_in_block: u8,
/// Timestamp counter (ms).
timestamp_ms: u32,
/// Acoustic echo canceller (removes speaker echo from mic signal).
aec: EchoCanceller,
/// Automatic gain control (normalises mic level).
agc: AutoGainControl,
/// Silence detector for suppression.
silence_detector: SilenceDetector,
/// Whether silence suppression is enabled.
@@ -237,6 +241,8 @@ impl CallEncoder {
block_id: 0,
frame_in_block: 0,
timestamp_ms: 0,
aec: EchoCanceller::new(48000, 100), // 100 ms echo tail
agc: AutoGainControl::new(),
silence_detector: SilenceDetector::new(
config.silence_threshold_rms,
config.silence_hangover_frames,
@@ -274,15 +280,21 @@ impl CallEncoder {
/// Input: 48kHz mono PCM, frame size depends on profile (960 for 20ms, 1920 for 40ms).
/// Output: one or more MediaPackets to send.
pub fn encode_frame(&mut self, pcm: &[i16]) -> Result<Vec<MediaPacket>, anyhow::Error> {
// Noise suppression: denoise the PCM before silence detection and encoding.
let pcm = if self.denoiser.is_enabled() {
let mut buf = pcm.to_vec();
self.denoiser.process(&mut buf);
buf
} else {
pcm.to_vec()
};
let pcm = &pcm[..];
// Copy PCM into a mutable buffer for the processing pipeline.
let mut pcm_buf = pcm.to_vec();
// Step 1: Echo cancellation (far-end reference must have been fed already).
self.aec.process_frame(&mut pcm_buf);
// Step 2: Automatic gain control (normalise mic level).
self.agc.process_frame(&mut pcm_buf);
// Step 3: Noise suppression (RNNoise).
if self.denoiser.is_enabled() {
self.denoiser.process(&mut pcm_buf);
}
let pcm = &pcm_buf[..];
// Silence suppression: skip encoding silent frames, periodically send CN.
if self.suppression_enabled && self.silence_detector.is_silent(pcm) {
@@ -400,6 +412,24 @@ impl CallEncoder {
self.frame_in_block = 0;
Ok(())
}
/// Feed decoded playout audio as the echo reference signal.
///
/// Must be called with each decoded frame BEFORE the corresponding
/// microphone frame is processed.
pub fn feed_aec_farend(&mut self, farend: &[i16]) {
self.aec.feed_farend(farend);
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&mut self, enabled: bool) {
self.aec.set_enabled(enabled);
}
/// Enable or disable automatic gain control.
pub fn set_agc_enabled(&mut self, enabled: bool) {
self.agc.set_enabled(enabled);
}
}
/// Manages the recv/decode side of a call.