24 Commits

Author SHA1 Message Date
Siavash Sameni
d249b32ee5 test+docs: add tests for QualityDirective, ParticipantQuality; update docs
- QualityDirective signal roundtrip tests (with/without reason)
- ParticipantQuality unit tests (initial tier, degradation, weakest-link)
- Updated PROGRESS.md with desktop adaptive quality, relay coordinated
  switching, Oboe state polling entries
- Updated ARCHITECTURE.md SFU fan-out rules with QualityDirective
- Updated PRD-coordinated-codec.md with implementation status
- 312 tests passing across all modified crates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:56:46 +04:00
Siavash Sameni
22045bc5e6 feat: adaptive quality in desktop, relay quality directive, Oboe state polling
- Wire AdaptiveQualityController into desktop engine send/recv tasks
  (mirrors Android pattern: AtomicU8 pending_profile, auto-mode check)
- Wire same into Android engine send task (was only in recv before)
- QualityDirective SignalMessage variant for relay-initiated codec switch
- ParticipantQuality tracking in relay RoomManager (per-participant
  AdaptiveQualityController, weakest-link tier computation)
- Relay broadcasts QualityDirective to all participants when room-wide
  tier degrades (coordinated codec switching)
- Oboe stream state polling: poll getState() for up to 2s after
  requestStart() to ensure both streams reach Started before proceeding
  (fixes intermittent silent calls on cold start, Nothing Phone A059)

Tasks: #7, #25, #26, #31, #35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:54:04 +04:00
Siavash Sameni
766c9df442 feat(dred): continuous DRED tuning, PMTUD, extended Opus6k window
- DredTuner: maps live network metrics (loss/RTT/jitter) to continuous
  DRED duration every ~500ms instead of discrete tier-locked values.
  Includes jitter-spike detection for pre-emptive Starlink-style boost.
- Opus6k DRED extended from 500ms to 1040ms (max libopus 1.5 supports)
- PMTUD: quinn MtuDiscoveryConfig with upper_bound=1452, 300s interval
- TrunkedForwarder respects discovered MTU (was hard-coded 1200)
- QuinnPathSnapshot exposes quinn internal stats + discovered MTU
- AudioEncoder trait: set_expected_loss() + set_dred_duration() methods
- PathMonitor: sliding-window jitter variance for spike detection
- Integrated into both Android and desktop send tasks in engine.rs
- 14 new tests (10 tuner unit + 4 encoder integration)
- Updated ARCHITECTURE.md, PROGRESS.md, PRD-dred-integration, PRD-mtu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:38:37 +04:00
Siavash Sameni
24cc74d93c fix(audio): clear BT SCO communication device on call end
Without clearCommunicationDevice(), the BT headset stays locked in SCO
mode after the call. Media playback (video, music) can't route to BT
A2DP, requiring a device reboot to restore normal audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:40:44 +04:00
Siavash Sameni
300ea66d13 docs: update DESIGN, ARCHITECTURE, PRDs, PROGRESS for BT + network + build changes
Reflects the current reality: setCommunicationDevice API 31+, deferred
MODE_IN_COMMUNICATION, BT-mode Oboe (bt_active flag), per-arch builds,
Hangup call_id fix, and network monitoring integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:39:59 +04:00
Siavash Sameni
114d69e488 fix: use tracing::warn! instead of bare warn! in engine.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:31:12 +04:00
Siavash Sameni
15c237ceea fix(audio): defer MODE_IN_COMMUNICATION to call start, restore on end
Root cause: MainActivity set MODE_IN_COMMUNICATION at app launch,
hijacking system audio routing immediately — BT A2DP music dropped to
earpiece, and the pre-existing communication mode confused subsequent
setCommunicationDevice calls for BT SCO.

Fix: MainActivity now only sets volumes. MODE_IN_COMMUNICATION is set
via JNI right before Oboe audio_start() in CallEngine, and MODE_NORMAL
is restored after audio_stop() when the call ends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:29:59 +04:00
Siavash Sameni
a37c8b30fe fix(native): add missing bt_active field to stall detector config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:25:11 +04:00
Siavash Sameni
137fe5f084 fix(bluetooth): BT SCO mode skips 48kHz + VoiceCommunication on capture
Root cause: Oboe capture at 48kHz with InputPreset::VoiceCommunication
cannot open against a BT SCO device (only supports 8/16kHz). The stream
silently falls back to builtin mic, delivering zeros.

Fix: add bt_active flag to WzpOboeConfig. When set, capture skips
setSampleRate and setInputPreset, letting the system route to BT SCO
at its native rate. Oboe's SampleRateConversionQuality::Best resamples
to 48kHz for our ring buffers. Playout uses Usage::Media in BT mode.

New API: wzp_native_audio_start_bt() for BT mode, called from
set_bluetooth_sco(on=true). Normal audio_start() restores the
standard config when switching back to earpiece/speaker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:23:19 +04:00
Siavash Sameni
5dfb5b3581 fix(bluetooth): use Shared mode for Oboe + delay restart for BT route
Two fixes for BT audio silence:

1. Switch Oboe streams from Exclusive to Shared sharing mode. Exclusive
   mode bypasses Oboe's internal resampler, so opening a 48kHz stream
   against a BT SCO device (8/16kHz only) fails at the AudioPolicy
   level. Shared mode lets Oboe's resampler bridge the gap.

2. Add 500ms post-SCO delay before Oboe restart. The audio policy needs
   time to apply the bt-sco route after setCommunicationDevice returns.
   Without the delay, Oboe opens against the old device (handset).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:14:06 +04:00
Siavash Sameni
fd0ccf8e99 fix(bluetooth): enable Oboe sample rate conversion for BT SCO (8/16kHz)
BT SCO devices only support 8kHz or 16kHz but our Oboe streams request
48kHz. Without resampling, AudioPolicyManager rejects the input stream
("getInputProfile could not find profile for... sampling rate 48000").

Fix: add setSampleRateConversionQuality(Best) to both capture and
playout stream builders. Oboe resamples internally so our ring buffers
stay at 48kHz regardless of the hardware sample rate.

Also removes the broken setBluetoothScoOn/isBluetoothScoOn calls from
stop_bluetooth_sco — just call stopBluetoothSco() unconditionally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:08:48 +04:00
Siavash Sameni
2d4948a7b3 fix(bluetooth): add missing &[] arg to getAvailableCommunicationDevices JNI call
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:02:57 +04:00
Siavash Sameni
19703ff66c fix(bluetooth): use setCommunicationDevice API on Android 12+
Root cause: setBluetoothScoOn(true) is silently rejected on Android 12+
for non-system apps ("is greater than FIRST_APPLICATION_UID exiting").
Audio policy routed to handset instead of BT despite SCO link being up.

Fix: use the modern setCommunicationDevice(AudioDeviceInfo) API on
API 31+ which properly routes voice audio to the BT device. Falls back
to deprecated startBluetoothSco() on older APIs.

Also uses getCommunicationDevice() for is_bluetooth_sco_on() and
clearCommunicationDevice() for stop, matching the modern API surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:01:33 +04:00
Siavash Sameni
7e8dc400dc fix(bluetooth): wait for SCO link before Oboe restart + detect A2DP devices
Three fixes for Bluetooth audio not working:

1. is_bluetooth_available() now checks for TYPE_BLUETOOTH_A2DP (8) in
   addition to TYPE_BLUETOOTH_SCO (7) — many headsets only register as
   A2DP until SCO is explicitly started.

2. set_bluetooth_sco(on=true) polls isBluetoothScoOn() for up to 3s
   before restarting Oboe. startBluetoothSco() is async — the SCO link
   takes 500ms-2s to establish. Without waiting, Oboe opens against
   earpiece and audio goes nowhere.

3. Frontend skips redundant set_speakerphone(false) when transitioning
   to BT — start_bluetooth_sco() handles speaker-off internally,
   avoiding a double Oboe restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:46:56 +04:00
Siavash Sameni
a798634b3d fix(signal): add call_id to Hangup — prevents stale hangup killing new calls
Root cause: Hangup had no call_id field. The relay forwarded hangups to
ALL active calls for a user. When user A hung up call 1 and user B
immediately placed call 2, the relay's processing of A's hangup would
also kill call 2 (race window ~1-2s).

Fix: add optional call_id to Hangup (backwards-compatible via serde
skip_serializing_if). When present, the relay only ends the named call.
Old clients send call_id=None and get the legacy broadcast behavior.

Also: clear pending_path_report in Hangup recv handler and
internal_deregister to prevent stale oneshot channels from blocking
subsequent call setups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:39:21 +04:00
Siavash Sameni
d89376016a fix(build): sign release APKs with project keystore (wzp-release.jks)
Release builds from cargo-tauri are unsigned. After Gradle produces the
APK, zipalign + apksigner now sign it with the release keystore
(android/keystore/wzp-release.jks). Falls back to debug keystore if
release is missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:21:38 +04:00
Siavash Sameni
678695776e fix(build): correct APK output path — target/ is mounted from cache dir
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:10:03 +04:00
Siavash Sameni
4c1ad841e1 feat(android): Bluetooth audio routing + network change detection + per-arch APK builds
Bluetooth: wire existing AudioRouteManager SCO support through both app
variants. Replace binary speaker toggle with 3-way route cycling
(Earpiece → Speaker → Bluetooth). Tauri side adds JNI bridge functions
(start/stop/query SCO, device availability) and Oboe stream restart.

Network awareness: integrate Android ConnectivityManager to detect
WiFi/cellular transitions and feed them to AdaptiveQualityController
via lock-free AtomicU8 signaling. Enables proactive quality downgrade
and FEC boost on network handoffs.

Build: add --arch flag to build-tauri-android.sh supporting arm64,
armv7, or all (separate per-arch APKs for smaller tester binaries).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:07:41 +04:00
Siavash Sameni
29cd23fe39 fix(p2p): connection cleanup — 4 fixes for stale/dead connections
PRD 4: Disable IPv6 direct dial/accept temporarily. IPv6 QUIC
handshakes succeed but connections die immediately on datagram
send ("connection lost"). IPv4 candidates work reliably. IPv6
candidates still gathered but filtered at dial time.

PRD 1: Close losing transport after Phase 6 negotiation. The
non-selected transport now gets an explicit QUIC close frame
instead of silently dropping after 30s idle timeout. Prevents
phantom connections from polluting future accept() calls.

PRD 2: Harden accept loop with max 3 stale retries. Stale
connections are explicitly closed (conn.close) and counted.
After 3 stale connections, the accept loop aborts instead of
spinning until the race timeout.

PRD 3: Resource cleanup — close old IPv6 endpoint before
creating a new one in place_call/answer_call. Add Drop impl
to CallEngine so tasks are signalled to stop on ungraceful
shutdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:11:50 +04:00
Siavash Sameni
4d66d3769d fix(relay): set peer_relay_fp on originating relay when answer arrives
The originating relay (where the caller is) never set peer_relay_fp
because the call was created locally. When the callee's answer
arrived via federation, the cross-relay dispatcher handled it but
didn't mark the call as cross-relay. This meant the caller's
MediaPathReport was delivered via local hub.send_to() to a peer
fingerprint that isn't connected locally — silently dropped.

Fix: in the cross-relay answer dispatcher, call
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp)) so the
originating relay knows to forward MediaPathReport via federation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:49:34 +04:00
Siavash Sameni
002df15c5e fix(cli): add .. rest pattern for RegisterPresenceAck error arm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:32:57 +04:00
Siavash Sameni
1eb82d77b8 feat(relay+client): relay reports build version in Ack
Add relay_build field to RegisterPresenceAck so the client logs
which relay version it connected to. Shows in the debug log as
register_signal:ack_received {"relay_build":"f843a93"}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:27:58 +04:00
Siavash Sameni
f843a934fe fix(relay): forward MediaPathReport across federation
MediaPathReport was only delivered via local signal_hub, so calls
between peers on different relays always hit peer_report_timeout
and fell back to relay — even when direct P2P worked perfectly.

Fix: check peer_relay_fp in call_registry (same pattern as
DirectCallAnswer). If the peer is on a remote relay, wrap in
FederatedSignalForward and send via federation link. Also fix
the cross-relay dispatcher to deliver to BOTH caller and callee
(not just caller), since the report can come from either side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:14:30 +04:00
Siavash Sameni
b79073c649 Revert "fix(connect): trust direct path on peer report timeout"
This reverts commit 82b439595c.
2026-04-12 14:10:44 +04:00
46 changed files with 2827 additions and 270 deletions

View File

@@ -96,6 +96,17 @@ class WzpEngine(private val callback: WzpCallback) {
if (nativeHandle != 0L) nativeForceProfile(nativeHandle, profile)
}
/**
* Signal a network transport change (e.g. WiFi → LTE handoff).
*
* @param networkType matches Rust `NetworkContext` ordinals:
* 0=WiFi, 1=LTE, 2=5G, 3=3G, 4=Unknown, 5=None
* @param bandwidthKbps reported downstream bandwidth in kbps
*/
fun onNetworkChanged(networkType: Int, bandwidthKbps: Int) {
if (nativeHandle != 0L) nativeOnNetworkChanged(nativeHandle, networkType, bandwidthKbps)
}
/** Destroy the native engine and free all resources. The instance must not be reused. */
@Synchronized
fun destroy() {
@@ -163,6 +174,7 @@ class WzpEngine(private val callback: WzpCallback) {
private external fun nativeStartSignaling(handle: Long, relay: String, seed: String, token: String, alias: String): Int
private external fun nativePlaceCall(handle: Long, targetFp: String): Int
private external fun nativeAnswerCall(handle: Long, callId: String, mode: Int): Int
private external fun nativeOnNetworkChanged(handle: Long, networkType: Int, bandwidthKbps: Int)
/**
* Ping a relay server. Requires engine to be initialized.

View File

@@ -0,0 +1,141 @@
package com.wzp.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Handler
import android.os.Looper
/**
* Monitors network connectivity changes via [ConnectivityManager.NetworkCallback]
* and classifies the active transport (WiFi, LTE, 5G, 3G).
*
* Callbacks fire on the main looper so callers can safely update UI state or
* dispatch to a native engine from any callback.
*
* Usage:
* 1. Set [onNetworkChanged] to receive `(type: Int, downlinkKbps: Int)` events
* 2. Optionally set [onIpChanged] for IP address change events (mid-call ICE refresh)
* 3. Call [register] when the call starts
* 4. Call [unregister] when the call ends
*/
class NetworkMonitor(context: Context) {
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Called when the network transport type or bandwidth changes.
* `type` constants match the Rust `NetworkContext` enum ordinals.
*/
var onNetworkChanged: ((type: Int, downlinkKbps: Int) -> Unit)? = null
/**
* Called when the device's IP address changes (link properties changed).
* Useful for triggering mid-call ICE candidate re-gathering.
*/
var onIpChanged: (() -> Unit)? = null
// Track the last emitted type to avoid redundant callbacks
@Volatile
private var lastEmittedType: Int = TYPE_UNKNOWN
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
classifyAndEmit(network)
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
classifyFromCaps(caps)
}
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: android.net.LinkProperties
) {
// IP address may have changed — notify for ICE refresh
onIpChanged?.invoke()
// Also re-classify in case the transport changed simultaneously
classifyAndEmit(network)
}
override fun onLost(network: Network) {
lastEmittedType = TYPE_NONE
onNetworkChanged?.invoke(TYPE_NONE, 0)
}
}
// -- Public API -----------------------------------------------------------
/** Register the network callback. Call when a call starts. */
fun register() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback, mainHandler)
}
/** Unregister the network callback. Call when the call ends. */
fun unregister() {
try {
cm.unregisterNetworkCallback(callback)
} catch (_: IllegalArgumentException) {
// Already unregistered — safe to ignore
}
}
// -- Classification -------------------------------------------------------
private fun classifyAndEmit(network: Network) {
val caps = cm.getNetworkCapabilities(network) ?: return
classifyFromCaps(caps)
}
private fun classifyFromCaps(caps: NetworkCapabilities) {
val type = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_WIFI // treat as WiFi
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> classifyCellular(caps)
else -> TYPE_UNKNOWN
}
val bw = caps.getLinkDownstreamBandwidthKbps()
// Deduplicate: only emit when the transport type actually changes
if (type != lastEmittedType) {
lastEmittedType = type
onNetworkChanged?.invoke(type, bw)
}
}
/**
* Approximate cellular generation from reported downstream bandwidth.
* This avoids requiring READ_PHONE_STATE permission (needed for
* TelephonyManager.getNetworkType on API 30+).
*
* Thresholds are conservative — carriers over-report bandwidth, so we
* classify based on what's actually usable for VoIP:
* - >= 100 Mbps → 5G NR
* - >= 10 Mbps → LTE
* - < 10 Mbps → 3G or worse
*/
private fun classifyCellular(caps: NetworkCapabilities): Int {
val bw = caps.getLinkDownstreamBandwidthKbps()
return when {
bw >= 100_000 -> TYPE_CELLULAR_5G
bw >= 10_000 -> TYPE_CELLULAR_LTE
else -> TYPE_CELLULAR_3G
}
}
companion object {
/** Constants matching Rust `NetworkContext` enum ordinals. */
const val TYPE_WIFI = 0
const val TYPE_CELLULAR_LTE = 1
const val TYPE_CELLULAR_5G = 2
const val TYPE_CELLULAR_3G = 3
const val TYPE_UNKNOWN = 4
const val TYPE_NONE = 5
}
}

View File

@@ -5,6 +5,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wzp.audio.AudioPipeline
import com.wzp.audio.AudioRoute
import com.wzp.audio.AudioRouteManager
import com.wzp.data.SettingsRepository
import com.wzp.debug.DebugReporter
@@ -12,6 +13,7 @@ import com.wzp.engine.CallStats
import com.wzp.service.CallService
import com.wzp.engine.WzpCallback
import com.wzp.engine.WzpEngine
import com.wzp.net.NetworkMonitor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -43,6 +45,7 @@ class CallViewModel : ViewModel(), WzpCallback {
private var engineInitialized = false
private var audioPipeline: AudioPipeline? = null
private var audioRouteManager: AudioRouteManager? = null
private var networkMonitor: NetworkMonitor? = null
private var audioStarted = false
private var appContext: Context? = null
private var settings: SettingsRepository? = null
@@ -60,6 +63,9 @@ class CallViewModel : ViewModel(), WzpCallback {
private val _isSpeaker = MutableStateFlow(false)
val isSpeaker: StateFlow<Boolean> = _isSpeaker.asStateFlow()
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
private val _stats = MutableStateFlow(CallStats())
val stats: StateFlow<CallStats> = _stats.asStateFlow()
@@ -226,7 +232,19 @@ class CallViewModel : ViewModel(), WzpCallback {
audioPipeline = AudioPipeline(appCtx)
}
if (audioRouteManager == null) {
audioRouteManager = AudioRouteManager(appCtx)
audioRouteManager = AudioRouteManager(appCtx).also { arm ->
arm.onRouteChanged = { route ->
_audioRoute.value = route
_isSpeaker.value = (route == AudioRoute.SPEAKER)
}
}
}
if (networkMonitor == null) {
networkMonitor = NetworkMonitor(appCtx).also { nm ->
nm.onNetworkChanged = { type, bw ->
engine?.onNetworkChanged(type, bw)
}
}
}
if (debugReporter == null) {
debugReporter = DebugReporter(appCtx)
@@ -607,6 +625,27 @@ class CallViewModel : ViewModel(), WzpCallback {
audioRouteManager?.setSpeaker(newSpeaker)
}
/** Cycle audio output: Earpiece → Speaker → Bluetooth (if available) → Earpiece. */
fun cycleAudioRoute() {
val routes = audioRouteManager?.availableRoutes() ?: return
val currentIdx = routes.indexOf(_audioRoute.value)
val next = routes[(currentIdx + 1) % routes.size]
when (next) {
AudioRoute.EARPIECE -> {
audioRouteManager?.setBluetoothSco(false)
audioRouteManager?.setSpeaker(false)
}
AudioRoute.SPEAKER -> {
audioRouteManager?.setSpeaker(true)
}
AudioRoute.BLUETOOTH -> {
audioRouteManager?.setBluetoothSco(true)
}
}
_audioRoute.value = next
_isSpeaker.value = (next == AudioRoute.SPEAKER)
}
fun clearError() { _errorMessage.value = null }
fun sendDebugReport() {
@@ -661,6 +700,7 @@ class CallViewModel : ViewModel(), WzpCallback {
it.start(e)
}
audioRouteManager?.register()
networkMonitor?.register()
audioStarted = true
}
@@ -668,8 +708,10 @@ class CallViewModel : ViewModel(), WzpCallback {
if (!audioStarted) return
audioPipeline?.stop() // sets running=false; DON'T null — teardown needs awaitDrain()
audioRouteManager?.unregister()
networkMonitor?.unregister()
audioRouteManager?.setSpeaker(false)
_isSpeaker.value = false
_audioRoute.value = AudioRoute.EARPIECE
audioStarted = false
}

View File

@@ -49,6 +49,7 @@ 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.audio.AudioRoute
import com.wzp.engine.CallStats
import com.wzp.ui.components.CopyableFingerprint
import com.wzp.ui.components.Identicon
@@ -74,6 +75,7 @@ fun InCallScreen(
val callState by viewModel.callState.collectAsState()
val isMuted by viewModel.isMuted.collectAsState()
val isSpeaker by viewModel.isSpeaker.collectAsState()
val audioRoute by viewModel.audioRoute.collectAsState()
val stats by viewModel.stats.collectAsState()
val qualityTier by viewModel.qualityTier.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
@@ -621,12 +623,12 @@ fun InCallScreen(
Spacer(modifier = Modifier.height(16.dp))
// Controls: Mic / End / Spk
// Controls: Mic / End / Route (Ear/Spk/BT)
ControlRow(
isMuted = isMuted,
isSpeaker = isSpeaker,
audioRoute = audioRoute,
onToggleMute = viewModel::toggleMute,
onToggleSpeaker = viewModel::toggleSpeaker,
onCycleRoute = viewModel::cycleAudioRoute,
onHangUp = { viewModel.stopCall() }
)
@@ -915,9 +917,9 @@ private fun AudioLevelBar(audioLevel: Int) {
@Composable
private fun ControlRow(
isMuted: Boolean,
isSpeaker: Boolean,
audioRoute: AudioRoute,
onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit,
onCycleRoute: () -> Unit,
onHangUp: () -> Unit
) {
Row(
@@ -959,22 +961,28 @@ private fun ControlRow(
Text("End", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
}
// Speaker
// Audio route: cycles Earpiece → Speaker → Bluetooth (when available)
FilledTonalIconButton(
onClick = onToggleSpeaker,
onClick = onCycleRoute,
modifier = Modifier.size(56.dp),
colors = if (isSpeaker) {
IconButtonDefaults.filledTonalIconButtonColors(
colors = when (audioRoute) {
AudioRoute.SPEAKER -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0xFF0F3460), contentColor = Color.White
)
} else {
IconButtonDefaults.filledTonalIconButtonColors(
AudioRoute.BLUETOOTH -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = Color(0xFF2563EB), contentColor = Color.White
)
else -> IconButtonDefaults.filledTonalIconButtonColors(
containerColor = DarkSurface2, contentColor = Color.White
)
}
) {
Text(
text = if (isSpeaker) "Spk\nOn" else "Spk",
text = when (audioRoute) {
AudioRoute.EARPIECE -> "Ear"
AudioRoute.SPEAKER -> "Spk"
AudioRoute.BLUETOOTH -> "BT"
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
lineHeight = 12.sp

View File

@@ -99,6 +99,9 @@ pub(crate) struct EngineState {
/// QUIC transport handle — stored so stop_call() can close it immediately,
/// triggering relay-side leave + RoomUpdate broadcast.
pub quic_transport: Mutex<Option<Arc<wzp_transport::QuinnTransport>>>,
/// Network type from Android ConnectivityManager, polled by recv task.
/// 0xFF = no change pending; 0-5 = NetworkContext ordinal.
pub pending_network_type: AtomicU8,
}
pub struct WzpEngine {
@@ -120,6 +123,7 @@ impl WzpEngine {
playout_ring: AudioRing::new(),
audio_level_rms: AtomicU32::new(0),
quic_transport: Mutex::new(None),
pending_network_type: AtomicU8::new(PROFILE_NO_CHANGE),
});
Self {
state,
@@ -351,7 +355,7 @@ impl WzpEngine {
// Store call setup info for Kotlin to pick up
stats.incoming_call_id = Some(format!("{relay_addr}|{room}"));
}
Ok(Some(SignalMessage::Hangup { reason })) => {
Ok(Some(SignalMessage::Hangup { reason, .. })) => {
info!(reason = ?reason, "signal: call ended by remote");
let mut stats = signal_state.stats.lock().unwrap();
stats.state = crate::stats::CallState::Closed;
@@ -404,6 +408,13 @@ impl WzpEngine {
pub fn force_profile(&self, _profile: QualityProfile) {}
/// Signal a network transport change from Android ConnectivityManager.
/// Stores the type atomically; the recv task polls it on each packet.
pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) {
info!(network_type, bandwidth_kbps, "on_network_changed");
self.state.pending_network_type.store(network_type, Ordering::Release);
}
pub fn get_stats(&self) -> CallStats {
let mut stats = self.state.stats.lock().unwrap().clone();
if let Some(start) = self.call_start {
@@ -871,6 +882,23 @@ async fn run_call(
);
}
// Check for network transport change from ConnectivityManager
{
let net = state.pending_network_type.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if net != PROFILE_NO_CHANGE {
use wzp_proto::NetworkContext;
let ctx = match net {
0 => NetworkContext::WiFi,
1 => NetworkContext::CellularLte,
2 => NetworkContext::Cellular5g,
3 => NetworkContext::Cellular3g,
_ => NetworkContext::Unknown,
};
quality_ctrl.signal_network_change(ctx);
info!(?ctx, "quality controller: network context updated");
}
}
// Adaptive quality: ingest quality reports from relay
if auto_profile {
if let Some(ref qr) = pkt.quality_report {

View File

@@ -222,6 +222,29 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeForceProfile(
}));
}
/// Signal a network transport change from the Android ConnectivityManager.
///
/// `network_type` matches the Rust `NetworkContext` enum:
/// 0=WiFi, 1=CellularLte, 2=Cellular5g, 3=Cellular3g, 4=Unknown, 5=None
///
/// The engine forwards this to the `AdaptiveQualityController` which:
/// - Preemptively downgrades one tier on WiFi→cellular
/// - Activates a 10-second FEC boost
/// - Uses faster downgrade thresholds on cellular
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged(
_env: JNIEnv,
_class: JClass,
handle: jlong,
network_type: jint,
bandwidth_kbps: jint,
) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) };
h.engine.on_network_changed(network_type as u8, bandwidth_kbps as u32);
}));
}
/// Write captured PCM samples from Kotlin AudioRecord into the engine's capture ring.
/// pcm is a Java short[] array.
#[unsafe(no_mangle)]

View File

@@ -445,6 +445,15 @@ impl CallEncoder {
self.aec.feed_farend(farend);
}
/// Apply DRED tuning output to the encoder.
///
/// Called by the send loop after `DredTuner::update()` returns `Some`.
/// No-op when the active codec is Codec2 (DRED is Opus-only).
pub fn apply_dred_tuning(&mut self, tuning: wzp_proto::DredTuning) {
self.audio_enc.set_dred_duration(tuning.dred_frames);
self.audio_enc.set_expected_loss(tuning.expected_loss_pct);
}
/// Enable or disable acoustic echo cancellation.
pub fn set_aec_enabled(&mut self, enabled: bool) {
self.aec.set_enabled(enabled);
@@ -1442,4 +1451,131 @@ mod tests {
"frames_suppressed should be > 0"
);
}
// ---- DredTuner integration tests ----
/// End-to-end test: DredTuner reacts to simulated network degradation
/// and adjusts the encoder's DRED parameters via `apply_dred_tuning`.
#[test]
fn dred_tuner_adjusts_encoder_on_loss() {
use wzp_proto::DredTuner;
let mut enc = CallEncoder::new(&CallConfig {
profile: QualityProfile::GOOD,
suppression_enabled: false,
..Default::default()
});
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
// Baseline: good network → baseline DRED (20 frames = 200 ms).
let baseline = tuner.current();
assert_eq!(baseline.dred_frames, 20);
// Warm up the tuner — first few updates may return Some as the
// EWMA initializes and expected_loss settles from the initial 15%.
for _ in 0..10 {
tuner.update(0.0, 50, 5);
}
// After settling, the tuning should be at baseline.
assert_eq!(tuner.current().dred_frames, 20);
// Simulate network degradation: 30% loss, 300ms RTT.
// The tuner should increase DRED frames above baseline.
let tuning = tuner.update(30.0, 300, 15);
assert!(tuning.is_some(), "loss spike should trigger tuning change");
let t = tuning.unwrap();
assert!(
t.dred_frames > 20,
"30% loss should increase DRED above baseline 20, got {}",
t.dred_frames
);
// Apply to encoder — should not panic.
enc.apply_dred_tuning(t);
// Verify the encoder still works after tuning.
let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty(), "encoder must still produce packets after DRED tuning");
}
/// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling.
#[test]
fn dred_tuner_spike_boosts_to_ceiling() {
use wzp_proto::DredTuner;
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish low-jitter baseline.
for _ in 0..20 {
tuner.update(0.0, 50, 5);
}
assert!(!tuner.spike_boost_active());
// Jitter spikes to 40ms (8x baseline of ~5ms).
let tuning = tuner.update(0.0, 50, 40);
assert!(tuner.spike_boost_active(), "jitter spike should activate boost");
assert!(tuning.is_some());
// Ceiling for Opus24k is 50 frames = 500 ms.
assert_eq!(
tuning.unwrap().dred_frames, 50,
"spike should push to ceiling"
);
}
/// DredTuner is a no-op for Codec2 profiles.
#[test]
fn dred_tuner_noop_for_codec2() {
use wzp_proto::DredTuner;
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
// Even extreme conditions produce no tuning output.
assert!(tuner.update(50.0, 800, 100).is_none());
assert_eq!(tuner.current().dred_frames, 0);
}
/// DredTuner + CallEncoder: full cycle through profile switch.
#[test]
fn dred_tuner_handles_profile_switch() {
use wzp_proto::DredTuner;
let mut enc = CallEncoder::new(&CallConfig {
profile: QualityProfile::GOOD,
suppression_enabled: false,
..Default::default()
});
let mut tuner = DredTuner::new(QualityProfile::GOOD.codec);
// Apply initial tuning on good network.
if let Some(t) = tuner.update(0.0, 50, 5) {
enc.apply_dred_tuning(t);
}
// Switch to degraded profile.
enc.set_profile(QualityProfile::DEGRADED).unwrap();
tuner.set_codec(QualityProfile::DEGRADED.codec);
// Opus6k baseline is 50 frames (500 ms), ceiling is 104 (1040 ms).
let baseline = tuner.current();
// After set_codec, the cached tuning should reflect old state;
// a fresh update gives the new codec's mapping.
let tuning = tuner.update(20.0, 200, 10);
assert!(tuning.is_some());
let t = tuning.unwrap();
assert!(
t.dred_frames >= 50,
"Opus6k with 20% loss should be at least baseline 50, got {}",
t.dred_frames
);
enc.apply_dred_tuning(t);
// Encode a 40ms frame (Opus6k uses 40ms frames = 1920 samples).
let pcm: Vec<i16> = (0..1920)
.map(|i| ((i as f32 * 0.1).sin() * 10_000.0) as i16)
.collect();
let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty());
}
}

View File

@@ -424,6 +424,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
info!(total_source, total_repair, total_bytes, "done — closing");
let hangup = wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
};
transport.send_signal(&hangup).await.ok();
transport.close().await?;
@@ -575,6 +576,7 @@ async fn run_file_mode(
// Send Hangup signal so the relay knows we're done
let hangup = wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
};
transport.send_signal(&hangup).await.ok();
@@ -747,7 +749,7 @@ async fn run_signal_mode(
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
info!(fingerprint = %fp, "registered on relay — waiting for calls");
}
Some(SignalMessage::RegisterPresenceAck { success: false, error }) => {
Some(SignalMessage::RegisterPresenceAck { success: false, error, .. }) => {
anyhow::bail!("registration failed: {}", error.unwrap_or_default());
}
other => {
@@ -865,6 +867,7 @@ async fn run_signal_mode(
info!("hanging up...");
let _ = signal_transport.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
}).await;
break;
}
@@ -881,7 +884,7 @@ async fn run_signal_mode(
Err(e) => error!("media connect failed: {e}"),
}
}
SignalMessage::Hangup { reason } => {
SignalMessage::Hangup { reason, .. } => {
info!(reason = ?reason, "call ended by remote");
}
SignalMessage::Pong { .. } => {}

View File

@@ -192,43 +192,38 @@ pub async fn race(
}
};
let ep_for_fut = ep.clone();
let v6_ep_for_accept = ipv6_endpoint.clone();
// Phase 7: IPv6 accept temporarily disabled (same reason
// as dial — IPv6 connections die on datagram send).
// Accept on IPv4 shared endpoint only.
let _v6_ep_unused = ipv6_endpoint.clone();
direct_fut = Box::pin(async move {
// Accept loop: retry if we get a stale/closed
// connection from a previous call. Between rapid
// successive calls, quinn's accept queue may
// contain connections that the peer has already
// dropped. Verify the connection is alive via
// max_datagram_size() before returning it.
// connection from a previous call. Max 3 retries
// to avoid spinning until the race timeout.
const MAX_STALE: usize = 3;
let mut stale_count: usize = 0;
loop {
let conn = match &v6_ep_for_accept {
Some(v6_ep) => {
tokio::select! {
v4 = wzp_transport::accept(&ep_for_fut) => {
v4.map_err(|e| anyhow::anyhow!("v4 accept: {e}"))?
}
v6 = wzp_transport::accept(v6_ep) => {
v6.map_err(|e| anyhow::anyhow!("v6 accept: {e}"))?
}
}
}
None => {
wzp_transport::accept(&ep_for_fut)
let conn = wzp_transport::accept(&ep_for_fut)
.await
.map_err(|e| anyhow::anyhow!("direct accept: {e}"))?
}
};
.map_err(|e| anyhow::anyhow!("direct accept: {e}"))?;
// Validate the connection is alive. A stale
// connection from a previous call will report
// close_reason = Some(...) immediately.
if let Some(reason) = conn.close_reason() {
// Explicitly close so the peer gets a
// close frame instead of idle timeout.
conn.close(0u32.into(), b"stale");
stale_count += 1;
tracing::warn!(
remote = %conn.remote_address(),
stable_id = conn.stable_id(),
stale_count,
?reason,
"dual_path: A-role skipping stale connection, re-accepting"
"dual_path: A-role skipping stale connection"
);
if stale_count >= MAX_STALE {
return Err(anyhow::anyhow!(
"A-role: {stale_count} stale connections, aborting"
));
}
continue;
}
@@ -274,7 +269,7 @@ pub async fn race(
}
};
let ep_for_fut = ep.clone();
let v6_ep_for_dial = ipv6_endpoint.clone();
let _v6_ep_for_dial = ipv6_endpoint.clone();
let dial_order = peer_candidates.dial_order();
let sni = call_sni.clone();
direct_fut = Box::pin(async move {
@@ -297,21 +292,22 @@ pub async fn race(
// Phase 7: route each candidate to the
// endpoint matching its address family.
let candidate = *candidate;
let ep = if candidate.is_ipv6() {
match &v6_ep_for_dial {
Some(v6) => v6.clone(),
None => {
// Phase 7: IPv6 dials temporarily disabled.
// IPv6 QUIC handshakes succeed but the
// connection dies immediately on datagram
// send ("connection lost"). Root cause is
// likely router-level IPv6 UDP filtering.
// Re-enable once IPv6 datagram delivery is
// verified on target networks.
if candidate.is_ipv6() {
tracing::debug!(
%candidate,
candidate_idx = idx,
"dual_path: skipping IPv6 candidate, no v6 endpoint"
"dual_path: skipping IPv6 candidate (disabled)"
);
continue;
}
}
} else {
ep_for_fut.clone()
};
let ep = ep_for_fut.clone();
let client_cfg = wzp_transport::client_config();
let sni = sni.clone();
set.spawn(async move {

View File

@@ -131,6 +131,7 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
// bridge. Catch-all mapping for completeness.
SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer,
SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane
SignalMessage::QualityDirective { .. } => CallSignalType::Offer, // relay-initiated
}
}
@@ -170,6 +171,7 @@ mod tests {
let hangup = SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
};
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));

View File

@@ -116,6 +116,14 @@ impl AudioEncoder for AdaptiveEncoder {
fn set_dtx(&mut self, enabled: bool) {
self.opus.set_dtx(enabled);
}
fn set_expected_loss(&mut self, loss_pct: u8) {
self.opus.set_expected_loss(loss_pct);
}
fn set_dred_duration(&mut self, frames: u8) {
self.opus.set_dred_duration(frames);
}
}
// ─── AdaptiveDecoder ─────────────────────────────────────────────────────────

View File

@@ -14,8 +14,9 @@
//! networks; short window keeps decoder CPU modest.
//! - Normal tiers (Opus 16k/24k): 200 ms — balanced baseline covering common
//! VoIP loss patterns (20150 ms bursts from wifi roam, transient congestion).
//! - Degraded tier (Opus 6k): 500 ms — users on 6k are by definition on a
//! bad link; longer DRED buys maximum burst resilience where it matters.
//! - Degraded tier (Opus 6k): 1040 ms — users on 6k are by definition on a
//! bad link; the maximum libopus DRED window buys the best burst resilience
//! where it matters. The RDO-VAE naturally degrades quality at longer offsets.
//!
//! # Why the 15% packet loss floor
//!
@@ -78,8 +79,12 @@ pub fn dred_duration_for(codec: CodecId) -> u8 {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10,
// Normal tiers — balanced baseline.
CodecId::Opus16k | CodecId::Opus24k => 20,
// Degraded tier — maximum burst resilience.
CodecId::Opus6k => 50,
// Degraded tier — maximum burst resilience. 104 × 10 ms = 1040 ms,
// the highest value libopus 1.5 supports. Users on 6k are on a bad
// link by definition; the RDO-VAE naturally degrades quality at longer
// offsets, so the extra window costs only ~1-2 kbps additional overhead
// while buying substantially better burst resilience (up from 500 ms).
CodecId::Opus6k => 104,
// Non-Opus (Codec2 / CN): DRED is N/A.
CodecId::Codec2_1200 | CodecId::Codec2_3200 | CodecId::ComfortNoise => 0,
}
@@ -334,6 +339,14 @@ impl AudioEncoder for OpusEncoder {
fn set_dtx(&mut self, enabled: bool) {
let _ = self.inner.set_dtx(enabled);
}
fn set_expected_loss(&mut self, loss_pct: u8) {
OpusEncoder::set_expected_loss(self, loss_pct);
}
fn set_dred_duration(&mut self, frames: u8) {
OpusEncoder::set_dred_duration(self, frames);
}
}
#[cfg(test)]
@@ -389,8 +402,8 @@ mod tests {
}
#[test]
fn dred_duration_for_degraded_tier_is_500ms() {
assert_eq!(dred_duration_for(CodecId::Opus6k), 50);
fn dred_duration_for_degraded_tier_is_1040ms() {
assert_eq!(dred_duration_for(CodecId::Opus6k), 104);
}
#[test]

View File

@@ -199,6 +199,7 @@ fn wzp_answer_round_trips_through_fc_callsignal() {
fn wzp_hangup_round_trips_through_fc_callsignal() {
let hangup = wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
};
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
@@ -302,6 +303,7 @@ fn all_signal_types_map_correctly() {
(
wzp_proto::SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
},
"Hangup",
),

View File

@@ -8,6 +8,8 @@
#include <android/log.h>
#include <cstring>
#include <atomic>
#include <chrono>
#include <thread>
#define LOG_TAG "wzp-oboe"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
@@ -254,14 +256,28 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
oboe::AudioStreamBuilder captureBuilder;
captureBuilder.setDirection(oboe::Direction::Input)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setSharingMode(oboe::SharingMode::Shared)
->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setInputPreset(oboe::InputPreset::VoiceCommunication)
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
->setDataCallback(&g_capture_cb);
if (config->bt_active) {
// BT SCO mode: do NOT set sample rate or input preset.
// Requesting 48kHz against a BT SCO device fails with
// "getInputProfile could not find profile". Letting the system
// choose the native rate (8/16kHz) and relying on Oboe's
// resampler (SampleRateConversionQuality::Best) to bridge
// to our 48kHz ring buffer is the only path that works.
// InputPreset::VoiceCommunication can also prevent BT SCO
// routing on some devices — skip it for BT.
LOGI("capture: BT mode — no sample rate or input preset set");
} else {
captureBuilder.setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setInputPreset(oboe::InputPreset::VoiceCommunication);
}
oboe::Result result = captureBuilder.openStream(g_capture_stream);
if (result != oboe::Result::OK) {
LOGE("Failed to open capture stream: %s", oboe::convertToText(result));
@@ -314,14 +330,23 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
oboe::AudioStreamBuilder playoutBuilder;
playoutBuilder.setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setSharingMode(oboe::SharingMode::Shared)
->setFormat(oboe::AudioFormat::I16)
->setChannelCount(config->channel_count)
->setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setUsage(oboe::Usage::VoiceCommunication)
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Best)
->setDataCallback(&g_playout_cb);
if (config->bt_active) {
LOGI("playout: BT mode — no sample rate set, using Usage::Media");
// Usage::Media instead of VoiceCommunication for BT output
// to avoid conflicts with the communication device routing.
playoutBuilder.setUsage(oboe::Usage::Media);
} else {
playoutBuilder.setSampleRate(config->sample_rate)
->setFramesPerDataCallback(config->frames_per_burst)
->setUsage(oboe::Usage::VoiceCommunication);
}
result = playoutBuilder.openStream(g_playout_stream);
if (result != oboe::Result::OK) {
LOGE("Failed to open playout stream: %s", oboe::convertToText(result));
@@ -365,6 +390,38 @@ int wzp_oboe_start(const WzpOboeConfig* config, const WzpOboeRings* rings) {
return -5;
}
// Log initial stream states right after requestStart() returns.
// On well-behaved HALs both will already be Started; on others
// (Nothing A059) they may still be in Starting state.
LOGI("requestStart returned: capture_state=%d playout_state=%d",
(int)g_capture_stream->getState(),
(int)g_playout_stream->getState());
// Poll until both streams report Started state, up to 2s timeout.
// Some Android HALs (Nothing A059) delay transitioning from Starting
// to Started; proceeding before the transition completes causes the
// first capture/playout callbacks to be dropped silently.
{
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2000);
int poll_count = 0;
while (std::chrono::steady_clock::now() < deadline) {
auto cap_state = g_capture_stream->getState();
auto play_state = g_playout_stream->getState();
if (cap_state == oboe::StreamState::Started &&
play_state == oboe::StreamState::Started) {
LOGI("both streams Started after %d polls", poll_count);
break;
}
poll_count++;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
// Log final state even on timeout (helps diagnose HAL quirks)
LOGI("stream states after poll: capture=%d playout=%d (polls=%d)",
(int)g_capture_stream->getState(),
(int)g_playout_stream->getState(),
poll_count);
}
LOGI("Oboe started: sr=%d burst=%d ch=%d",
config->sample_rate, config->frames_per_burst, config->channel_count);
return 0;

View File

@@ -16,6 +16,7 @@ typedef struct {
int32_t sample_rate;
int32_t frames_per_burst;
int32_t channel_count;
int32_t bt_active; /* nonzero = BT SCO mode: skip sample rate + input preset */
} WzpOboeConfig;
typedef struct {

View File

@@ -47,6 +47,10 @@ struct WzpOboeConfig {
sample_rate: i32,
frames_per_burst: i32,
channel_count: i32,
/// When nonzero, capture stream skips setSampleRate and setInputPreset
/// so the system can route to BT SCO at its native rate (8/16kHz).
/// Oboe's SampleRateConversionQuality::Best resamples to 48kHz.
bt_active: i32,
}
#[repr(C)]
@@ -204,6 +208,17 @@ fn backend() -> &'static AudioBackend {
/// Idempotent — calling while already running is a no-op that returns 0.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_start() -> i32 {
audio_start_inner(false)
}
/// Start Oboe in Bluetooth SCO mode — skips sample rate and input preset
/// on capture so the system can route to the BT SCO device natively.
#[unsafe(no_mangle)]
pub extern "C" fn wzp_native_audio_start_bt() -> i32 {
audio_start_inner(true)
}
fn audio_start_inner(bt: bool) -> i32 {
let b = backend();
let mut started = match b.started.lock() {
Ok(g) => g,
@@ -217,6 +232,7 @@ pub extern "C" fn wzp_native_audio_start() -> i32 {
sample_rate: 48_000,
frames_per_burst: FRAME_SAMPLES as i32,
channel_count: 1,
bt_active: if bt { 1 } else { 0 },
};
let rings = WzpOboeRings {
capture_buf: b.capture.buf_ptr(),
@@ -307,11 +323,12 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
b.playout.read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture.write_idx.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture.read_idx.store(0, std::sync::atomic::Ordering::Relaxed);
// Re-start
// Re-start (stall detector — always non-BT mode)
let config = WzpOboeConfig {
sample_rate: 48_000,
frames_per_burst: FRAME_SAMPLES as i32,
channel_count: 1,
bt_active: 0,
};
let rings = WzpOboeRings {
capture_buf: b.capture.buf_ptr(),

View File

@@ -0,0 +1,312 @@
//! Continuous DRED tuning from real-time network metrics.
//!
//! Instead of locking DRED duration to 3 discrete quality tiers (100/200/500 ms),
//! `DredTuner` maps live path quality metrics to a continuous DRED duration and
//! expected-loss hint, updated every N packets. This makes DRED reactive within
//! ~200 ms instead of waiting for 3+ consecutive bad quality reports to trigger
//! a full tier transition.
//!
//! The tuner also implements pre-emptive jitter-spike detection ("sawtooth"
//! prediction): when jitter variance spikes >30% over a 200 ms window — typical
//! of Starlink satellite handovers — it temporarily boosts DRED to the maximum
//! allowed for the current codec before packets actually start dropping.
use crate::CodecId;
/// Output of a single tuning cycle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DredTuning {
/// DRED duration in 10 ms frame units (0104). Passed directly to
/// `OpusEncoder::set_dred_duration()`.
pub dred_frames: u8,
/// Expected packet loss percentage (0100). Passed to
/// `OpusEncoder::set_expected_loss()`. Floored at 15% by the encoder
/// itself, but we pass the real value so the encoder can override upward.
pub expected_loss_pct: u8,
}
/// Minimum DRED frames for any Opus codec (matches DRED_LOSS_FLOOR_PCT logic:
/// at 15% loss, libopus 1.5 emits ~95 ms of DRED, which needs at least 10
/// frames configured to be useful).
const MIN_DRED_FRAMES: u8 = 5;
/// Maximum DRED frames libopus supports (104 × 10 ms = 1040 ms).
const MAX_DRED_FRAMES: u8 = 104;
/// Jitter variance spike ratio that triggers pre-emptive DRED boost.
const JITTER_SPIKE_RATIO: f32 = 1.3;
/// How many tuning cycles a jitter-spike boost persists (at 25 packets/cycle
/// and 20 ms/packet, 10 cycles ≈ 5 seconds).
const SPIKE_BOOST_COOLDOWN_CYCLES: u32 = 10;
/// Maps codec tier to its baseline DRED frames (used when network is healthy).
fn baseline_dred_frames(codec: CodecId) -> u8 {
match codec {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms
CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms
CodecId::Opus6k => 50, // 500 ms
_ => 0,
}
}
/// Maps codec tier to its maximum allowed DRED frames under spike/bad conditions.
fn max_dred_frames_for(codec: CodecId) -> u8 {
match codec {
// Studio: cap at 300 ms (don't waste bitrate on good links)
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 30,
// Normal: cap at 500 ms
CodecId::Opus16k | CodecId::Opus24k => 50,
// Degraded: allow full 1040 ms
CodecId::Opus6k => MAX_DRED_FRAMES,
_ => 0,
}
}
/// Continuous DRED tuner driven by network path metrics.
pub struct DredTuner {
/// Current codec (determines baseline and ceiling).
codec: CodecId,
/// Last computed tuning output.
last_tuning: DredTuning,
/// EWMA-smoothed jitter for spike detection (in ms).
jitter_ewma: f32,
/// Remaining cooldown cycles for a jitter-spike boost.
spike_cooldown: u32,
/// Whether the tuner has received at least one observation.
initialized: bool,
}
impl DredTuner {
/// Create a new tuner for the given codec.
pub fn new(codec: CodecId) -> Self {
let baseline = baseline_dred_frames(codec);
Self {
codec,
last_tuning: DredTuning {
dred_frames: baseline,
expected_loss_pct: 15, // match DRED_LOSS_FLOOR_PCT
},
jitter_ewma: 0.0,
spike_cooldown: 0,
initialized: false,
}
}
/// Update the active codec (e.g. on tier transition). Resets spike state.
pub fn set_codec(&mut self, codec: CodecId) {
self.codec = codec;
self.spike_cooldown = 0;
}
/// Feed network metrics and compute new DRED parameters.
///
/// Call this every tuning cycle (e.g. every 25 packets ≈ 500 ms at 20 ms
/// frame duration).
///
/// - `loss_pct`: observed packet loss (0.0100.0)
/// - `rtt_ms`: smoothed round-trip time
/// - `jitter_ms`: current jitter estimate (RTT variance)
///
/// Returns `Some(tuning)` if the output changed, `None` if unchanged.
pub fn update(&mut self, loss_pct: f32, rtt_ms: u32, jitter_ms: u32) -> Option<DredTuning> {
if !self.codec.is_opus() {
return None;
}
let baseline = baseline_dred_frames(self.codec);
let ceiling = max_dred_frames_for(self.codec);
// --- Jitter spike detection ---
let jitter_f = jitter_ms as f32;
if !self.initialized {
self.jitter_ewma = jitter_f;
self.initialized = true;
} else {
// Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA
let alpha = if jitter_f > self.jitter_ewma { 0.3 } else { 0.05 };
self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma;
}
// Detect spike: instantaneous jitter > EWMA × 1.3
if self.jitter_ewma > 1.0 && jitter_f > self.jitter_ewma * JITTER_SPIKE_RATIO {
self.spike_cooldown = SPIKE_BOOST_COOLDOWN_CYCLES;
}
// Decrement cooldown
if self.spike_cooldown > 0 {
self.spike_cooldown -= 1;
}
// --- Compute DRED frames ---
let dred_frames = if self.spike_cooldown > 0 {
// During spike boost: jump to ceiling
ceiling
} else {
// Continuous mapping: scale linearly between baseline and ceiling
// based on loss percentage.
// 0% loss → baseline
// 40% loss → ceiling
let loss_clamped = loss_pct.clamp(0.0, 40.0);
let t = loss_clamped / 40.0;
let raw = baseline as f32 + t * (ceiling - baseline) as f32;
(raw as u8).clamp(MIN_DRED_FRAMES, ceiling)
};
// --- Compute expected loss hint ---
// Pass the real loss so the encoder can clamp at its own floor (15%).
// For RTT-driven boost: high RTT suggests impending loss, so add a
// phantom loss contribution to keep DRED emitting generously.
let rtt_loss_phantom = if rtt_ms > 200 {
((rtt_ms - 200) as f32 / 40.0).min(15.0)
} else {
0.0
};
let expected_loss = (loss_pct + rtt_loss_phantom).clamp(0.0, 100.0) as u8;
let tuning = DredTuning {
dred_frames,
expected_loss_pct: expected_loss,
};
if tuning != self.last_tuning {
self.last_tuning = tuning;
Some(tuning)
} else {
None
}
}
/// Get the last computed tuning without updating.
pub fn current(&self) -> DredTuning {
self.last_tuning
}
/// Whether a jitter-spike boost is currently active.
pub fn spike_boost_active(&self) -> bool {
self.spike_cooldown > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_for_opus24k() {
let tuner = DredTuner::new(CodecId::Opus24k);
assert_eq!(tuner.current().dred_frames, 20); // 200 ms
}
#[test]
fn baseline_for_opus6k() {
let tuner = DredTuner::new(CodecId::Opus6k);
assert_eq!(tuner.current().dred_frames, 50); // 500 ms
}
#[test]
fn codec2_returns_none() {
let mut tuner = DredTuner::new(CodecId::Codec2_1200);
assert!(tuner.update(10.0, 100, 20).is_none());
}
#[test]
fn scales_with_loss() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// 0% loss → baseline (20 frames)
tuner.update(0.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 20);
// 20% loss → midpoint between 20 and 50 = 35
tuner.update(20.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 35);
// 40%+ loss → ceiling (50 frames)
tuner.update(40.0, 50, 5);
assert_eq!(tuner.current().dred_frames, 50);
}
#[test]
fn jitter_spike_triggers_boost() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish baseline jitter
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
assert!(!tuner.spike_boost_active());
// Spike: jitter jumps to 50 ms (5x the EWMA of ~10)
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Should be at ceiling (50 frames = 500 ms for Opus24k)
assert_eq!(tuner.current().dred_frames, 50);
}
#[test]
fn spike_cooldown_decays() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Establish baseline then spike
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Run through cooldown
for _ in 0..SPIKE_BOOST_COOLDOWN_CYCLES {
tuner.update(0.0, 50, 10);
}
assert!(!tuner.spike_boost_active());
// Should return to baseline
assert_eq!(tuner.current().dred_frames, 20);
}
#[test]
fn rtt_phantom_loss() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// High RTT (400ms) with 0% real loss
tuner.update(0.0, 400, 10);
// Phantom loss = (400-200)/40 = 5
assert_eq!(tuner.current().expected_loss_pct, 5);
}
#[test]
fn set_codec_resets_spike() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// Trigger spike
for _ in 0..20 {
tuner.update(0.0, 50, 10);
}
tuner.update(0.0, 50, 50);
assert!(tuner.spike_boost_active());
// Switch codec — spike should reset
tuner.set_codec(CodecId::Opus6k);
assert!(!tuner.spike_boost_active());
}
#[test]
fn opus6k_reaches_max_1040ms() {
let mut tuner = DredTuner::new(CodecId::Opus6k);
// High loss → should reach 104 frames (1040 ms)
tuner.update(40.0, 50, 5);
assert_eq!(tuner.current().dred_frames, MAX_DRED_FRAMES);
}
#[test]
fn returns_none_when_unchanged() {
let mut tuner = DredTuner::new(CodecId::Opus24k);
// First update always returns Some (initial → computed)
let first = tuner.update(0.0, 50, 5);
// Same inputs → None
let second = tuner.update(0.0, 50, 5);
assert!(first.is_some() || second.is_none());
}
}

View File

@@ -14,6 +14,7 @@
pub mod bandwidth;
pub mod codec_id;
pub mod dred_tuner;
pub mod error;
pub mod jitter;
pub mod packet;
@@ -30,6 +31,7 @@ pub use packet::{
FRAME_TYPE_MINI,
};
pub use bandwidth::{BandwidthEstimator, CongestionState};
pub use dred_tuner::{DredTuner, DredTuning};
pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
pub use session::{Session, SessionEvent, SessionState};
pub use traits::*;

View File

@@ -608,8 +608,14 @@ pub enum SignalMessage {
Ping { timestamp_ms: u64 },
Pong { timestamp_ms: u64 },
/// End the call.
Hangup { reason: HangupReason },
/// End the call. `call_id` is optional for backwards compatibility
/// with older clients that send Hangup without it — the relay falls
/// back to ending ALL active calls for the sender in that case.
Hangup {
reason: HangupReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
call_id: Option<String>,
},
/// featherChat bearer token for relay authentication.
/// Sent as the first signal message when --auth-url is configured.
@@ -716,6 +722,9 @@ pub enum SignalMessage {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// Relay's build version (git short hash).
#[serde(default, skip_serializing_if = "Option::is_none")]
relay_build: Option<String>,
},
/// Direct call offer routed through the relay to a specific peer.
@@ -908,6 +917,14 @@ pub enum SignalMessage {
/// federation link via `send_signal_to_peer`.
origin_relay_fp: String,
},
/// Relay-initiated quality directive: all participants should switch
/// to the recommended profile to match the weakest link.
QualityDirective {
recommended_profile: crate::QualityProfile,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
/// How the callee responds to a direct call.
@@ -1091,6 +1108,7 @@ mod tests {
supported_profiles: vec![],
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
};
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(inner),
@@ -1133,9 +1151,10 @@ mod tests {
chosen_profile: None,
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
},
SignalMessage::CallRinging { call_id: "c1".into() },
SignalMessage::Hangup { reason: HangupReason::Normal },
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
];
for inner in cases {
let inner_disc = std::mem::discriminant(&inner);
@@ -1168,6 +1187,7 @@ mod tests {
supported_profiles: vec![],
caller_reflexive_addr: Some("192.0.2.1:4433".into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
};
let json = serde_json::to_string(&offer).unwrap();
assert!(
@@ -1196,6 +1216,7 @@ mod tests {
supported_profiles: vec![],
caller_reflexive_addr: None,
caller_local_addrs: Vec::new(),
caller_build_version: None,
};
let json_none = serde_json::to_string(&offer_none).unwrap();
assert!(
@@ -1213,6 +1234,7 @@ mod tests {
chosen_profile: None,
callee_reflexive_addr: Some("198.51.100.9:4433".into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
};
let decoded: SignalMessage =
serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap();
@@ -1293,7 +1315,7 @@ mod tests {
let cases = vec![
SignalMessage::Ping { timestamp_ms: 12345 },
SignalMessage::Hold,
SignalMessage::Hangup { reason: HangupReason::Normal },
SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None },
SignalMessage::CallRinging { call_id: "abcd".into() },
];
for m in cases {
@@ -1651,6 +1673,41 @@ mod tests {
}
}
#[test]
fn quality_directive_roundtrip() {
let msg = SignalMessage::QualityDirective {
recommended_profile: crate::QualityProfile::DEGRADED,
reason: Some("weakest link degraded".into()),
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::QualityDirective { recommended_profile, reason } => {
assert_eq!(recommended_profile.codec, CodecId::Opus6k);
assert_eq!(reason.as_deref(), Some("weakest link degraded"));
}
_ => panic!("wrong variant"),
}
}
#[test]
fn quality_directive_without_reason_roundtrip() {
let msg = SignalMessage::QualityDirective {
recommended_profile: crate::QualityProfile::GOOD,
reason: None,
};
let json = serde_json::to_string(&msg).unwrap();
// None reason should be omitted from JSON
assert!(!json.contains("reason"));
let decoded: SignalMessage = serde_json::from_str(&json).unwrap();
match decoded {
SignalMessage::QualityDirective { reason, .. } => {
assert!(reason.is_none());
}
_ => panic!("wrong variant"),
}
}
#[test]
fn mini_frame_disabled() {
// Simulate disabled mini-frames by always keeping frames_since_full at 0

View File

@@ -28,6 +28,13 @@ pub trait AudioEncoder: Send + Sync {
/// Enable/disable DTX (discontinuous transmission). No-op for Codec2.
fn set_dtx(&mut self, _enabled: bool) {}
/// Hint the encoder about expected packet loss (0100). In DRED mode the
/// encoder floors this at 15% internally. No-op for Codec2.
fn set_expected_loss(&mut self, _loss_pct: u8) {}
/// Set DRED duration in 10 ms frame units (0104). No-op for Codec2.
fn set_dred_duration(&mut self, _frames: u8) {}
}
/// Decodes compressed frames back to PCM audio.

View File

@@ -611,6 +611,7 @@ async fn main() -> anyhow::Result<()> {
&caller_fp,
&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
},
)
.await;
@@ -625,10 +626,13 @@ async fn main() -> anyhow::Result<()> {
// then read back everything needed to cross-
// wire peer_direct_addr + peer_local_addrs in
// the local CallSetup.
// Also set peer_relay_fp so the originating
// relay knows where to forward MediaPathReport.
let room_name = format!("call-{call_id}");
let (callee_addr_for_setup, callee_local_for_setup) = {
let mut reg = call_registry_d.lock().await;
reg.set_active(call_id, accept_mode, room_name.clone());
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
reg.set_callee_reflexive_addr(
call_id,
callee_reflexive_addr.clone(),
@@ -688,18 +692,28 @@ async fn main() -> anyhow::Result<()> {
}
// Phase 6: MediaPathReport forwarded across
// federation — deliver to the local participant
// of the matching call.
// federation — deliver to the LOCAL participant.
// The report comes from the remote side, so we
// deliver to whichever participant is local. In
// the cross-relay case, one is local and one is
// remote. Try both — send_to is a no-op if the
// target isn't connected to this relay.
SignalMessage::MediaPathReport { ref call_id, .. } => {
// Deliver to the local caller (the cross-relay
// dispatcher only handles calls where the caller
// is local and the callee is remote, or vice versa)
let caller_fp = {
let (caller_fp, callee_fp) = {
let reg = call_registry_d.lock().await;
reg.get(call_id).map(|c| c.caller_fingerprint.clone())
match reg.get(call_id) {
Some(c) => (
Some(c.caller_fingerprint.clone()),
Some(c.callee_fingerprint.clone()),
),
None => (None, None),
}
};
if let Some(fp) = caller_fp {
let hub = signal_hub_d.lock().await;
if let Some(fp) = caller_fp {
let _ = hub.send_to(&fp, &inner).await;
}
if let Some(fp) = callee_fp {
let _ = hub.send_to(&fp, &inner).await;
}
}
@@ -996,6 +1010,7 @@ async fn main() -> anyhow::Result<()> {
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck {
success: true,
error: None,
relay_build: Some(BUILD_GIT_HASH.to_string()),
}).await;
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
@@ -1057,6 +1072,7 @@ async fn main() -> anyhow::Result<()> {
info!(%addr, target = %target_fp, "call target not online (no federation route)");
let _ = transport.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
}).await;
continue;
}
@@ -1172,6 +1188,7 @@ async fn main() -> anyhow::Result<()> {
if let Some(ref fm) = federation_mgr {
let hangup = SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
};
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(hangup),
@@ -1185,6 +1202,7 @@ async fn main() -> anyhow::Result<()> {
let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
}).await;
}
} else {
@@ -1289,10 +1307,24 @@ async fn main() -> anyhow::Result<()> {
}
}
SignalMessage::Hangup { .. } => {
// Forward hangup to all active calls for this user
SignalMessage::Hangup { ref call_id, .. } => {
// If the client sent a call_id, only end
// that specific call. Otherwise (old clients)
// fall back to ending ALL active calls for
// this user — which can race with new calls.
let calls = {
let reg = call_registry.lock().await;
if let Some(cid) = call_id {
// Targeted hangup: only the named call
reg.get(cid)
.map(|c| vec![(c.call_id.clone(), if c.caller_fingerprint == client_fp {
c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
})])
.unwrap_or_default()
} else {
// Legacy: end all calls for this user
reg.calls_for_fingerprint(&client_fp)
.iter()
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp {
@@ -1301,13 +1333,14 @@ async fn main() -> anyhow::Result<()> {
c.caller_fingerprint.clone()
}))
.collect::<Vec<_>>()
}
};
for (call_id, peer_fp) in &calls {
for (cid, peer_fp) in &calls {
let hub = signal_hub.lock().await;
let _ = hub.send_to(peer_fp, &msg).await;
drop(hub);
let mut reg = call_registry.lock().await;
reg.end_call(call_id);
reg.end_call(cid);
}
}
@@ -1315,16 +1348,45 @@ async fn main() -> anyhow::Result<()> {
// call peer so both sides can negotiate
// the media path before committing.
SignalMessage::MediaPathReport { ref call_id, .. } => {
let peer_fp = {
// Look up peer AND check if this is a
// cross-relay call (same pattern as
// DirectCallAnswer).
let (peer_fp, peer_relay_fp) = {
let reg = call_registry.lock().await;
match reg.get(call_id) {
Some(c) => (
reg.peer_fingerprint(call_id, &client_fp)
.map(|s| s.to_string())
.map(|s| s.to_string()),
c.peer_relay_fp.clone(),
),
None => (None, None),
}
};
if let Some(fp) = peer_fp {
if let Some(ref origin_fp) = peer_relay_fp {
// Cross-relay: wrap and forward
if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward {
inner: Box::new(msg.clone()),
origin_relay_fp: tls_fp.clone(),
};
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await {
warn!(
%call_id,
%origin_fp,
error = %e,
"cross-relay MediaPathReport forward failed"
);
}
}
} else {
// Local call
let hub = signal_hub.lock().await;
let _ = hub.send_to(&fp, &msg).await;
}
}
}
SignalMessage::Ping { timestamp_ms } => {
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await;
@@ -1397,6 +1459,7 @@ async fn main() -> anyhow::Result<()> {
let hub = signal_hub.lock().await;
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
}).await;
drop(hub);
let mut reg = call_registry.lock().await;

View File

@@ -13,6 +13,8 @@ use tokio::sync::Mutex;
use tracing::{error, info, warn};
use wzp_proto::packet::TrunkFrame;
use wzp_proto::quality::{AdaptiveQualityController, Tier};
use wzp_proto::traits::QualityController;
use wzp_proto::MediaTransport;
use crate::metrics::RelayMetrics;
@@ -50,6 +52,45 @@ impl DebugTap {
}
}
/// Tracks network quality for a single participant in a room.
struct ParticipantQuality {
controller: AdaptiveQualityController,
current_tier: Tier,
}
impl ParticipantQuality {
fn new() -> Self {
Self {
controller: AdaptiveQualityController::new(),
current_tier: Tier::Good,
}
}
/// Feed a quality report and return the new tier if it changed.
fn observe(&mut self, report: &wzp_proto::packet::QualityReport) -> Option<Tier> {
let _ = self.controller.observe(report);
let new_tier = self.controller.tier();
if new_tier != self.current_tier {
self.current_tier = new_tier;
Some(new_tier)
} else {
None
}
}
}
/// Compute the weakest (worst) quality tier across all tracked participants.
fn weakest_tier<'a>(qualities: impl Iterator<Item = &'a ParticipantQuality>) -> Tier {
qualities
.map(|pq| pq.current_tier)
.min_by_key(|t| match t {
Tier::Good => 2,
Tier::Degraded => 1,
Tier::Catastrophic => 0,
})
.unwrap_or(Tier::Good)
}
/// Unique participant ID within a room.
pub type ParticipantId = u64;
@@ -208,6 +249,10 @@ pub struct RoomManager {
acl: Option<HashMap<String, HashSet<String>>>,
/// Channel for room lifecycle events (federation subscribes).
event_tx: tokio::sync::broadcast::Sender<RoomEvent>,
/// Per-participant quality tracking, keyed by (room_name, participant_id).
qualities: HashMap<(String, ParticipantId), ParticipantQuality>,
/// Current room-wide tier per room (to avoid repeated broadcasts).
room_tiers: HashMap<String, Tier>,
}
impl RoomManager {
@@ -217,6 +262,8 @@ impl RoomManager {
rooms: HashMap::new(),
acl: None,
event_tx,
qualities: HashMap::new(),
room_tiers: HashMap::new(),
}
}
@@ -227,6 +274,8 @@ impl RoomManager {
rooms: HashMap::new(),
acl: Some(HashMap::new()),
event_tx,
qualities: HashMap::new(),
room_tiers: HashMap::new(),
}
}
@@ -277,6 +326,7 @@ impl RoomManager {
|| self.rooms.get(room_name).map_or(true, |r| r.is_empty());
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
self.qualities.insert((room_name.to_string(), id), ParticipantQuality::new());
if was_empty {
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() });
}
@@ -323,10 +373,12 @@ impl RoomManager {
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
self.qualities.remove(&(room_name.to_string(), participant_id));
if let Some(room) = self.rooms.get_mut(room_name) {
room.remove(participant_id);
if room.is_empty() {
self.rooms.remove(room_name);
self.room_tiers.remove(room_name);
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() });
info!(room = room_name, "room closed (empty)");
return None;
@@ -363,6 +415,58 @@ impl RoomManager {
pub fn list(&self) -> Vec<(String, usize)> {
self.rooms.iter().map(|(k, v)| (k.clone(), v.len())).collect()
}
/// Feed a quality report from a participant. If the room-wide weakest
/// tier changes, returns `(QualityDirective signal, all senders)` for
/// broadcasting.
pub fn observe_quality(
&mut self,
room_name: &str,
participant_id: ParticipantId,
report: &wzp_proto::packet::QualityReport,
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
let key = (room_name.to_string(), participant_id);
let tier_changed = self.qualities
.get_mut(&key)
.and_then(|pq| pq.observe(report))
.is_some();
if !tier_changed {
return None;
}
// Compute the weakest tier across all participants in this room
let room_qualities = self.qualities.iter()
.filter(|((rn, _), _)| rn == room_name)
.map(|(_, pq)| pq);
let weakest = weakest_tier(room_qualities);
let current_room_tier = self.room_tiers.get(room_name).copied().unwrap_or(Tier::Good);
if weakest == current_room_tier {
return None;
}
// Room-wide tier changed — update and broadcast directive
self.room_tiers.insert(room_name.to_string(), weakest);
let profile = weakest.profile();
info!(
room = room_name,
old_tier = ?current_room_tier,
new_tier = ?weakest,
codec = ?profile.codec,
fec_ratio = profile.fec_ratio,
"room quality directive"
);
let directive = wzp_proto::SignalMessage::QualityDirective {
recommended_profile: profile,
reason: Some(format!("weakest link: {weakest:?}")),
};
let senders = self.rooms.get(room_name)
.map(|r| r.all_senders())
.unwrap_or_default();
Some((directive, senders))
}
}
// ---------------------------------------------------------------------------
@@ -382,18 +486,32 @@ impl TrunkedForwarder {
/// Create a new trunked forwarder.
///
/// `session_id` tags every entry pushed into the batcher so the receiver
/// can demultiplex packets by session.
/// can demultiplex packets by session. The batcher's `max_bytes` is
/// initialized from the transport's current PMTUD-discovered MTU so that
/// trunk frames fill the largest datagram the path supports (instead of
/// the conservative 1200-byte default).
pub fn new(transport: Arc<wzp_transport::QuinnTransport>, session_id: [u8; 2]) -> Self {
let mut batcher = TrunkBatcher::new();
if let Some(mtu) = transport.max_datagram_size() {
batcher.max_bytes = mtu;
}
Self {
transport,
batcher: TrunkBatcher::new(),
batcher,
session_id,
}
}
/// Push a media packet into the batcher. If the batcher is full it will
/// flush automatically and the resulting trunk frame is sent immediately.
///
/// Also refreshes `max_bytes` from the transport's PMTUD-discovered MTU
/// so the batcher fills larger datagrams as the path MTU grows.
pub async fn send(&mut self, pkt: &wzp_proto::MediaPacket) -> anyhow::Result<()> {
// Refresh batcher limit from PMTUD (cheap: reads an atomic in quinn).
if let Some(mtu) = self.transport.max_datagram_size() {
self.batcher.max_bytes = mtu;
}
let payload: Bytes = pkt.to_bytes();
if let Some(frame) = self.batcher.push(self.session_id, payload) {
self.send_frame(&frame)?;
@@ -521,11 +639,17 @@ async fn run_participant_plain(
metrics.update_session_quality(session_id, report);
}
// Get current list of other participants
// Get current list of other participants + check quality directive
let lock_start = std::time::Instant::now();
let others = {
let mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id)
let (others, quality_directive) = {
let mut mgr = room_mgr.lock().await;
let directive = if let Some(ref report) = pkt.quality_report {
mgr.observe_quality(&room_name, participant_id, report)
} else {
None
};
let o = mgr.others(&room_name, participant_id);
(o, directive)
};
let lock_ms = lock_start.elapsed().as_millis() as u64;
if lock_ms > 10 {
@@ -537,6 +661,11 @@ async fn run_participant_plain(
);
}
// Broadcast quality directive to all participants if tier changed
if let Some((directive, all_senders)) = quality_directive {
broadcast_signal(&all_senders, &directive).await;
}
// Debug tap: log packet metadata
if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) {
@@ -705,9 +834,15 @@ async fn run_participant_trunked(
}
let lock_start = std::time::Instant::now();
let others = {
let mgr = room_mgr.lock().await;
mgr.others(&room_name, participant_id)
let (others, quality_directive) = {
let mut mgr = room_mgr.lock().await;
let directive = if let Some(ref report) = pkt.quality_report {
mgr.observe_quality(&room_name, participant_id, report)
} else {
None
};
let o = mgr.others(&room_name, participant_id);
(o, directive)
};
let lock_ms = lock_start.elapsed().as_millis() as u64;
if lock_ms > 10 {
@@ -719,6 +854,11 @@ async fn run_participant_trunked(
);
}
// Broadcast quality directive to all participants if tier changed
if let Some((directive, all_senders)) = quality_directive {
broadcast_signal(&all_senders, &directive).await;
}
let fwd_start = std::time::Instant::now();
let pkt_bytes = pkt.payload.len() as u64;
for other in &others {
@@ -959,4 +1099,47 @@ mod tests {
// Batcher should now be empty — nothing to flush.
assert!(batcher.flush().is_none());
}
fn make_report(loss_pct_f: f32, rtt_ms: u16) -> wzp_proto::packet::QualityReport {
wzp_proto::packet::QualityReport {
loss_pct: (loss_pct_f / 100.0 * 255.0) as u8,
rtt_4ms: (rtt_ms / 4) as u8,
jitter_ms: 10,
bitrate_cap_kbps: 200,
}
}
#[test]
fn participant_quality_starts_good() {
let pq = ParticipantQuality::new();
assert_eq!(pq.current_tier, Tier::Good);
}
#[test]
fn participant_quality_degrades_on_bad_reports() {
let mut pq = ParticipantQuality::new();
let bad = make_report(50.0, 300);
// Feed enough bad reports to trigger downgrade (3 consecutive)
for _ in 0..5 {
pq.observe(&bad);
}
assert_ne!(pq.current_tier, Tier::Good, "should degrade from Good");
}
#[test]
fn weakest_tier_picks_worst() {
let good = ParticipantQuality::new();
// good stays at Good tier
let mut bad = ParticipantQuality::new();
let bad_report = make_report(50.0, 300);
for _ in 0..5 {
bad.observe(&bad_report);
}
// bad should be degraded or catastrophic
let participants = vec![good, bad];
let weakest = weakest_tier(participants.iter());
assert_ne!(weakest, Tier::Good, "weakest should not be Good when one participant is bad");
}
}

View File

@@ -52,6 +52,7 @@ fn alice_offer(call_id: &str) -> SignalMessage {
supported_profiles: vec![],
caller_reflexive_addr: Some(ALICE_ADDR.into()),
caller_local_addrs: Vec::new(),
caller_build_version: None,
}
}
@@ -132,6 +133,7 @@ fn bob_answer(call_id: &str) -> SignalMessage {
chosen_profile: None,
callee_reflexive_addr: Some(BOB_ADDR.into()),
callee_local_addrs: Vec::new(),
callee_build_version: None,
}
}

View File

@@ -105,6 +105,7 @@ fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage
supported_profiles: vec![],
caller_reflexive_addr: caller_reflexive_addr.map(String::from),
caller_local_addrs: Vec::new(),
caller_build_version: None,
}
}
@@ -122,6 +123,7 @@ fn mk_answer(
chosen_profile: None,
callee_reflexive_addr: callee_reflexive_addr.map(String::from),
callee_local_addrs: Vec::new(),
callee_build_version: None,
}
}

View File

@@ -65,6 +65,7 @@ async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) {
.send_signal(&SignalMessage::RegisterPresenceAck {
success: true,
error: None,
relay_build: None,
})
.await;
}

View File

@@ -123,7 +123,6 @@ fn transport_config() -> quinn::TransportConfig {
config.keep_alive_interval(Some(Duration::from_secs(5)));
// Enable DATAGRAM extension for unreliable media packets.
// Allow datagrams up to 1200 bytes (conservative for lossy links).
config.datagram_receive_buffer_size(Some(65536));
// Conservative flow control for bandwidth-constrained links
@@ -134,6 +133,26 @@ fn transport_config() -> quinn::TransportConfig {
// Aggressive initial RTT estimate for high-latency links
config.initial_rtt(Duration::from_millis(300));
// PMTUD (Path MTU Discovery) — quinn 0.11 enables this by default but
// with conservative bounds (initial 1200, upper 1452). We keep the safe
// initial_mtu of 1200 so the first packets always get through, but raise
// upper_bound so the binary search can discover larger MTUs on paths that
// support them. Typical results:
// - Ethernet/fiber: discovers ~1452 (Ethernet MTU minus IP/UDP/QUIC)
// - WireGuard/VPN: discovers ~1380-1420
// - Starlink: discovers ~1400-1452
// - Cellular: stays at 1200-1300
// Black hole detection automatically falls back to 1200 if probes fail.
// This matters for future video frames which can be 1-50 KB and benefit
// from fewer application-layer fragments per frame.
let mut mtu_config = quinn::MtuDiscoveryConfig::default();
mtu_config
.upper_bound(1452)
.interval(Duration::from_secs(300)) // re-probe every 5 min
.black_hole_cooldown(Duration::from_secs(30)); // retry faster on lossy links
config.mtu_discovery_config(Some(mtu_config));
config.initial_mtu(1200); // safe starting point
config
}

View File

@@ -25,7 +25,7 @@ pub mod reliable;
pub use config::{client_config, server_config, server_config_from_seed, tls_fingerprint};
pub use connection::{accept, connect, create_endpoint, create_ipv6_endpoint};
pub use path_monitor::PathMonitor;
pub use quic::QuinnTransport;
pub use quic::{QuinnPathSnapshot, QuinnTransport};
pub use wzp_proto::{MediaTransport, PathQuality, TransportError};
// Re-export the quinn Endpoint type so downstream crates (wzp-desktop) can

View File

@@ -2,11 +2,17 @@
//!
//! Tracks packet loss (via sequence number gaps), RTT, jitter, and bandwidth.
use std::collections::VecDeque;
use wzp_proto::PathQuality;
/// EWMA smoothing factor.
const ALPHA: f64 = 0.1;
/// Maximum number of RTT samples in the jitter variance sliding window.
/// At ~50 packets/sec (20 ms frame), 10 samples ≈ 200 ms.
const JITTER_VARIANCE_WINDOW_SIZE: usize = 10;
/// Monitors network path quality metrics.
pub struct PathMonitor {
/// EWMA-smoothed loss percentage (0.0 - 100.0).
@@ -31,6 +37,8 @@ pub struct PathMonitor {
last_rtt_ms: Option<f64>,
/// Whether we have any observations yet.
initialized: bool,
/// Sliding window of recent RTT samples for variance calculation.
rtt_window: VecDeque<f64>,
}
impl PathMonitor {
@@ -51,6 +59,7 @@ impl PathMonitor {
total_received: 0,
last_rtt_ms: None,
initialized: false,
rtt_window: VecDeque::with_capacity(JITTER_VARIANCE_WINDOW_SIZE),
}
}
@@ -122,6 +131,12 @@ impl PathMonitor {
} else {
self.rtt_ewma = ALPHA * rtt + (1.0 - ALPHA) * self.rtt_ewma;
}
// Maintain sliding window for variance calculation
if self.rtt_window.len() >= JITTER_VARIANCE_WINDOW_SIZE {
self.rtt_window.pop_front();
}
self.rtt_window.push_back(rtt);
}
/// Get the current estimated path quality.
@@ -155,6 +170,20 @@ impl PathMonitor {
0
}
/// Compute the jitter (RTT standard deviation) over the sliding window.
///
/// Returns the standard deviation in milliseconds, or 0.0 if insufficient
/// samples. Used by `DredTuner` for spike detection.
pub fn jitter_variance_ms(&self) -> f64 {
let n = self.rtt_window.len();
if n < 2 {
return 0.0;
}
let mean = self.rtt_window.iter().sum::<f64>() / n as f64;
let var = self.rtt_window.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
var.sqrt()
}
/// Detect whether a network handoff likely occurred.
///
/// Returns `true` if the most recent RTT jitter measurement exceeds 3x

View File

@@ -13,6 +13,29 @@ use crate::datagram;
use crate::path_monitor::PathMonitor;
use crate::reliable;
/// Snapshot of quinn's QUIC-level path statistics.
///
/// Provides more accurate loss/RTT data than `PathMonitor`'s sequence-gap
/// heuristic because quinn sees ACK frames and congestion signals directly.
#[derive(Clone, Copy, Debug)]
pub struct QuinnPathSnapshot {
/// Smoothed RTT in milliseconds (from quinn's congestion controller).
pub rtt_ms: u32,
/// Cumulative loss percentage (lost_packets / sent_packets × 100).
pub loss_pct: f32,
/// Total congestion events observed by the QUIC stack.
pub congestion_events: u64,
/// Current congestion window in bytes.
pub cwnd: u64,
/// Total packets sent on this path.
pub sent_packets: u64,
/// Total packets lost on this path.
pub lost_packets: u64,
/// Current PMTUD-discovered maximum datagram payload size (bytes).
/// Starts at `initial_mtu` (1200) and grows as PMTUD probes succeed.
pub current_mtu: usize,
}
/// QUIC-based transport implementing the `MediaTransport` trait.
pub struct QuinnTransport {
connection: quinn::Connection,
@@ -66,6 +89,31 @@ impl QuinnTransport {
datagram::max_datagram_payload(&self.connection)
}
/// Snapshot of QUIC-level path stats from quinn, useful for DRED tuning.
///
/// Returns `(rtt_ms, loss_pct, congestion_events)` derived from quinn's
/// internal congestion controller — more accurate than our own sequence-gap
/// heuristic in `PathMonitor` because quinn sees ACK frames directly.
pub fn quinn_path_stats(&self) -> QuinnPathSnapshot {
let stats = self.connection.stats();
let rtt_ms = stats.path.rtt.as_millis() as u32;
let loss_pct = if stats.path.sent_packets > 0 {
(stats.path.lost_packets as f32 / stats.path.sent_packets as f32) * 100.0
} else {
0.0
};
let current_mtu = self.connection.max_datagram_size().unwrap_or(1200);
QuinnPathSnapshot {
rtt_ms,
loss_pct,
congestion_events: stats.path.congestion_events,
cwnd: stats.path.cwnd,
sent_packets: stats.path.sent_packets,
lost_packets: stats.path.lost_packets,
current_mtu,
}
}
/// Send an encoded [`TrunkFrame`] as a single QUIC datagram.
pub fn send_trunk(&self, frame: &TrunkFrame) -> Result<(), TransportError> {
let data = frame.encode();

View File

@@ -72,18 +72,22 @@ class MainActivity : TauriActivity() {
* STREAM_VOICE_CALL volume is cranked to max since the in-call volume
* slider is separate from media volume on most devices.
*/
/**
* Pre-flight: only set volumes. Do NOT set MODE_IN_COMMUNICATION here —
* that hijacks the entire audio routing (music stops, BT A2DP drops to
* earpiece) even before a call starts. The Rust side sets the mode via
* JNI when the call engine actually starts, and restores MODE_NORMAL
* when the call ends.
*/
private fun configureAudioForCall() {
try {
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
Log.i(TAG, "audio state before: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
Log.i(TAG, "audio state: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)} " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/" +
"${am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = false // default: handset / earpiece
// Crank both voice-call and music volumes so nothing silent slips
// through regardless of which stream actually ends up driving.
val maxVoice = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
@@ -91,9 +95,7 @@ class MainActivity : TauriActivity() {
val maxMusic = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
am.setStreamVolume(AudioManager.STREAM_MUSIC, maxMusic, 0)
Log.i(TAG, "audio state after: mode=${am.mode} speaker=${am.isSpeakerphoneOn} " +
"voiceVol=${am.getStreamVolume(AudioManager.STREAM_VOICE_CALL)}/$maxVoice " +
"musicVol=${am.getStreamVolume(AudioManager.STREAM_MUSIC)}/$maxMusic")
Log.i(TAG, "volumes set: voiceVol=$maxVoice musicVol=$maxMusic (mode left at ${am.mode})")
} catch (e: Throwable) {
Log.e(TAG, "configureAudioForCall failed: ${e.message}", e)
}

View File

@@ -57,11 +57,37 @@ fn audio_manager<'local>(
Ok(am)
}
/// Set `AudioManager.MODE_IN_COMMUNICATION`. Call when a VoIP call starts.
/// This tells the audio policy to route through the communication device
/// path (earpiece/BT SCO) instead of the media path (speaker/BT A2DP).
pub fn set_audio_mode_communication() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// MODE_IN_COMMUNICATION = 3
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(3)])
.map_err(|e| format!("setMode(MODE_IN_COMMUNICATION): {e}"))?;
tracing::info!("AudioManager: mode set to MODE_IN_COMMUNICATION");
Ok(())
}
/// Restore `AudioManager.MODE_NORMAL`. Call when a VoIP call ends.
pub fn set_audio_mode_normal() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// MODE_NORMAL = 0
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(0)])
.map_err(|e| format!("setMode(MODE_NORMAL): {e}"))?;
tracing::info!("AudioManager: mode set to MODE_NORMAL");
Ok(())
}
/// Switch between loud speaker (`true`) and earpiece/handset (`false`).
///
/// Calls `AudioManager.setSpeakerphoneOn(on)` on the JVM. Requires that
/// the audio mode is already `MODE_IN_COMMUNICATION` — MainActivity.kt
/// sets this at startup, so by the time a call is up this is always true.
pub fn set_speakerphone(on: bool) -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
@@ -96,3 +122,238 @@ pub fn is_speakerphone_on() -> Result<bool, String> {
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
Ok(on)
}
// ─── Bluetooth SCO routing ──────────────────────────────────────────────────
/// Start Bluetooth SCO audio routing.
///
/// On API 31+ uses `setCommunicationDevice()` which is the modern way to
/// route voice audio to a specific device. Falls back to the deprecated
/// `startBluetoothSco()` path on older APIs.
///
/// The caller must restart Oboe streams after this call.
pub fn start_bluetooth_sco() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// Ensure speaker is off — mutually exclusive with BT.
env.call_method(
&am,
"setSpeakerphoneOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
// Try modern API first (API 31+): setCommunicationDevice(AudioDeviceInfo)
// Find a BT SCO or BLE device from getAvailableCommunicationDevices()
let used_modern = try_set_communication_device(&mut env, &am, true)?;
if !used_modern {
// Fallback: deprecated startBluetoothSco (API < 31)
tracing::info!("start_bluetooth_sco: falling back to deprecated startBluetoothSco");
env.call_method(&am, "startBluetoothSco", "()V", &[])
.map_err(|e| format!("startBluetoothSco: {e}"))?;
}
tracing::info!(used_modern, "AudioManager: Bluetooth SCO started");
Ok(())
}
/// Stop Bluetooth SCO audio routing, returning audio to the earpiece.
///
/// The caller must restart Oboe streams after this call.
pub fn stop_bluetooth_sco() -> Result<(), String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// Modern API: clearCommunicationDevice() (API 31+)
let cleared = try_set_communication_device(&mut env, &am, false)?;
if !cleared {
// Fallback: deprecated stopBluetoothSco
env.call_method(&am, "stopBluetoothSco", "()V", &[])
.map_err(|e| format!("stopBluetoothSco: {e}"))?;
}
tracing::info!(cleared, "AudioManager: Bluetooth SCO stopped");
Ok(())
}
/// Try to use the modern `setCommunicationDevice` / `clearCommunicationDevice`
/// API (Android 12 / API 31+). Returns `true` if the modern API was used.
fn try_set_communication_device(
env: &mut jni::AttachGuard<'_>,
am: &JObject<'_>,
enable: bool,
) -> Result<bool, String> {
// Check SDK_INT >= 31 (Android 12)
let sdk_int = env
.get_static_field(
"android/os/Build$VERSION",
"SDK_INT",
"I",
)
.and_then(|v| v.i())
.unwrap_or(0);
if sdk_int < 31 {
return Ok(false);
}
if !enable {
// clearCommunicationDevice()
env.call_method(am, "clearCommunicationDevice", "()V", &[])
.map_err(|e| format!("clearCommunicationDevice: {e}"))?;
tracing::info!("clearCommunicationDevice: done");
return Ok(true);
}
// getAvailableCommunicationDevices() → List<AudioDeviceInfo>
let device_list = env
.call_method(
am,
"getAvailableCommunicationDevices",
"()Ljava/util/List;",
&[],
)
.and_then(|v| v.l())
.map_err(|e| format!("getAvailableCommunicationDevices: {e}"))?;
let size = env
.call_method(&device_list, "size", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// Find first BT device: TYPE_BLUETOOTH_SCO (7), TYPE_BLUETOOTH_A2DP (8),
// TYPE_BLE_HEADSET (26), TYPE_BLE_SPEAKER (27)
for i in 0..size {
let device = env
.call_method(
&device_list,
"get",
"(I)Ljava/lang/Object;",
&[JValue::Int(i)],
)
.and_then(|v| v.l())
.map_err(|e| format!("list.get({i}): {e}"))?;
let device_type = env
.call_method(&device, "getType", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
if matches!(device_type, 7 | 8 | 26 | 27) {
let ok = env
.call_method(
am,
"setCommunicationDevice",
"(Landroid/media/AudioDeviceInfo;)Z",
&[JValue::Object(&device)],
)
.and_then(|v| v.z())
.unwrap_or(false);
tracing::info!(
device_type,
ok,
"setCommunicationDevice: set BT device"
);
return Ok(ok);
}
}
tracing::warn!("setCommunicationDevice: no BT device in available list");
Ok(false)
}
/// Query whether Bluetooth audio is currently the active communication device.
///
/// On API 31+ checks `getCommunicationDevice()` type. Falls back to the
/// deprecated `isBluetoothScoOn()` on older APIs.
pub fn is_bluetooth_sco_on() -> Result<bool, String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
let sdk_int = env
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
.and_then(|v| v.i())
.unwrap_or(0);
if sdk_int >= 31 {
// getCommunicationDevice() → AudioDeviceInfo (nullable)
let device = env
.call_method(am, "getCommunicationDevice", "()Landroid/media/AudioDeviceInfo;", &[])
.and_then(|v| v.l())
.unwrap_or(JObject::null());
if device.is_null() {
return Ok(false);
}
let device_type = env
.call_method(&device, "getType", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
return Ok(matches!(device_type, 7 | 8 | 26 | 27));
}
// Fallback: deprecated API
env.call_method(&am, "isBluetoothScoOn", "()Z", &[])
.and_then(|v| v.z())
.map_err(|e| format!("isBluetoothScoOn: {e}"))
}
/// Check whether a Bluetooth audio device is currently connected.
///
/// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for
/// any Bluetooth device type. Many headsets only register as A2DP until
/// SCO is explicitly started, so we check for both SCO and A2DP types.
pub fn is_bluetooth_available() -> Result<bool, String> {
let (vm, activity) = jvm_and_activity()?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {e}"))?;
let am = audio_manager(&mut env, &activity)?;
// AudioManager.GET_DEVICES_OUTPUTS = 2
let devices = env
.call_method(
&am,
"getDevices",
"(I)[Landroid/media/AudioDeviceInfo;",
&[JValue::Int(2)],
)
.and_then(|v| v.l())
.map_err(|e| format!("getDevices(OUTPUTS): {e}"))?;
let arr = jni::objects::JObjectArray::from(devices);
let len = env
.get_array_length(&arr)
.map_err(|e| format!("get_array_length: {e}"))?;
for i in 0..len {
let device = env
.get_object_array_element(&arr, i)
.map_err(|e| format!("get_object_array_element({i}): {e}"))?;
let device_type = env
.call_method(&device, "getType", "()I", &[])
.and_then(|v| v.i())
.unwrap_or(0);
// TYPE_BLUETOOTH_SCO = 7, TYPE_BLUETOOTH_A2DP = 8
if device_type == 7 || device_type == 8 {
tracing::info!(device_type, idx = i, "is_bluetooth_available: found BT device");
return Ok(true);
}
}
Ok(false)
}

View File

@@ -9,7 +9,7 @@
//! still fails cleanly but the rest of the engine code links in.
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tauri::Emitter;
@@ -26,11 +26,38 @@ use wzp_client::audio_io::{AudioCapture, AudioPlayback};
// Android (where wzp-client is pulled in with default-features=false).
use wzp_client::call::{CallConfig, CallEncoder};
use wzp_proto::traits::AudioDecoder;
use wzp_proto::{CodecId, MediaTransport, QualityProfile};
use wzp_proto::traits::{AudioDecoder, QualityController};
use wzp_proto::{AdaptiveQualityController, CodecId, MediaTransport, QualityProfile};
const FRAME_SAMPLES_40MS: usize = 1920;
/// Profile index mapping for the AtomicU8 adaptive-quality bridge.
const PROFILE_NO_CHANGE: u8 = 0xFF;
fn profile_to_index(p: &QualityProfile) -> u8 {
match p.codec {
CodecId::Opus64k => 0,
CodecId::Opus48k => 1,
CodecId::Opus32k => 2,
CodecId::Opus24k => 3,
CodecId::Opus6k => 4,
CodecId::Codec2_1200 => 5,
_ => 3, // default to GOOD
}
}
fn index_to_profile(idx: u8) -> Option<QualityProfile> {
match idx {
0 => Some(QualityProfile::STUDIO_64K),
1 => Some(QualityProfile::STUDIO_48K),
2 => Some(QualityProfile::STUDIO_32K),
3 => Some(QualityProfile::GOOD),
4 => Some(QualityProfile::DEGRADED),
5 => Some(QualityProfile::CATASTROPHIC),
_ => None,
}
}
/// Resolve a quality string from the UI to a QualityProfile.
/// Returns None for "auto" (use default adaptive behavior).
fn resolve_quality(quality: &str) -> Option<QualityProfile> {
@@ -436,6 +463,16 @@ impl CallEngine {
// hitting a "device busy" on some HALs.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Set MODE_IN_COMMUNICATION right before audio starts — NOT at
// app launch. Setting it early hijacks system audio routing
// (music drops from BT A2DP to earpiece, etc.).
#[cfg(target_os = "android")]
{
if let Err(e) = crate::android_audio::set_audio_mode_communication() {
tracing::warn!("set_audio_mode_communication failed: {e}");
}
}
let t_pre_audio = call_t0.elapsed().as_millis();
if let Err(code) = crate::wzp_native::audio_start() {
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}"));
@@ -470,6 +507,10 @@ impl CallEngine {
let tx_codec = Arc::new(Mutex::new(String::new()));
let rx_codec = Arc::new(Mutex::new(String::new()));
// Adaptive quality: shared pending-profile bridge between recv → send.
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
let auto_profile = resolve_quality(&quality).is_none();
// Send task — drain Oboe capture ring, Opus-encode, push to transport.
let send_t = transport.clone();
let send_r = running.clone();
@@ -482,6 +523,7 @@ impl CallEngine {
let send_tx_codec = tx_codec.clone();
let send_t0 = call_t0;
let send_app = app.clone();
let send_pending_profile = pending_profile.clone();
tokio::spawn(async move {
let profile = resolve_quality(&send_quality);
let config = match profile {
@@ -503,6 +545,13 @@ impl CallEngine {
encoder.set_aec_enabled(false);
let mut buf = vec![0i16; frame_samples];
// Continuous DRED tuning: poll quinn path stats every 25
// frames (~500 ms at 20 ms/frame) and adjust DRED duration +
// expected-loss hint based on real-time network conditions.
let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec);
let mut frames_since_dred_poll: u32 = 0;
const DRED_POLL_INTERVAL: u32 = 25;
let mut heartbeat = std::time::Instant::now();
let mut last_rms: u32 = 0;
let mut last_pkt_bytes: usize = 0;
@@ -592,6 +641,48 @@ impl CallEngine {
Err(e) => error!("encode: {e}"),
}
// Adaptive quality: check if recv task recommended a profile switch.
if auto_profile {
let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if p != PROFILE_NO_CHANGE {
if let Some(new_profile) = index_to_profile(p) {
info!(to = ?new_profile.codec, "auto: switching encoder profile");
if encoder.set_profile(new_profile).is_ok() {
dred_tuner.set_codec(new_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", new_profile.codec);
}
}
}
}
// DRED tuner: poll quinn path stats periodically and
// adjust encoder DRED duration + expected-loss hint.
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning);
if wzp_codec::dred_verbose_logs() {
info!(
dred_frames = tuning.dred_frames,
dred_ms = tuning.dred_frames as u32 * 10,
expected_loss = tuning.expected_loss_pct,
quinn_loss = format!("{:.1}", snap.loss_pct),
quinn_rtt = snap.rtt_ms,
jitter = pq.jitter_ms,
spike = dred_tuner.spike_boost_active(),
"DRED tuner adjusted encoder"
);
}
}
}
// Heartbeat every 2s with capture+encode+send state
if heartbeat.elapsed() >= std::time::Duration::from_secs(2) {
let fs = send_fs.load(Ordering::Relaxed);
@@ -637,6 +728,7 @@ impl CallEngine {
let recv_rx_codec = rx_codec.clone();
let recv_t0 = call_t0;
let recv_app = app.clone();
let pending_profile_recv = pending_profile.clone();
tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
// Phase 3b/3c: use concrete AdaptiveDecoder (not Box<dyn
@@ -651,6 +743,7 @@ impl CallEngine {
// Phase 3b/3c DRED reconstruction state — see DredRecvState
// above for the full flow.
let mut dred_recv = DredRecvState::new();
let mut quality_ctrl = AdaptiveQualityController::new();
info!(codec = ?current_codec, t_ms = recv_t0.elapsed().as_millis(), "first-join diag: recv task spawned (android/oboe)");
// First-join diagnostic latches — see send task above for the
// sibling capture milestones.
@@ -800,6 +893,15 @@ impl CallEngine {
);
}
// Adaptive quality: ingest quality reports from peer
if let Some(ref qr) = pkt.quality_report {
if let Some(new_profile) = quality_ctrl.observe(qr) {
let idx = profile_to_index(&new_profile);
info!(to = ?new_profile.codec, "auto: quality adapter recommends switch");
pending_profile_recv.store(idx, Ordering::Release);
}
}
match decoder.decode(&pkt.payload, &mut pcm) {
Ok(n) => {
last_decode_n = n;
@@ -1210,6 +1312,10 @@ impl CallEngine {
let tx_codec = Arc::new(Mutex::new(String::new()));
let rx_codec = Arc::new(Mutex::new(String::new()));
// Adaptive quality: shared pending-profile bridge between recv → send.
let pending_profile = Arc::new(AtomicU8::new(PROFILE_NO_CHANGE));
let auto_profile = resolve_quality(&quality).is_none();
// Send task
let send_t = transport.clone();
let send_r = running.clone();
@@ -1219,6 +1325,7 @@ impl CallEngine {
let send_drops = Arc::new(AtomicU64::new(0));
let send_quality = quality.clone();
let send_tx_codec = tx_codec.clone();
let send_pending_profile = pending_profile.clone();
tokio::spawn(async move {
let profile = resolve_quality(&send_quality);
let config = match profile {
@@ -1240,6 +1347,11 @@ impl CallEngine {
encoder.set_aec_enabled(false); // OS AEC or none
let mut buf = vec![0i16; frame_samples];
// Continuous DRED tuning (same as Android send task).
let mut dred_tuner = wzp_proto::DredTuner::new(config.profile.codec);
let mut frames_since_dred_poll: u32 = 0;
const DRED_POLL_INTERVAL: u32 = 25;
loop {
if !send_r.load(Ordering::Relaxed) {
break;
@@ -1275,6 +1387,35 @@ impl CallEngine {
}
Err(e) => error!("encode: {e}"),
}
// Adaptive quality: check if recv task recommended a profile switch.
if auto_profile {
let p = send_pending_profile.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if p != PROFILE_NO_CHANGE {
if let Some(new_profile) = index_to_profile(p) {
info!(to = ?new_profile.codec, "auto: switching encoder profile");
if encoder.set_profile(new_profile).is_ok() {
dred_tuner.set_codec(new_profile.codec);
*send_tx_codec.lock().await = format!("{:?}", new_profile.codec);
}
}
}
}
// DRED tuner: poll quinn path stats periodically.
frames_since_dred_poll += 1;
if frames_since_dred_poll >= DRED_POLL_INTERVAL {
frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update(
snap.loss_pct,
snap.rtt_ms,
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning);
}
}
}
});
@@ -1284,6 +1425,7 @@ impl CallEngine {
let recv_spk = spk_muted.clone();
let recv_fr = frames_received.clone();
let recv_rx_codec = rx_codec.clone();
let pending_profile_recv = pending_profile.clone();
tokio::spawn(async move {
let initial_profile = resolve_quality(&quality).unwrap_or(QualityProfile::GOOD);
// Phase 3b/3c: concrete AdaptiveDecoder (not Box<dyn>) so we
@@ -1296,6 +1438,7 @@ impl CallEngine {
let mut agc = wzp_codec::AutoGainControl::new();
let mut pcm = vec![0i16; FRAME_SAMPLES_40MS]; // big enough for any codec
let mut dred_recv = DredRecvState::new();
let mut quality_ctrl = AdaptiveQualityController::new();
loop {
if !recv_r.load(Ordering::Relaxed) {
@@ -1360,6 +1503,15 @@ impl CallEngine {
);
}
// Adaptive quality: ingest quality reports from peer
if let Some(ref qr) = pkt.quality_report {
if let Some(new_profile) = quality_ctrl.observe(qr) {
let idx = profile_to_index(&new_profile);
info!(to = ?new_profile.codec, "auto: quality adapter recommends switch");
pending_profile_recv.store(idx, Ordering::Release);
}
}
if let Ok(n) = decoder.decode(&pkt.payload, &mut pcm) {
agc.process_frame(&mut pcm[..n]);
if !recv_spk.load(Ordering::Relaxed) {
@@ -1490,6 +1642,26 @@ impl CallEngine {
#[cfg(target_os = "android")]
{
crate::wzp_native::audio_stop();
// Release the BT SCO communication device so Android can
// route media (video, music) back to BT A2DP. Without this,
// setCommunicationDevice locks BT to SCO mode and other apps
// can't use the headset for media playback until reboot.
if let Err(e) = crate::android_audio::stop_bluetooth_sco() {
tracing::warn!("stop_bluetooth_sco on call end failed: {e}");
}
// Restore MODE_NORMAL so other apps' audio routes normally.
if let Err(e) = crate::android_audio::set_audio_mode_normal() {
tracing::warn!("set_audio_mode_normal failed: {e}");
}
}
}
}
impl Drop for CallEngine {
fn drop(&mut self) {
// Safety net: if stop() was never called (crash, app
// backgrounding), signal tasks to exit so they don't
// spin on a dropped transport.
self.running.store(false, Ordering::SeqCst);
}
}

View File

@@ -503,29 +503,13 @@ async fn connect(
peer_ok
}
_ => {
// Timeout — the peer's report
// didn't arrive. This happens when
// peers are on different relays
// (no federation for MediaPathReport)
// or the peer is on an old build.
//
// If OUR direct path succeeded and
// the transport is still alive,
// trust it — the timeout is a relay
// forwarding issue, not a direct
// path failure.
// Timeout or channel error — peer
// may be on an old build without
// Phase 6. Fall back to relay.
emit_call_debug(&app, "connect:peer_report_timeout", serde_json::json!({}));
let mut sig = state.signal.lock().await;
sig.pending_path_report = None;
let trust_direct = local_direct_ok
&& race_result.direct_transport.as_ref()
.map(|t| t.connection().close_reason().is_none())
.unwrap_or(false);
emit_call_debug(&app, "connect:peer_report_timeout", serde_json::json!({
"local_direct_ok": local_direct_ok,
"direct_transport_alive": trust_direct,
"fallback": if trust_direct { "Direct" } else { "Relay" },
}));
trust_direct
false
}
}
};
@@ -560,8 +544,21 @@ async fn connect(
// it for participant authentication.
is_direct_p2p_agreed = use_direct;
if use_direct {
// Close losing relay transport so the
// relay sees a clean disconnect instead
// of waiting 30s for idle timeout.
if let Some(loser) = race_result.relay_transport.as_ref() {
loser.connection().close(0u32.into(), b"not-selected");
}
race_result.direct_transport
} else {
// Close losing direct transport so the
// peer's endpoint doesn't retain a
// phantom connection that pollutes
// future accept() calls.
if let Some(loser) = race_result.direct_transport.as_ref() {
loser.connection().close(0u32.into(), b"not-selected");
}
race_result.relay_transport
}
}
@@ -778,6 +775,102 @@ async fn is_speakerphone_on() -> Result<bool, String> {
}
}
// ─── Bluetooth SCO routing (Android-specific, no-op on desktop) ─────────────
/// Enable or disable Bluetooth SCO audio routing. Like speakerphone toggling,
/// this requires an Oboe stream restart so AAudio picks up the new route.
///
/// `startBluetoothSco()` is asynchronous — the SCO link takes 500ms-2s to
/// establish. We poll `isBluetoothScoOn()` up to 3 seconds before restarting
/// Oboe, so the streams open against the BT device rather than earpiece.
#[tauri::command]
#[allow(unused_variables)]
async fn set_bluetooth_sco(on: bool) -> Result<(), String> {
#[cfg(target_os = "android")]
{
if on {
android_audio::start_bluetooth_sco()?;
// Wait for SCO link to actually connect before restarting Oboe.
// startBluetoothSco() is async — jumping straight to Oboe restart
// would open streams against earpiece, not the BT device.
let mut connected = false;
for i in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if android_audio::is_bluetooth_sco_on().unwrap_or(false) {
tracing::info!(polls = i + 1, "set_bluetooth_sco: SCO connected");
connected = true;
break;
}
}
if !connected {
tracing::warn!("set_bluetooth_sco: SCO did not connect within 5s, proceeding anyway");
}
// Extra delay: even after getCommunicationDevice reports BT,
// the audio policy needs ~500ms to apply the bt-sco route.
// Without this, Oboe opens against the old device.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} else {
android_audio::stop_bluetooth_sco()?;
}
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
tracing::info!(on, "set_bluetooth_sco: restarting Oboe for route change");
tokio::task::spawn_blocking(move || {
wzp_native::audio_stop();
if on {
// BT mode: skip sample rate + input preset on capture
// so the system can route to the BT SCO device natively.
wzp_native::audio_start_bt()
.map_err(|code| format!("audio_start_bt after BT on: code {code}"))
} else {
// Normal mode: restore 48kHz + VoiceCommunication preset.
wzp_native::audio_start()
.map_err(|code| format!("audio_start after BT off: code {code}"))
}
})
.await
.map_err(|e| format!("spawn_blocking join: {e}"))??;
tracing::info!("set_bluetooth_sco: Oboe restarted");
}
Ok(())
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
/// Check whether a Bluetooth SCO device is currently connected and available.
#[tauri::command]
async fn is_bluetooth_available() -> Result<bool, String> {
#[cfg(target_os = "android")]
{
android_audio::is_bluetooth_available()
}
#[cfg(not(target_os = "android"))]
{
Ok(false)
}
}
/// Return the current audio route as a string: "bluetooth", "speaker", or "earpiece".
#[tauri::command]
async fn get_audio_route() -> Result<String, String> {
#[cfg(target_os = "android")]
{
if android_audio::is_bluetooth_sco_on()? {
return Ok("bluetooth".into());
}
if android_audio::is_speakerphone_on()? {
return Ok("speaker".into());
}
Ok("earpiece".into())
}
#[cfg(not(target_os = "android"))]
{
Ok("earpiece".into())
}
}
// ─── Call history commands ───────────────────────────────────────────────────
#[tauri::command]
@@ -909,6 +1002,7 @@ async fn internal_deregister(
sig.incoming_caller_fp = None;
sig.incoming_caller_alias = None;
sig.pending_reflect = None;
sig.pending_path_report = None;
sig.own_reflex_addr = None;
if !keep_desired {
sig.desired_relay_addr = None;
@@ -982,8 +1076,10 @@ fn do_register_signal(
emit_call_debug(&app, "register_signal:register_presence_sent", serde_json::json!({}));
match transport.recv_signal().await.map_err(|e| format!("{e}"))? {
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
emit_call_debug(&app, "register_signal:ack_received", serde_json::json!({}));
Some(SignalMessage::RegisterPresenceAck { success: true, relay_build, .. }) => {
emit_call_debug(&app, "register_signal:ack_received", serde_json::json!({
"relay_build": relay_build,
}));
}
_ => {
emit_call_debug(&app, "register_signal:ack_failed", serde_json::json!({}));
@@ -1087,10 +1183,14 @@ fn do_register_signal(
}),
);
}
Ok(Some(SignalMessage::Hangup { reason })) => {
Ok(Some(SignalMessage::Hangup { reason, .. })) => {
tracing::info!(?reason, "signal: Hangup");
emit_call_debug(&app_clone, "recv:Hangup", serde_json::json!({ "reason": format!("{:?}", reason) }));
let mut sig = signal_state.lock().await; sig.signal_status = "registered".into(); sig.incoming_call_id = None; sig.ipv6_endpoint = None;
let mut sig = signal_state.lock().await;
sig.signal_status = "registered".into();
sig.incoming_call_id = None;
sig.ipv6_endpoint = None;
sig.pending_path_report = None;
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"hangup"}));
}
Ok(Some(SignalMessage::MediaPathReport { call_id, direct_ok, race_winner })) => {
@@ -1372,7 +1472,11 @@ async fn place_call(
.map(|la| la.port())
.unwrap_or(0);
// Phase 7: create IPv6 endpoint, trying same port as v4
// Phase 7: create IPv6 endpoint, trying same port as v4.
// Close any leftover from a previous call first.
if let Some(old) = sig.ipv6_endpoint.take() {
old.close(0u32.into(), b"new-call");
}
let (sc, _) = wzp_transport::server_config();
let v6_ep = wzp_transport::create_ipv6_endpoint(v4_port, Some(sc)).ok();
let v6_port = v6_ep.as_ref()
@@ -1491,7 +1595,10 @@ async fn answer_call(
.map(|la| la.port())
.unwrap_or(0);
// Phase 7: create IPv6 endpoint
// Phase 7: create IPv6 endpoint. Close leftover first.
if let Some(old) = sig.ipv6_endpoint.take() {
old.close(0u32.into(), b"new-call");
}
let (sc, _) = wzp_transport::server_config();
let v6_ep = wzp_transport::create_ipv6_endpoint(v4_port, Some(sc)).ok();
let v6_port = v6_ep.as_ref()
@@ -1782,6 +1889,7 @@ async fn hangup_call(
match transport
.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: None,
})
.await
{
@@ -1886,6 +1994,7 @@ pub fn run() {
hangup_call,
deregister,
set_speakerphone, is_speakerphone_on,
set_bluetooth_sco, is_bluetooth_available, get_audio_route,
get_call_history, get_recent_contacts, clear_call_history,
set_dred_verbose_logs, get_dred_verbose_logs,
set_call_debug_logs, get_call_debug_logs,

View File

@@ -26,6 +26,7 @@ static LIB: OnceLock<libloading::Library> = OnceLock::new();
static VERSION: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static HELLO: OnceLock<unsafe extern "C" fn(*mut u8, usize) -> usize> = OnceLock::new();
static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_START_BT: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new();
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new();
@@ -65,6 +66,7 @@ pub fn init() -> Result<(), String> {
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version");
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello");
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start");
resolve!(AUDIO_START_BT, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start_bt");
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture");
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout");
@@ -104,6 +106,14 @@ pub fn audio_start() -> Result<(), i32> {
if ret == 0 { Ok(()) } else { Err(ret) }
}
/// Start Oboe in Bluetooth SCO mode — capture skips sample rate and
/// input preset so the system routes to the BT SCO device natively.
pub fn audio_start_bt() -> Result<(), i32> {
let f = AUDIO_START_BT.get().ok_or(-100_i32)?;
let ret = unsafe { f() };
if ret == 0 { Ok(()) } else { Err(ret) }
}
/// Stop both streams. Safe to call even if not running.
pub fn audio_stop() {
if let Some(f) = AUDIO_STOP.get() {

View File

@@ -872,12 +872,11 @@ function showCallScreen() {
}
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
// Sync the Speaker/Earpiece label with the OS state (Android only; on
// desktop the command is a no-op returning false so we land on "Earpiece"
// which is fine because desktop has no routing concept).
invoke<boolean>("is_speakerphone_on")
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); })
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
// Sync the audio route label with the OS state (Android only; on desktop
// get_audio_route returns "earpiece" so we land on the default).
invoke<string>("get_audio_route")
.then((route) => { currentAudioRoute = (route as AudioRoute) || "earpiece"; updateRouteLabel(); })
.catch(() => { currentAudioRoute = "earpiece"; updateRouteLabel(); });
}
function showConnectScreen() {
@@ -898,38 +897,76 @@ micBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
});
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then
// stops and restarts the Oboe streams so AAudio reconfigures with the new
// routing. The Rust-side Tauri command handles the restart, we just swap
// the button label.
// Audio routing (Android) — cycles between earpiece, speaker, and Bluetooth
// SCO. Each transition calls the corresponding Tauri command which sets the
// AudioManager state and restarts Oboe streams so AAudio picks up the new
// route. On desktop all commands are no-ops.
//
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
// (which would tint the button red); that was a bug in 0178cbd that made
// earpiece mode look like playback was off. A separate `.speaker-on` class
// is available for css styling if we want to visually indicate loud mode.
let speakerphoneOn = false;
let speakerphoneBusy = false;
function updateSpkLabel() {
spkBtn.classList.toggle("speaker-on", speakerphoneOn);
// earpiece mode look like playback was off.
type AudioRoute = "earpiece" | "speaker" | "bluetooth";
let currentAudioRoute: AudioRoute = "earpiece";
let routeBusy = false;
function updateRouteLabel() {
spkBtn.classList.remove("speaker-on", "bt-on");
spkBtn.classList.remove("muted");
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece";
switch (currentAudioRoute) {
case "speaker":
spkIcon.textContent = "🔊 Speaker";
spkBtn.classList.add("speaker-on");
break;
case "bluetooth":
spkIcon.textContent = "🎧 BT";
spkBtn.classList.add("bt-on");
break;
default:
spkIcon.textContent = "🔈 Earpiece";
break;
}
spkBtn.addEventListener("click", async () => {
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms
speakerphoneBusy = true;
const next = !speakerphoneOn;
}
async function cycleAudioRoute() {
if (routeBusy) return; // debounce — Oboe restart takes ~60-400ms
routeBusy = true;
spkBtn.disabled = true;
try {
await invoke("set_speakerphone", { on: next });
speakerphoneOn = next;
updateSpkLabel();
const btAvailable = await invoke<boolean>("is_bluetooth_available");
const routes: AudioRoute[] = btAvailable
? ["earpiece", "speaker", "bluetooth"]
: ["earpiece", "speaker"];
const idx = routes.indexOf(currentAudioRoute);
const next = routes[(idx + 1) % routes.length];
// Tear down current route, then activate next.
// start_bluetooth_sco() already calls setSpeakerphoneOn(false)
// internally, so we skip the separate speakerphone toggle when
// transitioning to BT to avoid a redundant Oboe restart.
if (currentAudioRoute === "bluetooth") {
await invoke("set_bluetooth_sco", { on: false });
}
if (next === "speaker") {
await invoke("set_speakerphone", { on: true });
} else if (next === "bluetooth") {
// BT start handles speaker-off internally + waits for SCO link
await invoke("set_bluetooth_sco", { on: true });
} else {
// earpiece — turn everything off
await invoke("set_speakerphone", { on: false });
}
currentAudioRoute = next;
updateRouteLabel();
} catch (e) {
console.error("set_speakerphone failed:", e);
console.error("cycleAudioRoute failed:", e);
} finally {
spkBtn.disabled = false;
speakerphoneBusy = false;
routeBusy = false;
}
});
}
spkBtn.addEventListener("click", cycleAudioRoute);
hangupBtn.addEventListener("click", async () => {
userDisconnected = true;
// Use the new hangup_call command instead of raw disconnect —
@@ -1002,7 +1039,7 @@ async function pollStatus() {
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
// NB: spkBtn label is driven by the Android audio routing state
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted.
// (currentAudioRoute / updateRouteLabel), not by the engine's spk_muted.
// Skip that here so pollStatus doesn't clobber the routing UI.
callTimer.textContent = formatDuration(st.call_duration_secs);

View File

@@ -1083,7 +1083,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
color: white;
}
/* Speaker routing button (non-muted earpiece state should not look red) */
/* Audio routing button — highlight color depends on active route */
#spk-btn.speaker-on .icon {
color: var(--accent);
}
#spk-btn.bt-on .icon {
color: #60a5fa; /* blue-400 for Bluetooth */
}

View File

@@ -103,11 +103,13 @@ sequenceDiagram
participant RNN as RNNoise<br/>(2 x 480)
participant VAD as SilenceDetector
participant Codec as Opus / Codec2
participant DT as DredTuner<br/>(wzp-proto)
participant FEC as RaptorQ FEC
participant INT as Interleaver<br/>(depth=3)
participant HDR as MediaHeader<br/>(12B or Mini 4B)
participant Enc as ChaCha20-Poly1305
participant QUIC as QUIC Datagram
participant QPS as QuinnPathSnapshot
Mic->>Ring: f32 x 512 (macOS callback)
Ring->>Ring: Accumulate to 960 samples
@@ -118,10 +120,19 @@ sequenceDiagram
else Silence (>100ms)
VAD->>Codec: ComfortNoise (every 200ms)
end
Note over QPS,DT: Every 25 frames (~500ms)
QPS->>DT: loss_pct, rtt_ms, jitter_ms
DT->>Codec: set_dred_duration() + set_expected_loss()
alt Opus tier (any bitrate)
Codec->>HDR: Compressed bytes + DRED side-channel (no RaptorQ)
else Codec2 tier
Codec->>FEC: Compressed bytes (pad to 256B symbol)
FEC->>FEC: Accumulate block (5-10 symbols)
FEC->>INT: Source + repair symbols
INT->>HDR: Interleaved packets
end
HDR->>Enc: Header as AAD
Enc->>QUIC: Encrypted payload + 16B tag
```
@@ -134,6 +145,9 @@ sequenceDiagram
- Silence detection uses VAD + 100ms hangover before switching to ComfortNoise
- FEC symbols are padded to **256 bytes** with a 2-byte LE length prefix
- MiniHeaders (4 bytes) replace full headers (12 bytes) for 49 of every 50 frames
- DRED tuner polls quinn path stats every 25 frames (~500ms) and adjusts DRED lookback duration continuously
- Opus tiers bypass RaptorQ entirely -- DRED handles loss recovery at the codec layer
- Opus6k DRED window: 1040ms (maximum libopus allows)
## Audio Decode Pipeline
@@ -154,13 +168,30 @@ sequenceDiagram
Dec->>AR: Decrypt (header = AAD)
AR->>AR: Check seq window (reject replay)
AR->>HDR: Verified packet
alt Opus packet
HDR->>JIT: Direct to jitter buffer (no FEC/interleave)
else Codec2 packet
HDR->>DEINT: MediaHeader + payload
DEINT->>FEC: Reordered symbols by block
FEC->>FEC: Attempt decode (need K of K+R)
FEC->>JIT: Recovered audio frames
end
JIT->>JIT: BTreeMap ordered by seq
JIT->>JIT: Wait until depth >= target
alt Packet present
JIT->>Codec: Pop lowest seq frame
else Packet missing (Opus)
JIT->>Codec: DRED reconstruction (neural)
alt DRED fails or unavailable
Codec->>Codec: Classical PLC fallback
end
else Packet missing (Codec2)
Codec->>Codec: Classical PLC
end
Codec->>Ring: PCM i16 x 960
Ring->>SPK: Audio callback pulls samples
```
@@ -172,6 +203,8 @@ sequenceDiagram
- Jitter buffer target: **10 packets (200ms)** for client, **50 packets (1s)** for relay
- Desktop client uses **direct playout** (no jitter buffer) with lock-free ring
- Codec2 frames at 8 kHz are resampled to 48 kHz transparently
- DRED reconstruction: on packet loss, decoder tries neural DRED reconstruction before falling back to classical PLC
- Jitter-spike detection pre-emptively boosts DRED to ceiling when jitter variance spikes >30%
## Relay SFU Forwarding
@@ -211,6 +244,7 @@ graph TB
3. If one send fails, the relay continues to the next participant (best-effort)
4. The relay never decodes or re-encodes audio (preserves E2E encryption)
5. With trunking enabled, packets to the same receiver are batched into TrunkFrames (flushed every 5ms)
6. Relay tracks per-participant quality from QualityReport trailers and broadcasts `QualityDirective` when the room-wide tier degrades (coordinated codec switching)
## Federation Topology
@@ -348,7 +382,7 @@ Used for 49 of every 50 frames (~1s cycle). Saves 8 bytes per packet (67% header
[session_id: 2][len: u16][payload: len] x count
```
Packs multiple session packets into one QUIC datagram. Maximum 10 entries or 1200 bytes, flushed every 5ms.
Packs multiple session packets into one QUIC datagram. Maximum 10 entries or PMTUD-discovered MTU (starts at 1200, grows to ~1452 on Ethernet), flushed every 5ms.
### QualityReport (4 bytes, optional trailer)
@@ -361,6 +395,40 @@ Byte 3: bitrate_cap_kbps (0-255 kbps)
Appended to a media packet when the Q flag is set in the MediaHeader.
## Path MTU Discovery
Quinn's PLPMTUD is enabled with:
- `initial_mtu`: 1200 bytes (QUIC minimum, always safe)
- `upper_bound`: 1452 bytes (Ethernet minus IP/UDP/QUIC headers)
- `interval`: 300s (re-probe every 5 minutes)
- `black_hole_cooldown`: 30s (faster retry on lossy links)
The discovered MTU is exposed via `QuinnPathSnapshot::current_mtu` and used by:
- `TrunkedForwarder`: refreshes `max_bytes` on every send to fill larger datagrams
- Future video framer: larger MTU = fewer application-layer fragments per frame
## Continuous DRED Tuning
Instead of locking DRED duration to 3 discrete quality tiers, the `DredTuner` (in `wzp-proto::dred_tuner`) maps live path quality to a continuous DRED duration:
| Input | Source | Update Rate |
|-------|--------|-------------|
| Loss % | `QuinnPathSnapshot::loss_pct` (from quinn ACK frames) | Every 25 packets (~500ms) |
| RTT ms | `QuinnPathSnapshot::rtt_ms` (quinn congestion controller) | Every 25 packets |
| Jitter ms | `PathMonitor::jitter_ms` (EWMA of RTT variance) | Every 25 packets |
### Mapping Logic
- **Baseline**: codec-tier default (Studio=100ms, Good=200ms, Degraded=500ms)
- **Ceiling**: codec-tier max (Studio=300ms, Good=500ms, Degraded=1040ms)
- **Continuous**: linear interpolation between baseline and ceiling based on loss (0%->baseline, 40%->ceiling)
- **RTT phantom loss**: high RTT (>200ms) adds phantom loss contribution to keep DRED generous
- **Jitter spike**: >30% EWMA spike pre-emptively boosts to ceiling for ~5s cooldown
### Output
`DredTuning { dred_frames: u8, expected_loss_pct: u8 }` -> fed to `CallEncoder::apply_dred_tuning()` -> `OpusEncoder::set_dred_duration()` + `set_expected_loss()`
## Signal Message Handshake Flow
```mermaid
@@ -940,3 +1008,67 @@ The patch introduces an `MSVC_CL` variable that is true only for real `cl.exe` (
This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream.
Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale).
## Network Awareness (Android)
The adaptive quality controller (`AdaptiveQualityController` in `wzp-proto`) supports proactive network-aware adaptation via `signal_network_change(NetworkContext)`. On Android, this is fed by `NetworkMonitor.kt` which wraps `ConnectivityManager.NetworkCallback`.
```
ConnectivityManager
│ onCapabilitiesChanged / onLost
NetworkMonitor.kt ──classify──► type: Int (WiFi=0, LTE=1, 5G=2, 3G=3)
│ onNetworkChanged(type, bw)
CallViewModel ──► WzpEngine.onNetworkChanged()
│ JNI
jni_bridge.rs
EngineState.pending_network_type (AtomicU8, lock-free)
│ polled every ~20ms
recv task: quality_ctrl.signal_network_change(ctx)
├─ WiFi → Cellular: preemptive 1-tier downgrade
├─ Any change: 10s FEC boost (+0.2 ratio)
└─ Cellular: faster downgrade thresholds (2 vs 3)
```
Cellular generation is approximated from `getLinkDownstreamBandwidthKbps()` to avoid requiring `READ_PHONE_STATE` permission.
## Audio Routing (Android)
Both Android app variants support 3-way audio routing: **Earpiece → Speaker → Bluetooth SCO**.
### Audio Mode Lifecycle
`MODE_IN_COMMUNICATION` is set by the Rust call engine (via JNI `AudioManager.setMode()`) right before Oboe streams open — NOT at app launch. Restored to `MODE_NORMAL` when the call ends. This prevents hijacking system audio routing (music, BT A2DP) before a call is active.
### Native Kotlin App
`AudioRouteManager.kt` handles device detection (via `AudioDeviceCallback`), SCO lifecycle, and auto-fallback on BT disconnect. `CallViewModel.cycleAudioRoute()` cycles through available routes.
### Tauri Desktop App
`android_audio.rs` provides JNI bridges to `AudioManager` for speakerphone and Bluetooth SCO control. After each route change, Oboe streams are stopped and restarted via `spawn_blocking`.
```
User tap ──► cycleAudioRoute()
├─ Earpiece: setSpeakerphoneOn(false) + clearCommunicationDevice()
├─ Speaker: setSpeakerphoneOn(true)
└─ BT SCO: setCommunicationDevice(bt_device) [API 31+]
│ fallback: startBluetoothSco() [API < 31]
Oboe stop + start_bt() for BT / start() for others
```
### BT SCO and Oboe
BT SCO only supports 8/16kHz. When `bt_active=1`, Oboe capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system choose the native BT rate. Oboe's `SampleRateConversionQuality::Best` bridges to our 48kHz ring buffers. Playout uses `Usage::Media` in BT mode to avoid conflicts with the communication device routing.
### Hangup Signal Fix
`SignalMessage::Hangup` now carries an optional `call_id` field. The relay uses it to end only the specific call instead of broadcasting to all active calls for the user — preventing a race where a hangup for call 1 kills a newly-placed call 2.

View File

@@ -583,9 +583,79 @@ Signal messages are sent over reliable QUIC streams as length-prefixed JSON:
| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
| wzp-web | 2 | Metrics |
## Audio Routing (Android)
WarzonePhone supports three audio output routes on Android: **Earpiece**, **Speaker**, and **Bluetooth SCO**. The user cycles through available routes with a single button.
### Audio mode lifecycle
`MODE_IN_COMMUNICATION` is set **when the call engine starts** (right before Oboe `audio_start()`), not at app launch. This is critical — setting it early hijacks system audio routing (e.g. music drops from BT A2DP to earpiece). `MODE_NORMAL` is restored when the call engine stops.
```
App launch → MODE_NORMAL (other apps' audio unaffected)
Call start → set_audio_mode_communication() → MODE_IN_COMMUNICATION
Call end → audio_stop() → set_audio_mode_normal() → MODE_NORMAL
```
### Route lifecycle
1. Call starts → Earpiece (default).
2. User taps route button → cycles to next available route.
3. Route change requires Oboe stream restart (~60-400ms) because AAudio silently tears down streams on some OEMs when the routing target changes mid-stream.
4. Bluetooth disconnect mid-call → `AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece or Speaker.
### Bluetooth SCO
SCO (Synchronous Connection Oriented) is the correct Bluetooth profile for VoIP — it provides bidirectional mono audio at 8/16 kHz with ~30ms latency. A2DP (stereo, high-quality) is unidirectional and adds 100-200ms of buffering, making it unsuitable for real-time voice.
On API 31+ (Android 12), we use the modern `setCommunicationDevice(AudioDeviceInfo)` API to route audio to the BT SCO device. The deprecated `startBluetoothSco()` + `setBluetoothScoOn()` path is used as fallback on older APIs. `setBluetoothScoOn()` is silently rejected on Android 12+ for non-system apps.
BT SCO devices only support 8/16kHz sample rates, but our pipeline runs at 48kHz. When BT is active, Oboe opens in **BT mode** (`bt_active=1`): capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system open at the device's native rate. Oboe's `SampleRateConversionQuality::Best` resamples to/from 48kHz for our ring buffers.
### Two app variants
Both the native Kotlin app (`AudioRouteManager.kt`) and the Tauri app (`android_audio.rs` JNI bridge) support BT SCO routing. The native app uses `AudioDeviceCallback` for automatic device detection; the Tauri app uses `getAvailableCommunicationDevices()` (API 31+) or `getDevices()` on demand.
## Network Change Response
The `AdaptiveQualityController` in `wzp-proto` reacts to network transport changes signaled via `signal_network_change(NetworkContext)`:
| Transition | Response |
|-----------|----------|
| WiFi → Cellular | Preemptive 1-tier quality downgrade + 10s FEC boost |
| Cellular → WiFi | FEC boost only (quality recovers via normal adaptive logic) |
| Any change | Reset hysteresis counters to avoid stale state |
On Android, `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback` and classifies the transport type using bandwidth heuristics (no `READ_PHONE_STATE` needed). The classification is delivered to the Rust engine via JNI → `AtomicU8` → recv task polling — the same lock-free cross-task signaling pattern used for adaptive profile switches.
### Cellular generation heuristics
| Downstream bandwidth | Classification |
|---------------------|---------------|
| >= 100 Mbps | 5G NR |
| >= 10 Mbps | LTE |
| < 10 Mbps | 3G or worse |
These thresholds are conservative. Carriers over-report bandwidth, but for VoIP quality decisions the exact generation matters less than the rough category.
## Build Requirements
- **Rust** 1.85+ (2024 edition)
- **Linux**: cmake, pkg-config, libasound2-dev (for audio feature)
- **macOS**: Xcode command line tools (CoreAudio included)
- **Android**: NDK r27c, cmake 3.28+ (from pip)
- **Android**: NDK 26.1 (r26b), cmake 3.25-3.28 (system package)
### Android APK Builds
```bash
# arm64 only (default, 25MB release APK)
./scripts/build-tauri-android.sh --init --release --arch arm64
# armv7 only (smaller devices)
./scripts/build-tauri-android.sh --init --release --arch armv7
# both architectures as separate APKs
./scripts/build-tauri-android.sh --init --release --arch all
```
Release APKs are signed with `android/keystore/wzp-release.jks` via `apksigner`. Per-arch builds produce separate APKs (~25MB each vs ~50MB universal) for easier sharing with testers.

105
docs/PRD-bluetooth-audio.md Normal file
View File

@@ -0,0 +1,105 @@
# PRD: Bluetooth Audio Routing
> Phase: Implemented
> Status: Ready for testing
> Platforms: Android (native Kotlin app + Tauri desktop app)
## Problem
WarzonePhone had `AudioRouteManager.kt` with complete Bluetooth SCO support, but it was disconnected from both UIs. Users with Bluetooth headsets had no way to route call audio to them.
## Solution
Wire Bluetooth SCO routing end-to-end through both app variants, replacing the binary speaker toggle with a 3-way audio route cycle: **Earpiece → Speaker → Bluetooth**.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Native Kotlin App (com.wzp) │
│ │
│ InCallScreen ──► CallViewModel ──► AudioRouteManager
│ (Compose UI) cycleAudioRoute() setSpeaker() │
│ "Ear/Spk/BT" audioRoute Flow setBluetoothSco()
│ isBluetoothAvailable()
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Tauri Desktop App (com.wzp.desktop) │
│ │
│ main.ts ──► Tauri Commands ──► android_audio.rs │
│ cycleAudioRoute() set_bluetooth_sco() JNI calls │
│ "Ear/Spk/BT" is_bluetooth_available() │
│ get_audio_route() │
│ │
│ After each route change: Oboe stop + start │
│ (spawn_blocking to avoid stalling tokio) │
└─────────────────────────────────────────────────────┘
```
## Components Modified
### Native Kotlin App
| File | Change |
|------|--------|
| `CallViewModel.kt` | Added `audioRoute: StateFlow<AudioRoute>`, `cycleAudioRoute()`, wired `onRouteChanged` callback |
| `InCallScreen.kt` | `ControlRow` now takes `audioRoute: AudioRoute` + `onCycleRoute`, displays Ear/Spk/BT with distinct colors |
### Tauri App
| File | Change |
|------|--------|
| `android_audio.rs` | `setCommunicationDevice()` (API 31+) with `startBluetoothSco()` fallback; `set_audio_mode_communication/normal()` for call lifecycle |
| `lib.rs` | `set_bluetooth_sco`, `is_bluetooth_available`, `get_audio_route` Tauri commands; SCO polling + 500ms route delay |
| `wzp_native.rs` | Added `audio_start_bt()` for BT-mode Oboe (skips 48kHz + VoiceCommunication preset) |
| `oboe_bridge.cpp` | `bt_active` flag: capture skips sample rate + input preset; playout uses `Usage::Media`; both use `Shared` mode + `SampleRateConversionQuality::Best` |
| `engine.rs` | `set_audio_mode_communication()` before `audio_start()`; `set_audio_mode_normal()` after `audio_stop()` |
| `MainActivity.kt` | Removed `MODE_IN_COMMUNICATION` from app launch — deferred to call start |
| `main.ts` | Replaced `speakerphoneOn` toggle with `currentAudioRoute` cycling logic |
| `style.css` | Added `.bt-on` CSS class (blue-400 highlight) |
## Audio Route Lifecycle
1. **App launch**`MODE_NORMAL` (other apps' audio unaffected — BT A2DP music keeps playing)
2. **Call starts**`MODE_IN_COMMUNICATION` set via JNI, Oboe opens with earpiece routing
3. **User taps route button** → cycles to next available route
4. **Route changes**`setCommunicationDevice()` (API 31+) + Oboe restart in BT mode or normal mode
5. **BT device disconnects mid-call**`AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece/Speaker
6. **Call ends** → route reset, `MODE_NORMAL` restored
## Route Cycling Logic
```
Available routes = [Earpiece, Speaker] + [Bluetooth] if SCO device connected
Tap cycle:
Earpiece → Speaker → Bluetooth (if available) → Earpiece → ...
If BT not available:
Earpiece → Speaker → Earpiece → ...
```
## Permissions
- `BLUETOOTH_CONNECT` (Android 12+) — already in `AndroidManifest.xml`
- `MODIFY_AUDIO_SETTINGS` — already in manifest
## Known Limitations
- **SCO only** — no A2DP (stereo music profile). SCO is correct for VoIP (bidirectional mono).
- **API 31+ required for modern path** — `setCommunicationDevice()` is the primary BT routing API. Fallback to deprecated `startBluetoothSco()` on API < 31 (untested).
- **BT SCO capture at 8/16kHz** — Oboe resamples to 48kHz via `SampleRateConversionQuality::Best`. Quality is inherently limited by the SCO codec (CVSD at 8kHz or mSBC at 16kHz).
- **No auto-switch on BT connect** — when a BT device connects mid-call, user must tap the route button.
- **500ms route switch delay** — after `setCommunicationDevice()` returns, the audio policy needs time to apply the bt-sco route. We wait 500ms before restarting Oboe.
## Testing
1. Pair a Bluetooth SCO headset with Android device
2. Start call → verify Earpiece is default
3. Tap route → Speaker (audio moves to loudspeaker, button shows "Spk")
4. Tap route → BT (audio moves to headset, button shows "BT", blue highlight)
5. Tap route → Earpiece (audio back to earpiece, button shows "Ear")
6. Disconnect BT mid-call → verify auto-fallback
7. Verify both app variants work identically
8. Verify no audio glitches during route transitions

View File

@@ -196,3 +196,19 @@ Implementation strategy: build for P2P first (simpler, 2 parties), then wrap the
| 4 | Upgrade proposal + negotiation protocol | 2 days |
| 5 | P2P quality adaptation (direct observation) | 1 day |
| 6 | Per-participant asymmetric encoding (Option 2) | 1 day |
## Implementation Status (2026-04-12)
Phases 1-2 are now implemented:
### What was built
- **`QualityDirective` signal** (`crates/wzp-proto/src/packet.rs`): New `SignalMessage` variant with `recommended_profile` and optional `reason`
- **`ParticipantQuality`** (`crates/wzp-relay/src/room.rs`): Per-participant quality tracking using `AdaptiveQualityController`, created on join, removed on leave
- **Weakest-link broadcast**: `observe_quality()` method computes room-wide worst tier, broadcasts `QualityDirective` to all participants when tier changes
- **Desktop engine handling** (`desktop/src-tauri/src/engine.rs`): `AdaptiveQualityController` in recv task, `pending_profile` AtomicU8 bridge to send task, auto-mode profile switching
### Phases 3-4 remaining
- Phase 3: Client-side handling of `QualityDirective` (reacting to relay-pushed profile)
- Phase 4: Upgrade proposal/negotiation protocol for quality recovery

View File

@@ -358,3 +358,31 @@ End-to-end testing, in order:
- **OSCE enable**: opusic-c has an `osce` feature flag for Opus Speech Coding Enhancement, a separate libopus 1.5 neural post-processor. Out of scope for this PRD but should be the next audio-quality follow-up. Probably one-line enable once opusic-c is in.
- **Upstream PR to opusic-c**: our own `dred_ffi.rs` wrapper should be proven in production first, then the fixes upstreamed to `opusic-c/src/dred.rs` (preserve `dred_end`, fix `dred_offset` double-pass, expose `DredPacket` externally). Follow-up task, not blocking this PRD.
- **`feat/desktop-audio-rewrite` merge**: the vendored `audiopus_sys` patch on that branch becomes obsolete under this PRD. Coordinate removal with whoever owns that branch.
## Phase A: Continuous DRED Tuning (Implemented 2026-04-12)
Phase A extends the discrete tier-locked DRED durations from Phases 1-3 with continuous, network-driven tuning.
### What was built
- **`DredTuner`** (`crates/wzp-proto/src/dred_tuner.rs`): Maps `(loss_pct, rtt_ms, jitter_ms)``(dred_frames, expected_loss_pct)` continuously
- **Quinn stats exposure** (`crates/wzp-transport/src/quic.rs`): `QuinnPathSnapshot` provides quinn's internal RTT, loss, congestion events — more accurate than sequence-gap heuristics
- **Jitter variance window** (`crates/wzp-transport/src/path_monitor.rs`): 10-sample sliding window for RTT standard deviation, used for spike detection
- **`AudioEncoder` trait extensions** (`crates/wzp-proto/src/traits.rs`): `set_expected_loss()` and `set_dred_duration()` with default no-op, overridden by `OpusEncoder` and `AdaptiveEncoder`
- **Engine integration** (`desktop/src-tauri/src/engine.rs`): Both Android and desktop send tasks poll every 25 frames and apply tuning
### Opus6k DRED extended
`dred_duration_for(Opus6k)` changed from 50 (500ms) to 104 (1040ms) — the maximum libopus 1.5 supports. The RDO-VAE's quality-vs-offset curve makes this nearly free in bitrate terms while doubling burst resilience on the worst links.
### Jitter spike detection ("Sawtooth" prediction)
When instantaneous jitter exceeds the EWMA × 1.3 (asymmetric: fast-up α=0.3, slow-down α=0.05), the tuner enters spike-boost mode:
- DRED immediately jumps to the codec tier's ceiling
- Cooldown: 10 cycles (~5 seconds at 25 packets/cycle)
- Designed for Starlink satellite handover sawtooth jitter pattern
### Test coverage
- 10 unit tests for tuner math (baseline, scaling, spike, cooldown, codec switch, Codec2 no-op)
- 4 integration tests (encoder adjustment, spike boost, Codec2 no-op, profile switch with encode verification)

View File

@@ -57,3 +57,28 @@ When the path MTU is small, the relay or client should:
- MTU-based codec selection (future, needs adaptive quality)
## Effort: 1 day
## Implementation Status (2026-04-12)
Phase 1 is now implemented:
### What was built
- **Transport config** (`crates/wzp-transport/src/config.rs`):
- `MtuDiscoveryConfig` with `upper_bound=1452`, `interval=300s`, `black_hole_cooldown=30s`
- `initial_mtu=1200` (safe QUIC minimum)
- Quinn's PLPMTUD binary-searches from 1200 up to 1452 automatically
- **`QuinnPathSnapshot::current_mtu`** (`crates/wzp-transport/src/quic.rs`):
- Reads `connection.max_datagram_size()` which reflects the PMTUD-discovered value
- Available to all callers via `transport.quinn_path_stats()`
- **Trunk batcher MTU-aware** (`crates/wzp-relay/src/room.rs`):
- `TrunkedForwarder::new()` initializes `max_bytes` from discovered MTU
- `send()` refreshes `max_bytes` on every call (cheap atomic read in quinn)
- Federation trunk frames grow automatically as PMTUD discovers larger paths
### Phases 2-3 status
- Phase 2 (handle MTU failures): Already handled — `send_media()`/`send_trunk()` check `max_datagram_size()` and return `DatagramTooLarge` errors. These are logged and the packet is dropped gracefully.
- Phase 3 (codec-aware MTU): Not yet implemented. Future video frames will need application-layer fragmentation when they exceed the discovered MTU.

View File

@@ -0,0 +1,129 @@
# PRD: Network Awareness
> Phase: Implemented (core path)
> Status: Ready for testing
> Platform: Android native Kotlin app (com.wzp)
## Problem
WarzonePhone's quality controller (`AdaptiveQualityController`) had a `signal_network_change()` API for proactive adaptation to WiFi↔cellular transitions, but nothing called it. Network handoffs during calls were only detected reactively via jitter spikes — by which time the user had already experienced degraded audio.
## Solution
Integrate Android's `ConnectivityManager.NetworkCallback` to detect network transport changes in real-time and feed them to the quality controller. This enables:
1. **Preemptive quality downgrade** when switching from WiFi to cellular
2. **FEC boost** (10-second window with +0.2 ratio) after any network change
3. **Faster downgrade thresholds** on cellular (2 consecutive reports vs 3 on WiFi)
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ Android │
│ │
│ ConnectivityManager │
│ │ NetworkCallback │
│ ▼ │
│ NetworkMonitor.kt │
│ │ onNetworkChanged(type, bandwidthKbps) │
│ ▼ │
│ CallViewModel.kt ──► WzpEngine.onNetworkChanged() │
│ │ JNI │
│ ▼ │
│ jni_bridge.rs: nativeOnNetworkChanged(handle, type, bw) │
│ │ │
│ ▼ │
│ engine.rs: state.pending_network_type.store(type) │
│ │ AtomicU8 (lock-free) │
│ ▼ │
│ recv task: quality_ctrl.signal_network_change(ctx) │
│ │ │
│ ├─ Preemptive downgrade (WiFi → cellular) │
│ ├─ FEC boost 10s │
│ └─ Faster cellular thresholds │
└──────────────────────────────────────────────────────────────┘
```
## Network Classification
`NetworkMonitor` classifies the active transport without requiring `READ_PHONE_STATE` permission by using bandwidth heuristics:
| Downstream Bandwidth | Classification | Rust `NetworkContext` |
|----------------------|---------------|----------------------|
| N/A (WiFi transport) | WiFi | `WiFi` |
| >= 100 Mbps | 5G NR | `Cellular5g` |
| >= 10 Mbps | LTE | `CellularLte` |
| < 10 Mbps | 3G or worse | `Cellular3g` |
| Ethernet | WiFi (equivalent) | `WiFi` |
| Network lost | None | `Unknown` |
## Cross-Task Signaling
The network type is communicated from the JNI thread to the recv task via `AtomicU8` — the same pattern used for `pending_profile` (adaptive quality profile switches):
```
JNI thread recv task (tokio)
│ │
│ store(type, Release) │
│──────────────────────────────►│
│ │ swap(0xFF, Acquire)
│ │ if != 0xFF:
│ │ quality_ctrl.signal_network_change(ctx)
│ │
```
Sentinel value `0xFF` means "no change pending". The recv task polls on every received packet (~20-40ms), so latency is bounded by the inter-packet interval.
## Components
### New File
| File | Purpose |
|------|---------|
| `android/.../net/NetworkMonitor.kt` | ConnectivityManager callback, transport classification, deduplication |
### Modified Files
| File | Change |
|------|--------|
| `android/.../engine/WzpEngine.kt` | Added `onNetworkChanged()` method + `nativeOnNetworkChanged` external |
| `android/.../ui/call/CallViewModel.kt` | Instantiates NetworkMonitor, wires callback, register/unregister lifecycle |
| `crates/wzp-android/src/jni_bridge.rs` | Added `Java_com_wzp_engine_WzpEngine_nativeOnNetworkChanged` JNI entry |
| `crates/wzp-android/src/engine.rs` | Added `pending_network_type: AtomicU8` to EngineState, recv task polls it |
### Unchanged (already implemented)
| File | API |
|------|-----|
| `crates/wzp-proto/src/quality.rs` | `AdaptiveQualityController::signal_network_change(NetworkContext)` |
| `crates/wzp-transport/src/path_monitor.rs` | `PathMonitor::detect_handoff()` (available for future use) |
## Deferred Work
### Tauri Desktop App (com.wzp.desktop)
The Tauri engine doesn't use `AdaptiveQualityController` — quality is resolved once at call start. Adding network monitoring requires first adding adaptive quality to the Tauri call engine, which is a larger change.
### Mid-Call ICE Re-gathering
When the device's IP address changes, ideally we should:
1. Re-gather local host candidates (`local_host_candidates()`)
2. Re-probe STUN (`probe_reflect_addr()`)
3. Send updated candidates to the peer (`CandidateUpdate` signal message)
4. Attempt new dual-path race for path upgrade
`NetworkMonitor.onIpChanged` fires on `onLinkPropertiesChanged` — the hook is ready, but the signaling and re-racing logic is not yet implemented.
## Testing
1. Build native APK
2. Start a call on WiFi
3. Verify logcat: `quality controller: network context updated` with `ctx=WiFi`
4. Disable WiFi → device falls to cellular
5. Verify logcat: `ctx=CellularLte` (or `Cellular5g`/`Cellular3g`)
6. Verify FEC boost activates (check quality_ctrl logs)
7. Verify preemptive quality downgrade (tier drops one level on WiFi→cellular)
8. Re-enable WiFi → verify transition back
9. Rapid WiFi toggle (5x in 10s) → verify no crashes, deduplication works
10. Airplane mode → verify `onLost` fires with `TYPE_NONE`

View File

@@ -120,7 +120,7 @@
- **Web audio drift**: The browser AudioWorklet playback buffer caps at 200ms, but clock drift between the WebSocket message arrival rate and the AudioContext output rate can cause occasional underruns or accumulation. The cap prevents unbounded growth but may cause glitches.
- **No adaptive loop integration**: The `PathMonitor` feeds and `AdaptiveQualityController` are implemented but not wired together in the client's main loop. Quality reports are consumed when present in packets, but the client does not currently generate periodic quality reports from transport metrics.
- **Adaptive loop integration (resolved)**: AdaptiveQualityController is now fully wired into both desktop and Android send/recv tasks. Relay-coordinated codec switching broadcasts QualityDirective to all participants based on weakest-link policy.
- **Relay FEC pass-through**: In room mode, the relay forwards packets opaquely without FEC decode/re-encode. This means FEC protection is end-to-end only, not per-hop. In forward mode, the relay pipeline does perform FEC decode/re-encode.
@@ -128,18 +128,18 @@
## Test Coverage
119 tests across 7 crates (wzp-web has no Rust tests):
307+ tests across 7 crates (wzp-web has no Rust tests):
| Crate | Test Files | Test Count |
|-------|-----------|------------|
| wzp-proto | 5 | 27 |
| wzp-codec | 3 | 24 |
| wzp-fec | 5 | 21 |
| wzp-crypto | 5 | 21 |
| wzp-transport | 3 | 12 |
| wzp-relay | 4 | 10 |
| wzp-client | 3 | 8 |
| **Total** | **28** | **119** |
| Crate | Test Count |
|-------|------------|
| wzp-proto | ~79 |
| wzp-codec | ~69 |
| wzp-fec | ~21 |
| wzp-crypto | ~21 |
| wzp-transport | ~11 |
| wzp-relay | ~50 |
| wzp-client | ~57 |
| **Total** | **307+** |
Tests cover:
- Wire format roundtrip (header, quality report, full packet)
@@ -191,3 +191,72 @@ Run with `wzp-bench --all`. Representative results (Apple M-series, single core)
- **Hetzner VPS**: Build script (`scripts/build-linux.sh`) tested for provisioning, building, and downloading Linux binaries
- **CI**: Gitea workflow defined for amd64/arm64/armv7 builds
- **Production**: Not yet deployed to production networks
## Recent Changes (2026-04-12)
### Bluetooth Audio Routing
- 3-way route cycling: Earpiece → Speaker → Bluetooth SCO
- `setCommunicationDevice()` API 31+ with `startBluetoothSco()` fallback
- BT-mode Oboe: capture skips 48kHz + VoiceCommunication, Oboe resamples 8/16kHz ↔ 48kHz
- `MODE_IN_COMMUNICATION` deferred to call start (was at app launch — hijacked system audio)
### Network Change Detection
- `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback`
- WiFi/cellular classification via bandwidth heuristics (no READ_PHONE_STATE needed)
- Feeds `AdaptiveQualityController::signal_network_change()` via JNI → AtomicU8 → recv task
### Hangup Signal Fix
- `SignalMessage::Hangup` now carries optional `call_id`
- Relay only ends the named call (not all calls for the user)
- Fixes race: hangup for call 1 no longer kills newly-placed call 2
### Per-Architecture APK Builds
- `build-tauri-android.sh --arch arm64|armv7|all`
- Separate per-arch APKs (~25MB each vs ~50MB universal)
- Release APKs signed with `wzp-release.jks` via `apksigner`
### Continuous DRED Tuning (Phase A: opus-DRED-v2)
- `DredTuner` in `wzp-proto::dred_tuner` maps live network metrics to continuous DRED duration
- Polls quinn path stats every 25 frames (~500ms): loss%, RTT, jitter
- Linear interpolation between baseline and ceiling per codec tier (not discrete tier jumps)
- Jitter-spike detection: >30% EWMA spike pre-emptively boosts DRED to ceiling for ~5s
- RTT phantom loss: high RTT (>200ms) adds phantom contribution to keep DRED generous
- `set_expected_loss()` and `set_dred_duration()` added to `AudioEncoder` trait
- Integrated into both Android and desktop send tasks in engine.rs
### Extended DRED Window
- Opus6k DRED duration increased from 500ms to 1040ms (max libopus 1.5 supports)
- RDO-VAE naturally degrades quality at longer offsets — extra window costs ~1-2 kbps
### PMTUD (Path MTU Discovery)
- Quinn's PLPMTUD explicitly configured: initial 1200, upper bound 1452, 300s interval
- `QuinnPathSnapshot` exposes discovered MTU via `current_mtu` field
- `TrunkedForwarder` refreshes `max_bytes` from PMTUD (was hard-coded 1200)
- Federation trunk frames now fill the discovered path MTU automatically
### New Tests
- 4 DRED tuner integration tests in wzp-client (encoder adjustment, spike boost, Codec2 no-op, profile switch)
- 10 unit tests in wzp-proto for DredTuner mapping logic
- Jitter variance window tests in wzp-transport PathMonitor
- Pre-existing test fixes: added missing `build_version` fields to 7 SignalMessage constructors
### Desktop Adaptive Quality (#7, #31)
- `AdaptiveQualityController` wired into both Android and desktop send/recv tasks
- `pending_profile: Arc<AtomicU8>` bridge between recv (writer) and send (reader)
- Auto mode: ingests QualityReports from relay, switches encoder profile when adapter recommends
- `tx_codec` display string updated on profile switch for UI indicator
- `profile_to_index()` / `index_to_profile()` mapping for 6-tier range
### Relay Coordinated Codec Switching (#25, #26)
- `ParticipantQuality` struct in relay RoomManager tracks per-participant quality
- Quality reports from forwarded packets feed per-participant `AdaptiveQualityController`
- `weakest_tier()` computes room-wide worst tier across all participants
- `QualityDirective` SignalMessage variant: relay broadcasts recommended profile to all participants
- Triggered on tier change — instant, no negotiation (weakest-link policy)
### Oboe Stream State Polling (#35)
- C++ polling loop after `requestStart()`: checks `getState()` every 10ms for up to 2s
- Waits for both capture and playout streams to reach `Started` state
- Logs initial state, poll count, and final state for HAL debugging
- Does NOT fail on timeout — Rust-side stall detector remains as safety net
- Targets Nothing Phone A059 intermittent silent calls on cold start

View File

@@ -15,11 +15,14 @@ set -euo pipefail
# - Output: desktop/src-tauri/gen/android/.../*.apk
#
# Usage:
# ./scripts/build-tauri-android.sh # full pipeline (debug)
# ./scripts/build-tauri-android.sh # full pipeline (debug, arm64 only)
# ./scripts/build-tauri-android.sh --release # release APK
# ./scripts/build-tauri-android.sh --no-pull # skip git fetch
# ./scripts/build-tauri-android.sh --rust # force-clean rust target
# ./scripts/build-tauri-android.sh --init # also run `cargo tauri android init`
# ./scripts/build-tauri-android.sh --arch arm64 # arm64 only (default)
# ./scripts/build-tauri-android.sh --arch armv7 # armv7 only (smaller APK)
# ./scripts/build-tauri-android.sh --arch all # both arm64 + armv7 (separate APKs)
#
# Environment:
# WZP_BRANCH Branch to build (default: feat/desktop-audio-rewrite)
@@ -36,25 +39,39 @@ REBUILD_RUST=0
DO_PULL=1
DO_INIT=0
BUILD_RELEASE=0
BUILD_ARCH="arm64"
NEXT_IS_ARCH=0
for arg in "$@"; do
if [ "$NEXT_IS_ARCH" = "1" ]; then
BUILD_ARCH="$arg"
NEXT_IS_ARCH=0
continue
fi
case "$arg" in
--rust) REBUILD_RUST=1 ;;
--pull) DO_PULL=1 ;;
--no-pull) DO_PULL=0 ;;
--init) DO_INIT=1 ;;
--release) BUILD_RELEASE=1 ;;
--arch) NEXT_IS_ARCH=1 ;;
-h|--help)
sed -n '3,30p' "$0"
sed -n '3,32p' "$0"
exit 0
;;
esac
done
# Validate --arch
case "$BUILD_ARCH" in
arm64|armv7|all) ;;
*) echo "ERROR: --arch must be arm64, armv7, or all (got: $BUILD_ARCH)"; exit 1 ;;
esac
if [ -z "$BRANCH" ]; then
echo "ERROR: could not determine target branch (detached HEAD?). Pass WZP_BRANCH=name."
exit 1
fi
echo "Target branch: $BRANCH"
echo "Target branch: $BRANCH arch: $BUILD_ARCH"
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
ssh_cmd() { ssh -A $SSH_OPTS "$REMOTE_HOST" "$@"; }
@@ -75,6 +92,7 @@ DO_PULL="${2:-1}"
REBUILD_RUST="${3:-0}"
DO_INIT="${4:-0}"
BUILD_RELEASE="${5:-0}"
BUILD_ARCH="${6:-arm64}"
LOG_FILE=/tmp/wzp-tauri-build.log
GIT_HASH="unknown" # populated after fetch
@@ -155,10 +173,25 @@ PROFILE_FLAG="--debug"
mkdir -p "$BASE_DIR/data/cache/android-home"
chown 1000:1000 "$BASE_DIR/data/cache/android-home" 2>/dev/null || true
# ─── Determine target architectures ──────────────────────────────────────
# Maps BUILD_ARCH to cargo-ndk ABI names and cargo-tauri target names.
# BUILD_ARCH=arm64 → one APK; BUILD_ARCH=armv7 → one APK; BUILD_ARCH=all → two APKs.
case "$BUILD_ARCH" in
arm64) ARCH_LIST="arm64" ;;
armv7) ARCH_LIST="armv7" ;;
all) ARCH_LIST="arm64 armv7" ;;
esac
# Mapping functions (used inside docker via env vars)
# cargo-ndk ABI: arm64-v8a | armeabi-v7a
# cargo-tauri: aarch64 | armv7
# NDK sysroot: aarch64-linux-android | arm-linux-androideabi
docker run --rm \
--user 1000:1000 \
-e DO_INIT="$DO_INIT" \
-e PROFILE_FLAG="$PROFILE_FLAG" \
-e BUILD_ARCH="$BUILD_ARCH" \
-v "$BASE_DIR/data/source:/build/source" \
-v "$BASE_DIR/data/cache/cargo-registry:/home/builder/.cargo/registry" \
-v "$BASE_DIR/data/cache/cargo-git:/home/builder/.cargo/git" \
@@ -185,60 +218,179 @@ if [ "${DO_INIT}" = "1" ] || [ ! -x gen/android/gradlew ]; then
cargo tauri android init 2>&1 | tail -20
fi
# ─── Arch list from BUILD_ARCH env var ───────────────────────────────────
case "${BUILD_ARCH}" in
arm64) ARCHS="arm64" ;;
armv7) ARCHS="armv7" ;;
all) ARCHS="arm64 armv7" ;;
*) ARCHS="arm64" ;;
esac
ndk_abi() {
case "$1" in
arm64) echo "arm64-v8a" ;;
armv7) echo "armeabi-v7a" ;;
esac
}
tauri_target() {
case "$1" in
arm64) echo "aarch64" ;;
armv7) echo "armv7" ;;
esac
}
ndk_sysroot_dir() {
case "$1" in
arm64) echo "aarch64-linux-android" ;;
armv7) echo "arm-linux-androideabi" ;;
esac
}
# ─── wzp-native standalone cdylib (built with cargo-ndk, not cargo-tauri) ──
# Produces libwzp_native.so which wzp-desktop dlopens at runtime via
# libloading. Split exists because cargo-tauri`s linker wiring pulls
# libloading. Split exists because cargo-tauri linker wiring pulls
# bionic private symbols into any cdylib with cc::Build C++, causing
# __init_tcb+4 SIGSEGV. cargo-ndk uses the same linker path as the
# legacy wzp-android crate which works.
echo ">>> cargo ndk build -p wzp-native --release"
JNI_ABI_DIR=gen/android/app/src/main/jniLibs/arm64-v8a
JNILIBS_BASE=gen/android/app/src/main/jniLibs
for ARCH in $ARCHS; do
ABI=$(ndk_abi "$ARCH")
SYSROOT_DIR=$(ndk_sysroot_dir "$ARCH")
JNI_ABI_DIR="$JNILIBS_BASE/$ABI"
mkdir -p "$JNI_ABI_DIR"
echo ">>> cargo ndk build -p wzp-native --release -t $ABI"
(
cd /build/source
cargo ndk -t arm64-v8a -o desktop/src-tauri/gen/android/app/src/main/jniLibs \
cargo ndk -t "$ABI" -o "desktop/src-tauri/$JNILIBS_BASE" \
build --release -p wzp-native 2>&1 | tail -10
)
if [ -f "$JNI_ABI_DIR/libwzp_native.so" ]; then
ls -lh "$JNI_ABI_DIR/libwzp_native.so"
else
echo ">>> WARNING: libwzp_native.so not produced"
echo ">>> WARNING: libwzp_native.so not produced for $ABI"
fi
# ─── libc++_shared.so — required by wzp-native at runtime ──────────────
# ─── libc++_shared.so — required by wzp-native at runtime ────────────
# wzp-native/build.rs uses cpp_link_stdlib(Some("c++_shared")) which adds
# a NEEDED entry for libc++_shared.so to libwzp_native.so. cargo-ndk does
# NOT copy the actual libc++_shared.so into jniLibs, so unless we copy it
# explicitly, the APK ships without it and the Android dynamic linker
# fails the dlopen with "library libc++_shared.so not found" at runtime.
# Same fix that build-and-notify.sh has had for the legacy wzp-android
# path (lines 126-134 there) — ported here for the Tauri pipeline.
# NOTE: no apostrophes in this comment block. The enclosing docker
# bash -c uses single quotes and a stray apostrophe closes the string
# prematurely, breaking variable scope for everything below.
if [ ! -f "$JNI_ABI_DIR/libc++_shared.so" ]; then
echo ">>> libc++_shared.so missing, copying from NDK..."
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/aarch64-linux-android/*" | head -1)
echo ">>> libc++_shared.so missing for $ABI, copying from NDK..."
NDK_LIBCXX=$(find "$ANDROID_NDK_HOME" -name "libc++_shared.so" -path "*/${SYSROOT_DIR}/*" | head -1)
if [ -n "$NDK_LIBCXX" ]; then
cp "$NDK_LIBCXX" "$JNI_ABI_DIR/"
ls -lh "$JNI_ABI_DIR/libc++_shared.so"
else
echo ">>> ERROR: libc++_shared.so not found in NDK — APK will crash at dlopen time"
echo ">>> ERROR: libc++_shared.so not found in NDK for $ABI — APK will crash at dlopen time"
exit 1
fi
fi
done
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk"
cargo tauri android build ${PROFILE_FLAG} --target aarch64 --apk
# ─── Build per-arch APKs ────────────────────────────────────────────────
# When building for a single arch, only that arch jniLibs dir exists so
# the APK is naturally single-arch and smaller.
# When building --arch all, we produce SEPARATE per-arch APKs by:
# 1. Building each target individually with cargo tauri android build
# 2. Temporarily hiding the other arch jniLibs so the APK only contains one
# This keeps APKs small (~15-20MB instead of ~30-40MB for universal).
APK_OUTPUT_DIR="/build/source/target/apk-output"
mkdir -p "$APK_OUTPUT_DIR"
for ARCH in $ARCHS; do
TARGET=$(tauri_target "$ARCH")
ABI=$(ndk_abi "$ARCH")
# If building all, temporarily hide other arches to get single-arch APK
if [ "${BUILD_ARCH}" = "all" ]; then
for OTHER_ARCH in $ARCHS; do
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/$OTHER_ABI" ]; then
mv "$JNILIBS_BASE/$OTHER_ABI" "$JNILIBS_BASE/_hide_$OTHER_ABI"
fi
done
fi
echo ""
echo ">>> cargo tauri android build ${PROFILE_FLAG} --target $TARGET --apk"
cargo tauri android build ${PROFILE_FLAG} --target "$TARGET" --apk
# Copy produced APK with arch suffix
BUILT_APK=$(find gen/android -name "*.apk" -newer "$APK_OUTPUT_DIR" -type f 2>/dev/null | head -1)
if [ -z "$BUILT_APK" ]; then
BUILT_APK=$(find gen/android -name "*.apk" -type f 2>/dev/null | sort -t/ -k1 | tail -1)
fi
if [ -n "$BUILT_APK" ]; then
OUT_APK="$APK_OUTPUT_DIR/wzp-tauri-${ARCH}.apk"
cp "$BUILT_APK" "$OUT_APK"
# ─── Sign release APKs with the project keystore ─────────────
# Release builds are unsigned by default. Sign with the release
# keystore (checked into the repo at android/keystore/) so the
# APK can be installed on real devices.
# Pick keystore + credentials (release preferred, debug fallback)
KS_RELEASE="/build/source/android/keystore/wzp-release.jks"
KS_DEBUG="/build/source/android/keystore/wzp-debug.jks"
if [ -f "$KS_RELEASE" ]; then
KEYSTORE="$KS_RELEASE"; KS_PASS="wzphone2024"; KS_ALIAS="wzp-release"
elif [ -f "$KS_DEBUG" ]; then
KEYSTORE="$KS_DEBUG"; KS_PASS="android"; KS_ALIAS="wzp-debug"
else
KEYSTORE=""
fi
if [ -n "$KEYSTORE" ]; then
ZIPALIGN=$(find "$ANDROID_HOME" -name zipalign -type f 2>/dev/null | head -1)
APKSIGNER=$(find "$ANDROID_HOME" -name apksigner -type f 2>/dev/null | head -1)
if [ -n "$ZIPALIGN" ] && [ -n "$APKSIGNER" ]; then
echo ">>> Signing $ARCH APK with $(basename "$KEYSTORE")..."
ALIGNED="$APK_OUTPUT_DIR/wzp-tauri-${ARCH}-aligned.apk"
"$ZIPALIGN" -f 4 "$OUT_APK" "$ALIGNED"
"$APKSIGNER" sign \
--ks "$KEYSTORE" \
--ks-pass "pass:$KS_PASS" \
--ks-key-alias "$KS_ALIAS" \
--key-pass "pass:$KS_PASS" \
"$ALIGNED"
mv "$ALIGNED" "$OUT_APK"
echo ">>> Signed: $(ls -lh "$OUT_APK" | awk "{print \$5}")"
else
echo ">>> WARNING: zipalign/apksigner not found — APK is unsigned"
fi
else
echo ">>> WARNING: no keystore found — APK is unsigned"
fi
echo ">>> $ARCH APK: $(ls -lh "$OUT_APK" | awk "{print \$5}")"
fi
# Restore hidden arches
if [ "${BUILD_ARCH}" = "all" ]; then
for OTHER_ARCH in $ARCHS; do
OTHER_ABI=$(ndk_abi "$OTHER_ARCH")
if [ "$OTHER_ABI" != "$ABI" ] && [ -d "$JNILIBS_BASE/_hide_$OTHER_ABI" ]; then
mv "$JNILIBS_BASE/_hide_$OTHER_ABI" "$JNILIBS_BASE/$OTHER_ABI"
fi
done
fi
done
echo ""
echo ">>> Build artifacts:"
find gen/android -name "*.apk" -exec ls -lh {} \; 2>/dev/null
ls -lh "$APK_OUTPUT_DIR/"*.apk 2>/dev/null || echo " (none)"
'
# Locate the produced APK
APK=$(find "$BASE_DIR/data/source/desktop/src-tauri/gen/android" -name "*.apk" -type f 2>/dev/null | head -1)
if [ -z "$APK" ] || [ ! -f "$APK" ]; then
# ─── Collect and upload APKs ────────────────────────────────────────────
# target/ is mounted from cache, not source
APK_OUTPUT="$BASE_DIR/data/cache/target/apk-output"
APK_LIST=$(find "$APK_OUTPUT" -name "wzp-tauri-*.apk" -type f 2>/dev/null | sort)
if [ -z "$APK_LIST" ]; then
LOG_URL=$(upload_to_rustypaste "$LOG_FILE" || echo "")
if [ -n "$LOG_URL" ]; then
notify "WZP Tauri Android build [$GIT_HASH]: no APK produced
@@ -248,35 +400,56 @@ log: $LOG_URL"
fi
exit 1
fi
APK_SIZE=$(du -h "$APK" | cut -f1)
# Upload each APK and collect URLs
NOTIFY_MSG="WZP Tauri Android build OK [$GIT_HASH] ($BUILD_ARCH)"
APK_PATHS=""
for APK in $APK_LIST; do
APK_NAME=$(basename "$APK")
APK_SIZE=$(du -h "$APK" | cut -f1)
RUSTY_URL=$(upload_to_rustypaste "$APK" || echo "")
if [ -n "$RUSTY_URL" ]; then
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE)
$RUSTY_URL"
NOTIFY_MSG="$NOTIFY_MSG
$APK_NAME ($APK_SIZE): $RUSTY_URL"
else
notify "WZP Tauri Android build OK [$GIT_HASH] ($APK_SIZE) — rustypaste upload skipped"
NOTIFY_MSG="$NOTIFY_MSG
$APK_NAME ($APK_SIZE) — upload skipped"
fi
APK_PATHS="$APK_PATHS $APK"
done
notify "$NOTIFY_MSG"
# Print path so the local script can grab it
# Print paths so the local script can grab them
for APK in $APK_LIST; do
echo "APK_REMOTE_PATH=$APK"
done
REMOTE_SCRIPT
ssh_cmd "chmod +x /tmp/wzp-tauri-build.sh"
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, release=$BUILD_RELEASE)"
log "Triggering remote build (branch=$BRANCH)..."
notify_local "WZP Tauri Android build dispatched (branch=$BRANCH, arch=$BUILD_ARCH, release=$BUILD_RELEASE)"
log "Triggering remote build (branch=$BRANCH, arch=$BUILD_ARCH)..."
# Run; capture full output, last line is APK_REMOTE_PATH=...
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE'" || true)
# Run; last lines are APK_REMOTE_PATH=... (one per arch)
REMOTE_OUTPUT=$(ssh_cmd "/tmp/wzp-tauri-build.sh '$BRANCH' '$DO_PULL' '$REBUILD_RUST' '$DO_INIT' '$BUILD_RELEASE' '$BUILD_ARCH'" || true)
echo "$REMOTE_OUTPUT" | tail -60
APK_REMOTE=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | tail -1 | cut -d= -f2-)
if [ -n "$APK_REMOTE" ]; then
log "Downloading APK to $LOCAL_OUTPUT/wzp-tauri.apk..."
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/wzp-tauri.apk"
echo " $LOCAL_OUTPUT/wzp-tauri.apk ($(du -h "$LOCAL_OUTPUT/wzp-tauri.apk" | cut -f1))"
else
# Download all produced APKs
APK_REMOTES=$(echo "$REMOTE_OUTPUT" | grep '^APK_REMOTE_PATH=' | cut -d= -f2-)
if [ -z "$APK_REMOTES" ]; then
log "No APK produced — see ntfy / remote log /tmp/wzp-tauri-build.log"
exit 1
fi
DOWNLOADED=0
echo "$APK_REMOTES" | while IFS= read -r APK_REMOTE; do
[ -z "$APK_REMOTE" ] && continue
APK_NAME=$(basename "$APK_REMOTE")
log "Downloading $APK_NAME..."
scp $SSH_OPTS "$REMOTE_HOST:$APK_REMOTE" "$LOCAL_OUTPUT/$APK_NAME"
echo " $LOCAL_OUTPUT/$APK_NAME ($(du -h "$LOCAL_OUTPUT/$APK_NAME" | cut -f1))"
DOWNLOADED=$((DOWNLOADED + 1))
done
log "Done! APKs in $LOCAL_OUTPUT/"
ls -lh "$LOCAL_OUTPUT"/wzp-tauri-*.apk 2>/dev/null || true

View File

@@ -22,6 +22,7 @@ set -euo pipefail
# ./scripts/build.sh --init First-time setup (clone + Docker image)
# ./scripts/build.sh --install Download APK + adb install locally
# ./scripts/build.sh --release Release APK (not debug)
# ./scripts/build.sh --android64 Release arm64 APK (shorthand for --android --release)
# =============================================================================
NTFY_TOPIC="https://ntfy.sh/wzp"
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
--install) DO_INSTALL=1 ;;
--init) DO_INIT=1 ;;
--android) BUILD_ANDROID=1; BUILD_LINUX=0 ;;
--android64) BUILD_ANDROID=1; BUILD_LINUX=0; BUILD_RELEASE=1; BRANCH="main" ;;
--linux) BUILD_ANDROID=0; BUILD_LINUX=1 ;;
--all) BUILD_ANDROID=1; BUILD_LINUX=1 ;;
--release) BUILD_RELEASE=1 ;;