feat: add real audio pipeline with Opus + RaptorQ FEC
- AudioPipeline: Kotlin AudioRecord/AudioTrack on JVM threads, PCM shuttled to Rust via lock-free ring buffers + JNI - FEC: RaptorQ fountain codes on encode (5 frames/block, 20% repair ratio for GOOD profile), decoder feeds repair symbols for recovery - Real audio level meter from mic RMS (replaces fake animation) - Room name editable in UI (default: "android") - Relay changed to pangolin.manko.yoga:4433 - Stats overlay shows FEC recovered count - CallState now synced from polled stats (fixes "Connecting" stuck bug) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,8 @@ class CallActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.setContext(this)
|
||||
|
||||
setContent {
|
||||
WzpTheme {
|
||||
InCallScreen(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.wzp.ui.call
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wzp.audio.AudioPipeline
|
||||
import com.wzp.engine.CallStats
|
||||
import com.wzp.engine.WzpCallback
|
||||
import com.wzp.engine.WzpEngine
|
||||
@@ -17,9 +19,11 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
private var engine: WzpEngine? = null
|
||||
private var engineInitialized = false
|
||||
private var audioPipeline: AudioPipeline? = null
|
||||
private var audioStarted = false
|
||||
|
||||
private val _callState = MutableStateFlow(0)
|
||||
val callState: StateFlow<Int> = _callState.asStateFlow()
|
||||
val callState: StateFlow<Int> get() = _callState.asStateFlow()
|
||||
|
||||
private val _isMuted = MutableStateFlow(false)
|
||||
val isMuted: StateFlow<Boolean> = _isMuted.asStateFlow()
|
||||
@@ -36,16 +40,26 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val _roomName = MutableStateFlow(DEFAULT_ROOM)
|
||||
val roomName: StateFlow<String> = _roomName.asStateFlow()
|
||||
|
||||
private var statsJob: Job? = null
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_RELAY = "172.16.81.175:4433"
|
||||
const val DEFAULT_RELAY = "pangolin.manko.yoga:4433"
|
||||
const val DEFAULT_ROOM = "android"
|
||||
}
|
||||
|
||||
/** Must be called once with Activity context before startCall. */
|
||||
fun setContext(context: Context) {
|
||||
if (audioPipeline == null) {
|
||||
audioPipeline = AudioPipeline(context.applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
fun startCall(
|
||||
relayAddr: String = DEFAULT_RELAY,
|
||||
room: String = DEFAULT_ROOM
|
||||
room: String = _roomName.value
|
||||
) {
|
||||
try {
|
||||
if (engine == null) {
|
||||
@@ -58,9 +72,6 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
_callState.value = 1 // Connecting
|
||||
startStatsPolling()
|
||||
|
||||
// startCall blocks (runs tokio on calling thread), so dispatch
|
||||
// to a background coroutine. Using Dispatchers.IO which uses
|
||||
// Java threads (not native pthread_create).
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val result = engine?.startCall(relayAddr, room) ?: -1
|
||||
@@ -80,6 +91,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
}
|
||||
|
||||
fun stopCall() {
|
||||
stopAudio()
|
||||
stopStatsPolling()
|
||||
try {
|
||||
engine?.stopCall()
|
||||
@@ -101,11 +113,26 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
fun clearError() { _errorMessage.value = null }
|
||||
|
||||
fun setRoomName(name: String) { _roomName.value = name }
|
||||
|
||||
// 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" }
|
||||
|
||||
private fun startAudio() {
|
||||
if (audioStarted) return
|
||||
val e = engine ?: return
|
||||
audioPipeline?.start(e)
|
||||
audioStarted = true
|
||||
}
|
||||
|
||||
private fun stopAudio() {
|
||||
if (!audioStarted) return
|
||||
audioPipeline?.stop()
|
||||
audioStarted = false
|
||||
}
|
||||
|
||||
private fun startStatsPolling() {
|
||||
statsJob?.cancel()
|
||||
statsJob = viewModelScope.launch {
|
||||
@@ -113,7 +140,16 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
try {
|
||||
val json = engine?.getStats() ?: "{}"
|
||||
if (json.isNotEmpty()) {
|
||||
_stats.value = CallStats.fromJson(json)
|
||||
val s = CallStats.fromJson(json)
|
||||
_stats.value = s
|
||||
// Sync call state from native engine stats
|
||||
if (s.state != 0) {
|
||||
_callState.value = s.state
|
||||
}
|
||||
// Start audio pipeline when call becomes active
|
||||
if (s.state == 2 && !audioStarted) {
|
||||
startAudio()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
delay(500L)
|
||||
@@ -128,6 +164,7 @@ class CallViewModel : ViewModel(), WzpCallback {
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopAudio()
|
||||
stopStatsPolling()
|
||||
try {
|
||||
engine?.stopCall()
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -48,6 +49,7 @@ fun InCallScreen(
|
||||
val stats by viewModel.stats.collectAsState()
|
||||
val qualityTier by viewModel.qualityTier.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
val roomName by viewModel.roomName.collectAsState()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -83,11 +85,13 @@ fun InCallScreen(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Room: ${CallViewModel.DEFAULT_ROOM}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = roomName,
|
||||
onValueChange = { viewModel.setRoomName(it) },
|
||||
label = { Text("Room") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(0.6f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
@@ -132,7 +136,7 @@ fun InCallScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
AudioLevelBar(stats.framesEncoded)
|
||||
AudioLevelBar(stats.audioLevel)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
@@ -222,9 +226,11 @@ private fun QualityIndicator(tier: Int, label: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioLevelBar(framesEncoded: Long) {
|
||||
val level = if (framesEncoded > 0) {
|
||||
((framesEncoded % 100).toFloat() / 100f).coerceIn(0.05f, 1f)
|
||||
private fun AudioLevelBar(audioLevel: Int) {
|
||||
// audioLevel is RMS of i16 samples (0-32767).
|
||||
// Map to 0.0-1.0 with a log-ish curve for better visual feel.
|
||||
val level = if (audioLevel > 0) {
|
||||
(audioLevel.toFloat() / 8000f).coerceIn(0.02f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
@@ -351,7 +357,7 @@ private fun StatsOverlay(stats: CallStats) {
|
||||
) {
|
||||
StatItem("Enc", "${stats.framesEncoded}")
|
||||
StatItem("Dec", "${stats.framesDecoded}")
|
||||
StatItem("JB", "${stats.jitterBufferDepth}")
|
||||
StatItem("FEC", "${stats.fecRecovered}")
|
||||
StatItem("Under", "${stats.underruns}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user